10 Commits

Author SHA1 Message Date
super
70e902e7ea 更新这个货源 2026-05-22 09:41:31 +08:00
super
73d25fcbcb 提交一些更改 2026-05-20 09:05:28 +08:00
super
a2ebfc0b81 Merge branch 'master' of https://gitee.com/TeaCodeNice/crawler-plugin 2026-05-15 16:06:15 +08:00
super
14db1e0cb1 修改完善这个专利部分 2026-05-15 16:06:12 +08:00
铭坤
0d63d6d75a modified: amazon/similar_asin.py 2026-05-15 11:33:02 +08:00
铭坤
79b177b345 modified: amazon/chrome_base.py
modified:   amazon/detail_spider.py
	modified:   amazon/match_action.py
	modified:   amazon/similar_asin.py
	deleted:    amazon/user_data/chrome_data/BrowserMetrics-spare.pma
	deleted:    amazon/user_data/chrome_data/BrowserMetrics/BrowserMetrics-69FAEF07-558.pma
	deleted:    amazon/user_data/chrome_data/Crashpad/metadata
	deleted:    amazon/user_data/chrome_data/Crashpad/settings.dat
2026-05-13 16:51:29 +08:00
super
e4e01c1686 软件前端更新 2026-05-12 23:25:12 +08:00
super
0d78d63437 后台管理接口修复 后端BUG修复 2026-05-12 21:03:42 +08:00
super
5c990e651e 提交货源采集更新 2026-05-10 01:00:08 +08:00
super
a4e4745921 更新处理相关内容 2026-05-09 00:20:52 +08:00
136 changed files with 13038 additions and 12363 deletions

1
.gitignore vendored
View File

@@ -108,3 +108,4 @@ xlsx/
OPS_REDIS_MYSQL_OPTIMIZATION_NOTES.md
架构.md
*ts.%
.omc

View File

@@ -1,9 +1,9 @@
base_url=http://8.136.19.173:15124
base_url=http://47.110.241.161:15124
workflow_id=7608812635877900322
mysql_host=8.136.19.173
mysql_host=47.110.241.161
mysql_user=aiimage
proxy_url=https://api.jikip.com/ip-get?num=1&minute=1&format=json&area=all&protocol=1&mode=2&key=t24g6gi44ubufd8
proxy_url=https://api.jikip.com/ip-get?num=1&minute=3&format=json&area=all&protocol=1&mode=2&key=t24g6gi44ubufd8
proxy_mode=2
zn_company=rongchuang123
@@ -11,10 +11,10 @@ zn_username=%E8%87%AA%E5%8A%A8%E5%8C%96_Robot
client_name=ShuFuAI
# java_api_base=http://47.111.163.154:18080
# java_api_base=http://127.0.0.1:18080
# java_api_base=http://8.136.19.173:18080
java_api_base=http://121.196.149.225:18080
java_api_base=http://127.0.0.1:18080
# java_api_base=http://121.196.149.225:18080

View File

@@ -1,6 +1,6 @@
base_url=http://8.136.19.173:15124
base_url=http://47.111.163.154:15124
workflow_id=7608812635877900322
mysql_host=8.136.19.173
mysql_host=47.111.163.154
mysql_user=aiimage
proxy_url=https://api.jikip.com/ip-get?num=1&minute=1&format=json&area=all&protocol=1&mode=2&key=t24g6gi44ubufd8
@@ -10,6 +10,6 @@ client_name=ShuFuAI
java_api_base=http://127.0.0.1:18080
# java_api_base=http://8.136.19.173:18080
# java_api_base=http://47.111.163.154:18080

View File

@@ -1,5 +1,5 @@
workflow_id=7608812635877900322
mysql_host=8.136.19.173
mysql_host=47.111.163.154
mysql_user=aiimage
proxy_url=https://api.jikip.com/ip-get?num=1&minute=1&format=json&area=all&protocol=1&mode=2&key=t24g6gi44ubufd8
@@ -13,6 +13,6 @@ client_name=ShuFuAI
java_api_base=http://47.111.163.154:18080
# java_api_base=http://127.0.0.1:18080
# java_api_base=http://8.136.19.173:18080
# java_api_base=http://47.111.163.154:18080

View File

@@ -116,20 +116,20 @@ class ChromeAmzoneBase:
# print(f"当前地址信息: {current_text}")
# 先检查标识
if mark is not None and mark in current_text:
print(f"邮编检测到标识: {mark},无需修改")
self.log(f"邮编检测到标识: {mark},无需修改")
return True
# 检查是否已经包含目标邮编
if zip_code in current_text:
print(f"邮编已经设置为: {zip_code},无需修改")
self.log(f"邮编已经设置为: {zip_code},无需修改")
return True
# 需要设置邮编
print(f"正在设置邮编为: {zip_code}")
self.log(f"正在设置邮编为: {zip_code}")
# 点击地址选择按钮
location_link = self.tab.ele('xpath://a[@id="nav-global-location-popover-link"]', timeout=10)
if not location_link:
print("找不到地址设置按钮")
self.log("找不到地址设置按钮")
return False
location_link.click()

View File

@@ -273,18 +273,16 @@ class SpiderTask(TaskBase):
is_done = True
self.post_result(task_id=task_id, chunkIndex=len(groups), chunkTotal=len(groups),
asin=asin, item_data=result, is_done=is_done)
try:
chrome.close()
except Exception as e:
print("退出浏览器出错",e)
# 更新已处理店铺数
if task_id in runing_task:
runing_task[task_id]["processed_shops"] += 1
except Exception as e:
self.log(f"处理店铺 {task_id} 失败: {str(e)}", "ERROR")
self.log(traceback.format_exc(), "ERROR")
try:
chrome.close()
except Exception as e:
print("退出浏览器出错", e)
# 更新任务状态
if task_id in runing_task:
if runing_task[task_id].get("stop_requested", False):
@@ -351,7 +349,7 @@ class SpiderTask(TaskBase):
time.sleep(2)
self.log(f"已达到最大重试次数,结果回传最终失败", "ERROR")
raise RuntimeError("已达到最大重试次数,结果回传最终失败")
# raise RuntimeError("已达到最大重试次数,结果回传最终失败")
if __name__ == '__main__':
# spide = SpiderTask()

View File

@@ -30,7 +30,7 @@ class AmzoneMatchAction(AmamzonBase):
except Exception as e:
print(f"{self.mark_name}】等待加载中消失出错", e)
def run_page_action(self):
def run_page_action(self,skip_asin=[]):
self.log(f"==================={self.mark_name}=======================")
self.log("开始执行...")
num = 0
@@ -86,7 +86,13 @@ class AmzoneMatchAction(AmamzonBase):
# solve_problem = sku_ele.eles('xpath:.//kat-link[@label="解决商品信息问题"]')
asin = sku_ele.ele('xpath:.//div[contains(@class,"JanusSplitBox-module__container")]//div[contains(@class,"JanusSplitBox-module__panel--") and contains(string(.),"ASIN")]/..//div[last()]',timeout=3).text
self.log(f"{self.mark_name}ASIN {asin} 找到....")
self.log(f"ASIN {asin} 找到....")
if asin in skip_asin:
self.log(f"ASIN {asin} 跳过....")
yield (asin,"有最低价跳过")
self.already_asin.add(asin)
continue
if asin in self.already_asin:
self.log(f"{self.mark_name}{asin} 已经处理过了,跳过")
continue
@@ -228,6 +234,9 @@ class MatchTak(TaskBase):
shop_name = shop_item.get("shopName", "未知店铺")
company_name = shop_item.get("companyName", "")
skip_asins_by_country = shop_item.get("skipAsinsByCountry",{})
skipAsinDetailsByCountry = shop_item.get("skipAsinDetailsByCountry",{})
if not company_name:
self.log(f"店铺 {shop_name} 的公司名称为空,跳过", "WARNING")
return
@@ -252,6 +261,11 @@ class MatchTak(TaskBase):
self.log(f"检测到任务 {task_id} 的暂停请求,停止处理国家", "WARNING")
break
skip_asin = skip_asins_by_country.get(country_code)
if skipAsinDetailsByCountry :
skipAsinDetails = {i.get("asin"):i.get("minimumPrice") for i in skipAsinDetailsByCountry.get(country_code)}
else:
skipAsinDetails = {}
# 打开店铺
driver = self.open_shop(cls=AmzoneMatchAction,max_retries=max_retries, company_name=company_name,
shop_name=shop_name, iskill=iskill)
@@ -265,7 +279,7 @@ class MatchTak(TaskBase):
for _ in range(max_retries):
try:
self.process_country(driver, country_code, task_id, shop_name,risk_listing_filter,limit,
target_url=current_url)
target_url=current_url,skip_asin=skip_asin,skipAsinDetails=skipAsinDetails)
driver.reset_already_asin()
driver.close_store()
break
@@ -297,7 +311,8 @@ class MatchTak(TaskBase):
del runing_shop[shop_name]
self.log(f"店铺 {shop_name} 已从执行列表中移除")
def process_country(self, driver, country_code, task_id, shop_name, risk_listing_filter,limit=None,target_url=None):
def process_country(self, driver, country_code, task_id, shop_name, risk_listing_filter,limit=None,target_url=None,
skip_asin=[],skipAsinDetails={}):
"""处理单个国家的审批任务
"""
@@ -339,7 +354,7 @@ class MatchTak(TaskBase):
result = []
# 处理所有需要审批的商品通过yield获取结果
for asin, status in driver.run_page_action():
for asin, status in driver.run_page_action(skip_asin):
# 检查是否收到暂停请求
if task_id in runing_task and runing_task[task_id].get("stop_requested", False):
self.log(f"检测到任务 {task_id} 的暂停请求停止处理ASIN", "WARNING")
@@ -353,10 +368,13 @@ class MatchTak(TaskBase):
runing_task[task_id]["processed_asins"] += 1
runing_task[task_id]["failed_count"] += 1
minimumPrice = ""
if "有最低价跳过" in status:
minimumPrice = skipAsinDetails.get(asin)
result.append({
"asin": asin,
"status": status,
"minimumPrice": minimumPrice,
"done": False
})

View File

@@ -29,9 +29,9 @@ except ImportError:
CONFIG_PROXY_MODE = 1
# Forbidden 后 3 分钟内统一使用代理:记录代理生效截止时间与当前代理
_FORBIDDEN_PROXY_UNTIL = 0.0
_FORBIDDEN_PROXY_DICT = None
_FORBIDDEN_PROXY_MINUTES = 0.5
_FORBIDDEN_PROXY_UNTIL_Similar = 0.0
_FORBIDDEN_PROXY_DICT_Similar = None
_FORBIDDEN_PROXY_MINUTES_Similar = 0.5
class ChromeAmzone(ChromeAmzoneBase):
@@ -92,10 +92,10 @@ class ChromeAmzone(ChromeAmzoneBase):
"""
"fishkeeper Quick Aquarium Siphon Pump Gravel Cleaner - 256GPH Adjustable Powerful Fish Tank Vacuum Gravel Cleaning Kit for Aquarium Water Changer, Sand Cleaner, Dirt Removal : Amazon.co.uk: Pet Supplies"
标题 :
"""
global _FORBIDDEN_PROXY_UNTIL, _FORBIDDEN_PROXY_DICT
global _FORBIDDEN_PROXY_UNTIL_Similar, _FORBIDDEN_PROXY_DICT_Similar
headers = {
"accept": "application/json, text/plain, */*",
"accept-language": "zh-CN,zh;q=0.9,en;q=0.8",
@@ -141,21 +141,21 @@ class ChromeAmzone(ChromeAmzoneBase):
data = {
"imageBase64" : imageBase64
}
proxies = None
# 若 3 分钟内曾出现 Forbidden则直接使用当时保存的代理
now = time.time()
if now < _FORBIDDEN_PROXY_UNTIL and _FORBIDDEN_PROXY_DICT:
proxies = _FORBIDDEN_PROXY_DICT
if now < _FORBIDDEN_PROXY_UNTIL_Similar and _FORBIDDEN_PROXY_DICT_Similar:
proxies = _FORBIDDEN_PROXY_DICT_Similar
print("处于 Forbidden 代理窗口内,直接使用代理", proxies)
# 发送第一次请求
try:
response = requests_frp.post(url, headers=headers, params=params, json=data, impersonate="chrome101",
cookies=cookie, proxies=proxies)
response.encoding = "utf-8"
# 检查响应状态码和数据有效性
need_retry = False
if response.status_code != 200:
@@ -174,13 +174,13 @@ class ChromeAmzone(ChromeAmzoneBase):
except Exception as e:
print(f"解析响应JSON失败: {e}")
need_retry = True
# 如果需要重试且配置了代理URL
if need_retry and CONFIG_PROXY_URL and not proxies:
try:
proxy_resp = requests.get(CONFIG_PROXY_URL, timeout=10)
print("代理请求结果->", proxy_resp.text)
# 模式 2账号密码代理接口返回 JSON
if CONFIG_PROXY_MODE == 2:
resp_json = proxy_resp.json()
@@ -206,17 +206,17 @@ class ChromeAmzone(ChromeAmzoneBase):
"http": f"http://{proxy_ip}",
"https": f"http://{proxy_ip}",
}
if proxies:
print("使用代理重试请求")
response = requests_frp.post(url, headers=headers, params=params, json=data,
response = requests_frp.post(url, headers=headers, params=params, json=data,
impersonate="chrome101", cookies=cookie, proxies=proxies)
response.encoding = "utf-8"
print("代理重试结果状态码:", response.status_code)
# 记录 3 分钟内都使用该代理
_FORBIDDEN_PROXY_UNTIL = now + _FORBIDDEN_PROXY_MINUTES * 60
_FORBIDDEN_PROXY_DICT = proxies
_FORBIDDEN_PROXY_UNTIL_Similar = now + _FORBIDDEN_PROXY_MINUTES_Similar * 60
_FORBIDDEN_PROXY_DICT_Similar = proxies
except Exception as e:
print("获取代理或重试失败:", e)
@@ -240,6 +240,7 @@ class ChromeAmzone(ChromeAmzoneBase):
'image_url': "",
'title': "",
'category' : '',
'sku' : '',
'success' : True
}
@@ -252,6 +253,11 @@ class ChromeAmzone(ChromeAmzoneBase):
imge_ele = self.tab.ele('xpath://div[@id="imgTagWrapperId"]//img',timeout=20)
image_url = imge_ele.attr("src")
data["image_url"] = image_url
# sku
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["sku"] = sku_ele_ls[0].text
category = self.tab.eles('xpath://div[@id="wayfinding-breadcrumbs_feature_div"]//span[@class="a-list-item"]//a[@class="a-link-normal a-color-tertiary"]',timeout=20)
if len(category) > 0:
data["category"] = category[0].text
@@ -290,7 +296,7 @@ class ChromeAmzone(ChromeAmzoneBase):
domain = base_url.split("/dp/")[0]
# 拼接新的URL
product_url = f"{domain}/dp/{asin}"
print(f"正在访问: {product_url}")
self.log(f"正在访问: {product_url}")
# 打开链接
self.tab.get(product_url)
@@ -299,13 +305,13 @@ class ChromeAmzone(ChromeAmzoneBase):
self.close_init_popup()
# 2. 切换国家/设置邮编
print(f"正在检查并设置邮编: {zip_code},标识: {mark}")
self.log(f"正在检查并设置邮编: {zip_code},标识: {mark}")
self._set_zip_code(zip_code, mark)
# self.tab.wait.doc_loaded(timeout=5, raise_err=False)
# 3. 抓取数据
print("正在抓取商品数据...")
self.log("正在抓取商品数据...")
data = self._scrape_data()
# 判断是否采集标题出错
@@ -327,7 +333,7 @@ class ChromeAmzone(ChromeAmzoneBase):
category=data["category"],
imageBase64 = iamge_base64
)
self.log(f"Aliprice 扩展数据获取:{resp_data}")
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)
@@ -407,22 +413,6 @@ class SimilarAsinTask(TaskBase):
return payload.get("data") or {}
raise RuntimeError(f"获取货源查询解析载荷失败: {payload}")
@staticmethod
def _count_payload_rows(data):
rows = data.get("rows") or data.get("items") or []
if isinstance(rows, list) and rows:
return len(rows)
groups = data.get("groups") or []
if not isinstance(groups, list):
return 0
total = 0
for group in groups:
if isinstance(group, dict) and isinstance(group.get("items"), list):
total += len(group.get("items"))
return total
def _merge_parsed_payload(self, data, parsed_payload):
payload_rows = parsed_payload.get("allItems") or parsed_payload.get("items") or []
merged = {
@@ -445,28 +435,15 @@ class SimilarAsinTask(TaskBase):
try:
data = task_data.get("data", {})
task_id = data.get("taskId")
if task_id:
queued_rows = self._count_payload_rows(data)
try:
parsed_payload = self.fetch_parsed_payload(task_id, data.get("user_id") or data.get("userId") or 1)
data = self._merge_parsed_payload(data, parsed_payload)
self.log(
f"similar asin task {task_id} loaded full parsed payload from Java, queuedRows={queued_rows}, fullRows={self._count_payload_rows(data)}"
)
except Exception as e:
if not queued_rows:
raise
self.log(
f"similar asin task {task_id} failed to load full parsed payload, fallback to queued rows={queued_rows}: {e}",
"WARNING"
)
parsed_payload = self.fetch_parsed_payload(task_id, data.get("user_id") or data.get("userId") or 1)
data = self._merge_parsed_payload(data, parsed_payload)
groups = self.normalize_groups(data)
print(groups)
if not task_id:
self.log("任务ID为空跳过", "WARNING")
return
self.log(f"开始处理爬取任务 {task_id}{len(groups)} 个任务")
if not groups:
@@ -507,19 +484,23 @@ class SimilarAsinTask(TaskBase):
return_data = {
'image_url': "",
'title': ""
'title': "",
'category' : '',
'sku' : '',
'success' : False,
'similar_data' : []
}
asin = value.get("asin")
country = value.get("country")
return_data = None
for _ in range(max_retry):
try:
return_data = chrome.run(country, asin) or {}
return_data = chrome.run(country, asin,total_page=2) or {}
self.log(f"抓取结果->{return_data}")
break
except Exception as e:
if "与页面的连接已断开" in str(e):
chrome = ChromeAmzone()
# 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"):
@@ -533,6 +514,7 @@ class SimilarAsinTask(TaskBase):
"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"),
@@ -552,11 +534,11 @@ class SimilarAsinTask(TaskBase):
"items": group_item
}
print("================")
# print("================")
result.append(res)
print("================")
# print("================")
is_done = gp_index == len(groups)-1
if len(result) > 20 or is_done:
if len(result) > 5 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 = []
@@ -567,10 +549,7 @@ class SimilarAsinTask(TaskBase):
self.post_result(task_id=task_id, chunkIndex=len(groups), chunkTotal=len(groups),
asin=asin, item_data=result, is_done=is_done)
try:
chrome.close()
except Exception as e:
print("退出浏览器出错",e)
# 更新已处理店铺数
if task_id in runing_task:
runing_task[task_id]["processed_shops"] += 1
@@ -578,6 +557,11 @@ class SimilarAsinTask(TaskBase):
self.log(f"处理店铺 {task_id} 失败: {str(e)}", "ERROR")
self.log(traceback.format_exc(), "ERROR")
try:
chrome.close()
except Exception as e:
print("退出浏览器出错", e)
# 更新任务状态
if task_id in runing_task:
if runing_task[task_id].get("stop_requested", False):
@@ -585,7 +569,8 @@ class SimilarAsinTask(TaskBase):
self.log(f"任务 {task_id} 已被暂停!")
else:
runing_task[task_id]["status"] = "completed"
self.log(f"任务 {task_id} 处理完成!")
self.log(f"任务 {task_id} 处理完成!")
except Exception as e:
self.log(f"任务处理失败: {traceback.format_exc()}", "ERROR")
@@ -650,7 +635,7 @@ if __name__ == '__main__':
spide = ChromeAmzone()
resp = spide.run(
country="", asin="B0F79LCB3X", total_page=2
country="", asin="B0CJ8SNXXV", total_page=2
)
print(resp)
# task_data = {

View File

@@ -1 +1 @@
{"version": "1.0.56"}
{"version": "1.0.13"}

View File

@@ -0,0 +1,59 @@
# Multi-Server Start Commands
Use the same packaged jar on both servers. Do not switch code comments before packaging.
Requirements:
- Build once: `mvn clean package -DskipTests`
- Use profile: `server`
- Put JVM options before `-jar`
- Pass `AIIMAGE_INSTANCE_ID` and Redis address from the startup command
- Result file job MQ is expected to stay enabled in packaged `server` deployments
1Panel server 121:
Environment variables:
```env
SPRING_PROFILES_ACTIVE=server
AIIMAGE_INSTANCE_ID=server-121
AIIMAGE_REDIS_HOST=192.168.0.172
AIIMAGE_REDIS_PORT=16379
AIIMAGE_REDIS_PASSWORD=B6COTcY094TYe545
AIIMAGE_REDIS_DATABASE=0
AIIMAGE_SERVER_PORT=18080
AIIMAGE_STORAGE_LOCAL_TEMP_DIR=/app/data/tmp
AIIMAGE_RESULT_FILE_JOB_MQ_ENABLED=true
AIIMAGE_ROCKETMQ_NAME_SERVER=121.196.149.225:9876
```
Recommended startup command:
```bash
java -Xmx2048M -Xms2048M -jar /app/aiimage-backend-0.0.1-SNAPSHOT.jar
```
Baota server 111:
```bash
/www/server/java/jdk-21.0.2/bin/java -Xmx1024M -Xms256M -jar /app/java/aiimage-backend-0.0.1-SNAPSHOT.jar --spring.profiles.active=server --aiimage.instance-id=server-111 --spring.data.redis.host=47.111.163.154 --spring.data.redis.port=16379 --spring.data.redis.password=B6COTcY094TYe545 --server.port=18080 --aiimage.storage.local-temp-dir=/app/data/tmp
```
Optional Baota server 121 command:
```bash
/www/server/java/jdk-21.0.2/bin/java -Xmx1024M -Xms256M -jar /app/java/aiimage-backend-0.0.1-SNAPSHOT.jar --spring.profiles.active=server --aiimage.instance-id=server-121 --spring.data.redis.host=192.168.0.172 --spring.data.redis.port=16379 --spring.data.redis.password=B6COTcY094TYe545 --server.port=18080 --aiimage.storage.local-temp-dir=/app/data/tmp
```
Successful startup should show a line similar to:
```text
[instance] resolved instanceId=server-121 source=configured
```
or:
```text
[instance] resolved instanceId=server-111 source=configured
```
If the instanceId in the log does not match the startup command, the actual process was not started with the expected command.

View File

@@ -138,6 +138,7 @@
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
<parameters>true</parameters>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>

View File

@@ -8,11 +8,21 @@ public class BusinessException extends RuntimeException {
this(null, message);
}
public BusinessException(String message, Throwable cause) {
super(message, cause);
this.code = null;
}
public BusinessException(Integer code, String message) {
super(message);
this.code = code;
}
public BusinessException(Integer code, String message, Throwable cause) {
super(message, cause);
this.code = code;
}
public Integer getCode() {
return code;
}

View File

@@ -6,6 +6,9 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.async.AsyncRequestNotUsableException;
import java.io.IOException;
@Slf4j
@RestControllerAdvice
@@ -34,9 +37,33 @@ public class GlobalExceptionHandler {
return ApiResponse.fail(ex.getMessage());
}
@ExceptionHandler(AsyncRequestNotUsableException.class)
public void handleAsyncRequestNotUsableException(AsyncRequestNotUsableException ex) {
log.debug("Client disconnected before response completed: {}", ex.getMessage());
}
@ExceptionHandler(Exception.class)
public ApiResponse<Void> handleException(Exception ex) {
if (isClientAbort(ex)) {
log.debug("Client disconnected before response completed: {}", ex.getMessage());
return ApiResponse.fail("客户端已断开连接");
}
log.error("Unhandled exception", ex);
return ApiResponse.fail("服务异常: " + ex.getMessage());
}
private boolean isClientAbort(Throwable ex) {
Throwable cursor = ex;
while (cursor != null) {
String className = cursor.getClass().getName();
String message = cursor.getMessage() == null ? "" : cursor.getMessage().toLowerCase();
if (cursor instanceof AsyncRequestNotUsableException
|| cursor instanceof IOException && (message.contains("broken pipe") || message.contains("connection reset"))
|| className.contains("ClientAbortException")) {
return true;
}
cursor = cursor.getCause();
}
return false;
}
}

View File

@@ -0,0 +1,216 @@
package com.nanri.aiimage.common.util;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.function.BiConsumer;
import java.util.function.Function;
/**
* Coze 回流数据按 ID 分组传播工具。
*
* <p>业务背景:
* 解析行按 Excel 行顺序排列ID 形如 "1"、"1_1"、"1_2"、"2"、"2_1"。
* 同一 baseId第一个下划线 '_' 之前的部分)的连续行视为同一组——即所谓
* "分组只看下一行数据":按行顺序、相邻同 baseId 行才是同组,遇到不同 baseId 立即开新组。
*
* <p>传播规则:
* <ol>
* <li>单行组(孤立 baseId跳过不做任何处理。</li>
* <li>组内任一行的目标字段值"包含" {@code hitValues} 中某个候选值,
* 则把该组所有行的目标字段统一覆盖为该候选值(标准值)。</li>
* <li>多个候选值同时命中时,按 {@code hitValues} 列表顺序优先匹配
* (列表越靠前优先级越高,比如专利场景把"已侵权"放在"侵权"前面,
* 避免短串先命中导致丢失"已"字)。</li>
* <li>无法解析到对应 result 行resolver 返回 null的 parsed 行不参与
* 组内匹配,但仍占据分组位置——不会打断同 baseId 的连续性,
* 也不会被强行写入。</li>
* </ol>
*
* <p>使用方式:
* <pre>
* // 专利结论列:组内任一行结论命中 "已侵权" 或 "侵权",组内都改为该标准值
* CozeGroupResultPropagator.propagateByGroup(
* receivedRows,
* AppearancePatentParsedRowVo::getDisplayId,
* row -&gt; findResultRow(row, resultMap),
* AppearancePatentResultRowDto::getConclusion,
* AppearancePatentResultRowDto::setConclusion,
* List.of("已侵权", "侵权"),
* "[appearance-patent] taskId=" + task.getId()
* );
* </pre>
*/
public final class CozeGroupResultPropagator {
private static final Logger log = LoggerFactory.getLogger(CozeGroupResultPropagator.class);
/**
* 否定前缀关键字。若当前值同时包含 standard 和这些关键字之一,则不视为命中。
* 用于规避"没有侵权""不侵权""未发现明显侵权风险"等被 contains 误判为命中"侵权"的情况。
*/
private static final List<String> NEGATIVE_KEYWORDS = Arrays.asList("没有", "", "");
private CozeGroupResultPropagator() {
}
/**
* 按 baseId 连续分组、命中即组内传播。无日志上下文标签的便捷重载。
*/
public static <P, R> int propagateByGroup(List<P> parsedRows,
Function<P, String> displayIdGetter,
Function<P, R> resultRowResolver,
Function<R, String> fieldGetter,
BiConsumer<R, String> fieldSetter,
List<String> hitValues) {
return propagateByGroup(parsedRows, displayIdGetter, resultRowResolver,
fieldGetter, fieldSetter, hitValues, null);
}
/**
* 按 baseId 连续分组、命中即组内传播。
*
* @param parsedRows 解析行(按 Excel 行顺序)
* @param displayIdGetter parsed 行 → ID 字符串(用于计算 baseId
* @param resultRowResolver parsed 行 → 对应的 result DTO找不到返回 null
* @param fieldGetter result DTO → 当前目标字段值
* @param fieldSetter result DTO ← 新目标字段值
* @param hitValues 需要传播的标准值列表,靠前优先匹配(包含语义)
* @param logTag 日志前缀(如 "[appearance-patent] taskId=9975"null 则只在汇总层打日志
* @param <P> parsed 行类型
* @param <R> result DTO 类型
* @return 实际被改写的 result 行数(用于上层日志埋点)
*/
public static <P, R> int propagateByGroup(List<P> parsedRows,
Function<P, String> displayIdGetter,
Function<P, R> resultRowResolver,
Function<R, String> fieldGetter,
BiConsumer<R, String> fieldSetter,
List<String> hitValues,
String logTag) {
if (parsedRows == null || parsedRows.isEmpty()
|| displayIdGetter == null || resultRowResolver == null
|| fieldGetter == null || fieldSetter == null
|| hitValues == null || hitValues.isEmpty()) {
return 0;
}
String tagPrefix = (logTag == null || logTag.isEmpty()) ? "" : logTag + " ";
int totalUpdated = 0;
int n = parsedRows.size();
int i = 0;
while (i < n) {
String currentBaseId = baseId(displayIdGetter.apply(parsedRows.get(i)));
int groupStart = i;
int j = i + 1;
// 连续同 baseId 视为一组
while (j < n && currentBaseId.equals(baseId(displayIdGetter.apply(parsedRows.get(j))))) {
j++;
}
int groupEnd = j; // 半开区间 [groupStart, groupEnd)
i = j;
// 单行组跳过
if (groupEnd - groupStart < 2) {
continue;
}
// 收集组内所有能解析到 result DTO 的行,同时记录显示 ID 方便日志
List<R> groupResults = new ArrayList<>(groupEnd - groupStart);
List<String> groupDisplayIds = new ArrayList<>(groupEnd - groupStart);
for (int k = groupStart; k < groupEnd; k++) {
P parsedRow = parsedRows.get(k);
R resultRow = resultRowResolver.apply(parsedRow);
if (resultRow != null) {
groupResults.add(resultRow);
groupDisplayIds.add(displayIdGetter.apply(parsedRow));
}
}
if (groupResults.isEmpty()) {
continue;
}
// 按 hitValues 顺序检测组内是否有命中(包含语义),命中即定下"标准值"。
// 命中需同时满足:当前值包含 standard 且不含任何否定关键字("没有"/"不"/"未"
// 避免"没有侵权""不侵权""未发现明显侵权风险"这类被 contains 误判为命中。
String matchedStandardValue = null;
outer:
for (String standard : hitValues) {
if (standard == null || standard.isEmpty()) {
continue;
}
for (int k = 0; k < groupResults.size(); k++) {
R row = groupResults.get(k);
String currentValue = fieldGetter.apply(row);
if (currentValue == null || !currentValue.contains(standard)) {
continue;
}
if (containsNegative(currentValue)) {
log.debug("{}group-propagate skip negative baseId={} displayId={} currentValue={} candidate={}",
tagPrefix, currentBaseId, groupDisplayIds.get(k), currentValue, standard);
continue;
}
matchedStandardValue = standard;
break outer;
}
}
if (matchedStandardValue == null) {
continue;
}
// 把组内所有 result DTO 的目标字段统一覆盖为标准值,并逐行打日志
int groupUpdated = 0;
for (int idx = 0; idx < groupResults.size(); idx++) {
R row = groupResults.get(idx);
String currentValue = fieldGetter.apply(row);
if (matchedStandardValue.equals(currentValue)) {
continue;
}
fieldSetter.accept(row, matchedStandardValue);
groupUpdated++;
totalUpdated++;
log.info("{}group-propagate row baseId={} displayId={} oldValue={} newValue={}",
tagPrefix, currentBaseId, groupDisplayIds.get(idx),
currentValue, matchedStandardValue);
}
if (groupUpdated > 0) {
log.info("{}group-propagate group hit baseId={} groupSize={} standardValue={} updatedRows={}",
tagPrefix, currentBaseId, groupResults.size(),
matchedStandardValue, groupUpdated);
}
}
return totalUpdated;
}
/**
* 判断当前值是否含任意否定关键字。命中关键字即视为"未发生"语义,
* 上层应跳过该值,避免被 {@link String#contains(CharSequence)} 误判命中标准值。
*/
private static boolean containsNegative(String value) {
if (value == null || value.isEmpty()) {
return false;
}
for (String neg : NEGATIVE_KEYWORDS) {
if (value.contains(neg)) {
return true;
}
}
return false;
}
/**
* 取 ID 中第一个 '_' 之前的部分。例如 "1" → "1""1_2" → "1"null → ""。
* 与两个 TaskService 内的 {@code baseId} 实现保持一致。
*/
private static String baseId(String id) {
if (id == null) {
return "";
}
String s = id.trim();
int idx = s.indexOf('_');
return idx > 0 ? s.substring(0, idx) : s;
}
}

View File

@@ -0,0 +1,114 @@
package com.nanri.aiimage.common.util;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.function.Function;
import java.util.function.Predicate;
public final class FailedStatusRowFilter {
private static final String STATUS_HEADER_CN = "\u72b6\u6001";
private static final String FAILED_STATUS_ERROR = "\u9519\u8bef";
private static final String FAILED_STATUS_FAILED_CN = "\u5931\u8d25";
private static final String FAILED_STATUS_FAILED_EN = "failed";
private static final String NO_MATCHED_ROWS_MESSAGE =
"\u72b6\u6001\u5217\u8fc7\u6ee4\u540e\u672a\u627e\u5230 \u9519\u8bef/\u5931\u8d25/FAILED \u6570\u636e";
private static final Set<String> STATUS_HEADER_ALIASES = Set.of(
normalizeHeader(STATUS_HEADER_CN),
normalizeHeader("status")
);
private static final Set<String> FAILED_STATUS_VALUES = Set.of(
normalizeValue(FAILED_STATUS_ERROR),
normalizeValue(FAILED_STATUS_FAILED_CN),
normalizeValue(FAILED_STATUS_FAILED_EN)
);
private FailedStatusRowFilter() {
}
public static int findStatusColumnIndex(List<String> headers) {
if (headers == null || headers.isEmpty()) {
return -1;
}
for (int i = 0; i < headers.size(); i++) {
if (STATUS_HEADER_ALIASES.contains(normalizeHeader(headers.get(i)))) {
return i;
}
}
return -1;
}
public static boolean matchesFailedStatus(String value) {
return FAILED_STATUS_VALUES.contains(normalizeValue(value));
}
public static boolean isBlankStatus(String value) {
return normalizeValue(value).isBlank();
}
public static String noMatchedRowsMessage() {
return NO_MATCHED_ROWS_MESSAGE;
}
/**
* 暴露表头归一化逻辑给其它模块复用,避免每个模块各自维护一份规则。
* 处理:去 BOM、全角空格转半角、首尾 trim、连续空白合一、转小写
* 并剥除常见分隔符(含中文全角括号、方括号、冒号)。
*/
public static String canonicalizeHeader(String value) {
return normalizeHeader(value);
}
public static <T> FilterResult<T> retainFailedRows(List<T> rows,
boolean statusFilteringEnabled,
Function<T, String> statusReader) {
return retainRows(rows, statusFilteringEnabled, statusReader, FailedStatusRowFilter::matchesFailedStatus);
}
public static <T> FilterResult<T> retainRows(List<T> rows,
boolean statusFilteringEnabled,
Function<T, String> statusReader,
Predicate<String> statusMatcher) {
List<T> safeRows = rows == null ? List.of() : rows;
if (!statusFilteringEnabled) {
return new FilterResult<>(false, safeRows, 0);
}
List<T> filteredRows = new ArrayList<>();
int filteredCount = 0;
for (T row : safeRows) {
String status = statusReader == null ? "" : statusReader.apply(row);
boolean matched = statusMatcher != null && statusMatcher.test(status);
if (matched) {
filteredRows.add(row);
} else {
filteredCount++;
}
}
return new FilterResult<>(true, filteredRows, filteredCount);
}
private static String normalizeHeader(String value) {
// 扩展:兼容中文全角括号 ()、中文方括号 【】、中文冒号 :,避免
// "状态(结果)"、"标题维度(商标)" 等全角括号表头被识别失败。
return normalizeValue(value).replaceAll("[\\s_\\-()\\[\\]{}::()【】]+", "");
}
private static String normalizeValue(String value) {
if (value == null) {
return "";
}
return value
.replace(String.valueOf((char) 0xFEFF), "")
.replace((char) 0x3000, ' ')
.trim()
.replaceAll("\\s+", " ")
.toLowerCase(Locale.ROOT);
}
public record FilterResult<T>(boolean filterApplied, List<T> rows, int filteredCount) {
}
}

View File

@@ -3,6 +3,9 @@ package com.nanri.aiimage.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.ArrayList;
import java.util.List;
@Data
@ConfigurationProperties(prefix = "aiimage.appearance-patent")
public class AppearancePatentProperties {
@@ -11,11 +14,20 @@ public class AppearancePatentProperties {
private String cozeWorkflowHistoryPath = "/v1/workflows/{workflow_id}/run_histories/{execute_id}";
private String cozeWorkflowId = "7632683471312355338";
private String cozeToken = "";
private int cozeBatchSize = 10;
private List<CozeCredential> cozeCredentials = new ArrayList<>();
private int cozeCredentialStripeSize = 5;
private int cozeBatchSize = 50;
private int cozeConnectTimeoutMillis = 10000;
private int cozeReadTimeoutMillis = 60000;
private int cozePollIntervalMillis = 5000;
private int cozePollIntervalMillis = 30000;
private int cozePollTimeoutMillis = 600000;
private int staleTimeoutMinutes = 20;
private int staleTimeoutMinutes = 30;
private String staleFinalizeCron = "0 */2 * * * *";
@Data
public static class CozeCredential {
private String name;
private String workflowId;
private String token;
}
}

View File

@@ -1,21 +1,38 @@
package com.nanri.aiimage.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.net.InetAddress;
@Slf4j
@Component
public class InstanceMetadata {
private final String instanceId;
private final String hostname;
private final String source;
private final boolean stable;
public InstanceMetadata(@Value("${aiimage.instance-id:}") String configuredInstanceId) {
this.hostname = resolveHostname();
this.instanceId = configuredInstanceId == null || configuredInstanceId.isBlank()
? this.hostname
: configuredInstanceId.trim();
String normalizedConfiguredInstanceId = configuredInstanceId == null ? "" : configuredInstanceId.trim();
if (!normalizedConfiguredInstanceId.isBlank()) {
this.instanceId = normalizedConfiguredInstanceId;
this.source = "configured";
this.stable = true;
log.info("[instance] resolved instanceId={} source={} hostname={} continuityRisk=LOW",
this.instanceId, this.source, this.hostname);
} else {
this.instanceId = this.hostname;
this.source = detectFallbackSource(this.hostname);
this.stable = false;
log.warn("[instance] aiimage.instance-id is not configured, falling back to {}={} as instanceId. "
+ "This may change after restart/redeploy and break task owner continuity. "
+ "Set AIIMAGE_INSTANCE_ID in 1Panel/Docker env, or set -Daiimage.instance-id=<stable-id> in IDEA/local startup.",
this.source, this.hostname);
}
}
public String getInstanceId() {
@@ -26,6 +43,14 @@ public class InstanceMetadata {
return hostname;
}
public String getSource() {
return source;
}
public boolean isStable() {
return stable;
}
private static String resolveHostname() {
try {
return InetAddress.getLocalHost().getHostName();
@@ -34,4 +59,12 @@ public class InstanceMetadata {
return (envHostname == null || envHostname.isBlank()) ? "unknown-host" : envHostname;
}
}
private static String detectFallbackSource(String hostname) {
String envHostname = System.getenv("HOSTNAME");
if (envHostname != null && !envHostname.isBlank() && envHostname.equalsIgnoreCase(hostname)) {
return "env.HOSTNAME";
}
return "os.hostname";
}
}

View File

@@ -43,14 +43,18 @@ public class RequestTraceFilter extends OncePerRequestFilter {
response.setHeader("X-AIIMAGE-Instance", instanceMetadata.getInstanceId());
response.setHeader("X-AIIMAGE-Host", instanceMetadata.getHostname());
response.setHeader("X-AIIMAGE-Instance-Source", instanceMetadata.getSource());
response.setHeader("X-AIIMAGE-Instance-Stable", String.valueOf(instanceMetadata.isStable()));
try {
filterChain.doFilter(request, response);
} finally {
long costMs = System.currentTimeMillis() - start;
log.info(
"request-trace instance={} host={} method={} uri={} status={} remote={} forwardedHost={} forwardedProto={} forwardedPort={} requestId={} userAgent={} costMs={}",
"request-trace instance={} source={} stable={} host={} method={} uri={} status={} remote={} forwardedHost={} forwardedProto={} forwardedPort={} requestId={} userAgent={} costMs={}",
instanceMetadata.getInstanceId(),
instanceMetadata.getSource(),
instanceMetadata.isStable(),
instanceMetadata.getHostname(),
request.getMethod(),
request.getRequestURI(),

View File

@@ -3,19 +3,48 @@ package com.nanri.aiimage.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.ArrayList;
import java.util.List;
@Data
@ConfigurationProperties(prefix = "aiimage.similar-asin")
public class SimilarAsinProperties {
private String cozeBaseUrl = "https://api.coze.cn";
private String cozeWorkflowPath = "/v1/workflow/run";
private String cozeWorkflowHistoryPath = "/v1/workflows/{workflow_id}/run_histories/{execute_id}";
private String cozeWorkflowId = "7632683471312355338";
private String cozeWorkflowId = "7635328462404583478";
private String cozeToken = "";
private List<CozeCredential> cozeCredentials = new ArrayList<>();
private int cozeCredentialStripeSize = 0;
private int cozeBatchSize = 10;
private int cozeConnectTimeoutMillis = 10000;
private int cozeReadTimeoutMillis = 60000;
private int cozePollIntervalMillis = 5000;
private int cozePollTimeoutMillis = 600000;
private int staleTimeoutMinutes = 20;
private int cozePollIntervalMillis = 30000;
private int cozePollTimeoutMillis = 1800000;
private int staleTimeoutMinutes = 30;
private String staleFinalizeCron = "0 */2 * * * *";
/**
* 是否在 Coze 请求 parameters 中附带 api_key 字段。
* 默认 true线上 Coze 工作流将该字段视为必填,缺失会得到 4000
* "Missing required parameters";前端传入的 api_key 必须透传到 coze。
* 仅在工作流明确不再需要 api_key 时,可通过环境变量
* AIIMAGE_SIMILAR_ASIN_COZE_INCLUDE_LEGACY_API_KEY=false 关闭。
*/
private boolean cozeIncludeLegacyApiKey = true;
/**
* 是否使用旧的 item 字段顺序 {asin, sku, url, target_urls, title}。
* 默认 false当前实现使用 {asin, url, target_urls, title, sku}。
* 出现兼容问题时可通过 AIIMAGE_SIMILAR_ASIN_COZE_USE_LEGACY_ITEM_ORDER=true
* 切回旧顺序进行回归对比。
*/
private boolean cozeUseLegacyItemFieldOrder = false;
@Data
public static class CozeCredential {
private String name;
private String workflowId;
private String token;
}
}

View File

@@ -3,6 +3,8 @@ package com.nanri.aiimage.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.nio.file.Path;
@Data
@ConfigurationProperties(prefix = "aiimage.storage")
public class StorageProperties {
@@ -12,4 +14,25 @@ public class StorageProperties {
private long sourceRetentionHours = 24;
private long resultRetentionHours = 24;
private long transientPayloadRetentionHours = 72;
public String getLocalTempDir() {
String configured = localTempDir == null ? "" : localTempDir.trim();
if (configured.isBlank()) {
configured = "./data/tmp";
}
try {
Path raw = Path.of(configured);
if (raw.isAbsolute()) {
return raw.normalize().toString();
}
} catch (Exception ignored) {
// Fall through to resolve against a stable base directory.
}
String userDir = System.getProperty("user.dir");
if (userDir == null || userDir.isBlank()) {
userDir = System.getProperty("java.io.tmpdir", ".");
}
return Path.of(userDir).resolve(configured).normalize().toString();
}
}

View File

@@ -9,6 +9,7 @@ import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
@Configuration
public class TaskFileJobConfig {
@@ -37,7 +38,23 @@ public class TaskFileJobConfig {
}
@Bean("cozeTaskExecutor")
public TaskExecutor cozeTaskExecutor(ExecutorService cozeVirtualThreadExecutor) {
return new ConcurrentTaskExecutor(cozeVirtualThreadExecutor);
public TaskExecutor cozeTaskExecutor(
ExecutorService cozeVirtualThreadExecutor,
@Value("${aiimage.coze-task.max-concurrent:12}") int maxConcurrent) {
Semaphore semaphore = new Semaphore(Math.max(1, maxConcurrent));
return new ConcurrentTaskExecutor(command -> cozeVirtualThreadExecutor.execute(() -> {
boolean acquired = false;
try {
semaphore.acquire();
acquired = true;
command.run();
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
} finally {
if (acquired) {
semaphore.release();
}
}
}));
}
}

View File

@@ -34,7 +34,7 @@ public class TaskOperationLockConfig implements WebMvcConfigurer {
private static final Pattern TASK_PATH = Pattern.compile(".*/api/([^/]+)/tasks/(\\d+)(?:/.*)?$");
private static final Pattern TASK_RESULT_PATH = Pattern.compile(".*/api/([^/]+)/tasks/(\\d+)/result/?$");
private static final Set<String> MUTATING_METHODS = Set.of("POST", "PUT", "PATCH", "DELETE");
private static final long RESULT_SUBMIT_WAIT_MILLIS = 10 * 60 * 1000L;
private static final long RESULT_SUBMIT_WAIT_MILLIS = 30 * 1000L;
private static final long DELETE_WAIT_MILLIS = 60 * 1000L;
private static final String LOCK_ATTRIBUTE = TaskOperationLockInterceptor.class.getName() + ".LOCK";

View File

@@ -16,4 +16,5 @@ public class TransientStorageProperties {
private int readTimeoutSeconds = 60;
private int writeTimeoutSeconds = 60;
private int uploadMaxRetries = 3;
private int readMaxRetries = 3;
}

View File

@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.config.AppearancePatentProperties;
import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentResultRowDto;
import com.nanri.aiimage.modules.coze.service.CozeCredentialPoolService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
@@ -19,22 +20,26 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.atomic.AtomicLong;
@Component
@RequiredArgsConstructor
@Slf4j
public class AppearancePatentCozeClient {
private static final String MODULE_TYPE = "APPEARANCE_PATENT";
private static final MediaType APPLICATION_JSON_UTF8 = new MediaType("application", "json", StandardCharsets.UTF_8);
private final AppearancePatentProperties properties;
private final ObjectMapper objectMapper;
private final CozeCredentialPoolService cozeCredentialPoolService;
private final AtomicLong credentialCursor = new AtomicLong();
public List<AppearancePatentResultRowDto> inspect(List<AppearancePatentResultRowDto> rows, String prompt, String apiKey) {
if (rows == null || rows.isEmpty()) {
return List.of();
}
if (properties.getCozeToken() == null || properties.getCozeToken().isBlank()) {
if (!hasConfiguredCredential()) {
log.warn("[appearance-patent] coze token not configured, keep raw rows size={}", rows.size());
return rows.stream().map(this::copy).toList();
}
@@ -48,17 +53,31 @@ public class AppearancePatentCozeClient {
}
public CozeSubmitResponse submitWorkflow(List<AppearancePatentResultRowDto> rows, String prompt, String apiKey) throws Exception {
JsonNode submitRoot = objectMapper.readTree(postWorkflow(rows, prompt, apiKey));
return submitWorkflow(rows, prompt, apiKey, nextCredential());
}
public CozeSubmitResponse submitWorkflow(List<AppearancePatentResultRowDto> rows,
String prompt,
String apiKey,
CozeCredentialRef credential) throws Exception {
CozeCredentialRef resolvedCredential = resolveCredential(credential);
JsonNode submitRoot = objectMapper.readTree(postWorkflow(rows, prompt, apiKey, resolvedCredential));
ensureSuccess(submitRoot);
return new CozeSubmitResponse(
extractExecuteId(submitRoot),
extractResultDataText(submitRoot),
writeJson(submitRoot)
writeJson(submitRoot),
resolvedCredential.name()
);
}
public CozePollResponse pollWorkflow(String executeId) throws Exception {
JsonNode pollRoot = objectMapper.readTree(getWorkflowHistory(executeId));
return pollWorkflow(executeId, null);
}
public CozePollResponse pollWorkflow(String executeId, CozeCredentialRef credential) throws Exception {
CozeCredentialRef resolvedCredential = resolveCredential(credential);
JsonNode pollRoot = objectMapper.readTree(getWorkflowHistory(executeId, resolvedCredential));
ensureSuccess(pollRoot);
String status = normalize(resolveWorkflowStatus(pollRoot)).toUpperCase(Locale.ROOT);
String dataText = extractResultDataText(pollRoot);
@@ -66,7 +85,8 @@ public class AppearancePatentCozeClient {
String failureMessage = isFailedWorkflowStatus(status)
? firstNonBlank(resolveFailureMessage(pollRoot), "Coze async workflow failed")
: "";
return new CozePollResponse(executeId, status, dataText, outputText, failureMessage, writeJson(pollRoot));
return new CozePollResponse(executeId, status, dataText, outputText, failureMessage, writeJson(pollRoot),
resolvedCredential.name());
}
public List<AppearancePatentResultRowDto> mergeRowsFromDataText(List<AppearancePatentResultRowDto> rows, String dataText) throws Exception {
@@ -164,7 +184,8 @@ public class AppearancePatentCozeClient {
}
private String runWorkflowAsyncAndWait(List<AppearancePatentResultRowDto> rows, String prompt, String apiKey) throws Exception {
JsonNode submitRoot = objectMapper.readTree(postWorkflow(rows, prompt, apiKey));
CozeCredentialRef credential = nextCredential();
JsonNode submitRoot = objectMapper.readTree(postWorkflow(rows, prompt, apiKey, credential));
ensureSuccess(submitRoot);
String immediateData = extractResultDataText(submitRoot);
@@ -180,7 +201,7 @@ public class AppearancePatentCozeClient {
long deadline = System.currentTimeMillis() + Math.max(10000, properties.getCozePollTimeoutMillis());
while (System.currentTimeMillis() < deadline) {
ensureNotInterrupted();
JsonNode pollRoot = objectMapper.readTree(getWorkflowHistory(executeId));
JsonNode pollRoot = objectMapper.readTree(getWorkflowHistory(executeId, credential));
ensureSuccess(pollRoot);
String dataText = extractResultDataText(pollRoot);
@@ -205,20 +226,24 @@ public class AppearancePatentCozeClient {
throw new IllegalStateException("Coze async workflow poll timeout");
}
private String postWorkflow(List<AppearancePatentResultRowDto> rows, String prompt, String apiKey) {
private String postWorkflow(List<AppearancePatentResultRowDto> rows,
String prompt,
String apiKey,
CozeCredentialRef credential) {
Map<String, Object> parameters = buildParameters(rows, prompt, apiKey);
Map<String, Object> body = new LinkedHashMap<>();
body.put("workflow_id", properties.getCozeWorkflowId());
body.put("workflow_id", credential.workflowId());
body.put("parameters", parameters);
body.put("is_async", Boolean.TRUE);
log.info("[appearance-patent] coze request url={} body={}",
log.info("[appearance-patent] coze request credential={} url={} body={}",
credential.name(),
joinUrl(properties.getCozeBaseUrl(), properties.getCozeWorkflowPath()),
writeJson(maskCozeRequestBody(body)));
RestClient.RequestBodySpec request = restClient().post()
.uri(joinUrl(properties.getCozeBaseUrl(), properties.getCozeWorkflowPath()))
.headers(headers -> {
headers.setBearerAuth(stripBearer(properties.getCozeToken()));
headers.setBearerAuth(stripBearer(credential.token()));
headers.setContentType(APPLICATION_JSON_UTF8);
headers.set(HttpHeaders.ACCEPT_CHARSET, StandardCharsets.UTF_8.name());
});
@@ -233,28 +258,98 @@ public class AppearancePatentCozeClient {
});
}
private String getWorkflowHistory(String executeId) {
private String getWorkflowHistory(String executeId, CozeCredentialRef credential) {
String path = properties.getCozeWorkflowHistoryPath()
.replace("{workflow_id}", properties.getCozeWorkflowId())
.replace("{workflow_id}", credential.workflowId())
.replace("{execute_id}", executeId);
return restClient().get()
.uri(joinUrl(properties.getCozeBaseUrl(), path))
.headers(headers -> {
headers.setBearerAuth(stripBearer(properties.getCozeToken()));
headers.setBearerAuth(stripBearer(credential.token()));
headers.setContentType(APPLICATION_JSON_UTF8);
headers.set(HttpHeaders.ACCEPT_CHARSET, StandardCharsets.UTF_8.name());
})
.exchange((clientRequest, clientResponse) -> {
byte[] responseBytes = StreamUtils.copyToByteArray(clientResponse.getBody());
String responseText = responseBytes.length == 0 ? "" : new String(responseBytes, StandardCharsets.UTF_8);
log.info("[appearance-patent] coze history response executeId={} status={} body={}",
executeId,
log.info("[appearance-patent] coze history response credential={} executeId={} status={} body={}",
credential.name(), executeId,
clientResponse.getStatusCode(),
responseText);
return responseText;
});
}
public CozeCredentialRef nextCredential() {
List<CozeCredentialPoolService.CozeCredential> pooledCredentials = cozeCredentialPoolService.listEnabled(MODULE_TYPE);
CozeCredentialPoolService.CozeCredential pooledCredential =
cozeCredentialPoolService.chooseRoundRobin(MODULE_TYPE, pooledCredentials, properties.getCozeCredentialStripeSize());
if (pooledCredential != null) {
return new CozeCredentialRef(pooledCredential.name(), pooledCredential.workflowId(), pooledCredential.token(),
pooledCredential.maxConcurrent());
}
List<CozeCredentialRef> credentials = configuredCredentials();
int stripeSize = Math.max(1, properties.getCozeCredentialStripeSize());
long cursor = Math.max(0L, credentialCursor.getAndIncrement());
int index = (int) ((cursor / stripeSize) % credentials.size());
return credentials.get(index);
}
public CozeCredentialRef credentialByName(String name) {
if (name == null || name.isBlank()) {
return nextCredential();
}
String normalizedName = normalize(name);
for (CozeCredentialRef credential : configuredCredentials()) {
if (normalize(credential.name()).equals(normalizedName)) {
return credential;
}
}
return nextCredential();
}
public boolean hasConfiguredCredential() {
return !configuredCredentials().isEmpty();
}
public int configuredCredentialCount() {
return configuredCredentials().size();
}
private CozeCredentialRef resolveCredential(CozeCredentialRef credential) {
return credential == null ? nextCredential() : credential;
}
private List<CozeCredentialRef> configuredCredentials() {
List<CozeCredentialRef> credentials = new ArrayList<>();
for (CozeCredentialPoolService.CozeCredential credential : cozeCredentialPoolService.listEnabled(MODULE_TYPE)) {
credentials.add(new CozeCredentialRef(credential.name(), credential.workflowId(), credential.token(),
credential.maxConcurrent()));
}
if (!credentials.isEmpty()) {
return credentials;
}
if (properties.getCozeCredentials() != null) {
int index = 1;
for (AppearancePatentProperties.CozeCredential credential : properties.getCozeCredentials()) {
if (credential == null
|| normalize(credential.getWorkflowId()).isBlank()
|| normalize(credential.getToken()).isBlank()) {
continue;
}
String name = firstNonBlank(credential.getName(), "credential-" + index);
credentials.add(new CozeCredentialRef(name, credential.getWorkflowId(), credential.getToken(), Integer.MAX_VALUE));
index++;
}
}
if (credentials.isEmpty()
&& properties.getCozeWorkflowId() != null && !properties.getCozeWorkflowId().isBlank()
&& properties.getCozeToken() != null && !properties.getCozeToken().isBlank()) {
credentials.add(new CozeCredentialRef("default", properties.getCozeWorkflowId(), properties.getCozeToken(), Integer.MAX_VALUE));
}
return credentials;
}
private Map<String, Object> buildParameters(List<AppearancePatentResultRowDto> rows, String prompt, String apiKey) {
List<String> groupKeys = rows.stream().map(row -> nonBlank(row.getGroupKey(), rowKey(row))).toList();
List<String> rowIds = rows.stream().map(row -> nonBlank(row.getId(), "")).toList();
@@ -355,6 +450,9 @@ public class AppearancePatentCozeClient {
text(firstNonNull(node.get("result"),
firstNonNull(node.get("conclusion"),
firstNonNull(itemNode.get("result"), itemNode.get("conclusion"))))),
text(firstNonNull(
firstNonNull(node.get("status"), firstNonNull(node.get("row_status"), node.get("rowStatus"))),
firstNonNull(itemNode.get("status"), firstNonNull(itemNode.get("row_status"), itemNode.get("rowStatus"))))),
text(firstNonNull(node.get("title_reason"),
firstNonNull(node.get("titleReason"),
firstNonNull(itemNode.get("title_reason"), itemNode.get("titleReason"))))),
@@ -465,6 +563,7 @@ public class AppearancePatentCozeClient {
row.setAppearanceRisk(result.appearance());
row.setPatentRisk(result.patent());
row.setConclusion(result.result());
row.setStatus(result.status());
row.setTitleReason(result.titleReason());
row.setAppearanceReason(result.appearanceReason());
row.setPatentReason(result.patentReason());
@@ -490,6 +589,7 @@ public class AppearancePatentCozeClient {
row.setTitle(source.getTitle());
row.setError(source.getError());
row.setDone(source.getDone());
row.setStatus(source.getStatus());
row.setTitleRisk(source.getTitleRisk());
row.setAppearanceRisk(source.getAppearanceRisk());
row.setPatentRisk(source.getPatentRisk());
@@ -507,6 +607,12 @@ public class AppearancePatentCozeClient {
if (row.getError() == null || row.getError().isBlank()) {
row.setError(failureMessage);
}
if (row.getStatus() == null || row.getStatus().isBlank()) {
row.setStatus("FAILED");
// 标记本次 FAILED 是 markFailed 合成的,仅在内存生命周期内生效(@JsonIgnore
// 后续导出 / 重新上传时可据此与"用户真实失败"区分,避免重复触发 retry。
row.setFailureSyntheticStatus(true);
}
if (row.getTitleRisk() == null || row.getTitleRisk().isBlank()) {
row.setTitleRisk(reviewMessage);
}
@@ -734,7 +840,10 @@ public class AppearancePatentCozeClient {
private String resolveFailureMessage(JsonNode root) {
JsonNode dataNode = root.path("data");
String message = text(firstNonNull(dataNode.get("error_message"), firstNonNull(dataNode.get("msg"), root.get("msg"))));
return message == null ? "" : message;
if (message != null && !message.isBlank()) {
return message;
}
return firstNonBlank(findTextByFieldName(root, "error_message", "error", "msg"), "");
}
private String extractExecuteId(JsonNode root) {
@@ -939,6 +1048,7 @@ public class AppearancePatentCozeClient {
String appearance,
String patent,
String result,
String status,
String titleReason,
String appearanceReason,
String patentReason
@@ -956,7 +1066,8 @@ public class AppearancePatentCozeClient {
public record CozeSubmitResponse(
String executeId,
String immediateData,
String rawResponse
String rawResponse,
String credentialName
) {
}
@@ -966,7 +1077,8 @@ public class AppearancePatentCozeClient {
String dataText,
String outputText,
String failureMessage,
String rawResponse
String rawResponse,
String credentialName
) {
public boolean hasPayload() {
return dataText != null && !dataText.isBlank() || outputText != null && !outputText.isBlank();
@@ -978,7 +1090,7 @@ public class AppearancePatentCozeClient {
public boolean isFailed() {
String normalized = status == null ? "" : status.trim().toUpperCase(Locale.ROOT);
return normalized.contains("FAILED") || normalized.contains("ERROR") || normalized.contains("CANCEL");
return normalized.contains("FAIL") || normalized.contains("ERROR") || normalized.contains("CANCEL");
}
public boolean isFinished() {
@@ -992,6 +1104,14 @@ public class AppearancePatentCozeClient {
}
}
public record CozeCredentialRef(
String name,
String workflowId,
String token,
int maxConcurrent
) {
}
private static final class PartialCozeResultException extends RuntimeException {
private final int resolvedCount;

View File

@@ -1,6 +1,7 @@
package com.nanri.aiimage.modules.appearancepatent.model.dto;
import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.annotation.JsonIgnore;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@@ -48,6 +49,10 @@ public class AppearancePatentResultRowDto {
@Schema(description = "单行完成标记。当前主要使用请求体顶层 done 控制任务收尾,该字段仅作兼容。", example = "true")
private Boolean done;
@JsonAlias({"row_status", "rowStatus", "Status"})
@Schema(description = "Coze row status", example = "success", accessMode = Schema.AccessMode.READ_ONLY)
private String status;
@Schema(description = "Java 调用 Coze 后生成的标题维度检测结果,对应最终 xlsx 的“标题维度商标”列。Python 回传请求中不要传该字段;即使传入,后端也会以 Java/Coze 处理结果为准。", example = "标题未发现明显商标侵权风险。", accessMode = Schema.AccessMode.READ_ONLY)
private String titleRisk;
@@ -72,4 +77,13 @@ public class AppearancePatentResultRowDto {
@JsonAlias({"patent_reason", "patentReason", "patent reason"})
@Schema(description = "Coze patent reason", accessMode = Schema.AccessMode.READ_ONLY)
private String patentReason;
/**
* 仅内存生命周期标记,标识当前 status 是 markFailed 时合成出来的(而不是用户/Python 真实回传)。
* 不入库、不参与 chunk 序列化(@JsonIgnore用于导出 / 重新上传判定时区分"系统合成 FAILED"与"用户真正失败"
* 避免用户拿结果簿原样再上传时被反复识别为失败行重新触发 retry。
*/
@JsonIgnore
@Schema(hidden = true)
private boolean failureSyntheticStatus;
}

View File

@@ -34,4 +34,8 @@ public class AppearancePatentHistoryItemVo {
private Integer rowCount;
@Schema(description = "历史记录创建时间ISO 本地时间字符串。", example = "2026-04-26T10:30:00")
private String createdAt;
@Schema(description = "任务开始时间ISO 本地时间字符串。当前等价于 biz_file_task.created_at用户点解析创建任务时刻", example = "2026-04-26T10:00:00")
private String startedAt;
@Schema(description = "任务结束时间ISO 本地时间字符串。SUCCESS/FAILED 时回填,未结束为空。", example = "2026-04-26T10:10:00")
private String finishedAt;
}

View File

@@ -45,7 +45,6 @@ import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.File;
import java.io.FileInputStream;
@@ -1208,8 +1207,6 @@ public class BrandTaskService {
}
}
@Transactional
@org.springframework.scheduling.annotation.Scheduled(cron = "${aiimage.brand-progress.stale-check-cron:0 */2 * * * *}")
public void failStaleRunningTasks() {
DistributedJobLockService.LockHandle lockHandle = distributedJobLockService.tryLock("brand:stale-check", STALE_CHECK_LOCK_TTL);
if (lockHandle == null) {

View File

@@ -0,0 +1,9 @@
package com.nanri.aiimage.modules.coze.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.nanri.aiimage.modules.coze.model.entity.CozeCredentialEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface CozeCredentialMapper extends BaseMapper<CozeCredentialEntity> {
}

View File

@@ -0,0 +1,25 @@
package com.nanri.aiimage.modules.coze.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("biz_coze_credential")
public class CozeCredentialEntity {
@TableId(type = IdType.AUTO)
private Long id;
private String moduleType;
private String credentialName;
private String workflowId;
private String token;
private Integer enabled;
private Integer maxConcurrent;
private Integer sortOrder;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,200 @@
package com.nanri.aiimage.modules.coze.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.nanri.aiimage.modules.coze.mapper.CozeCredentialMapper;
import com.nanri.aiimage.modules.coze.model.entity.CozeCredentialEntity;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
@Slf4j
@Service
@RequiredArgsConstructor
public class CozeCredentialPoolService {
private static final Duration INFLIGHT_TTL = Duration.ofMinutes(30);
/**
* 每个 moduleType 只 WARN 一次,避免高频日志噪音。
* key = moduleTypevalue = 仅作占位,仅用 putIfAbsent 语义判断"是否已经 WARN 过"。
*/
private final ConcurrentHashMap<String, Boolean> stripeWarnedModules = new ConcurrentHashMap<>();
private final CozeCredentialMapper cozeCredentialMapper;
private final StringRedisTemplate stringRedisTemplate;
public List<CozeCredential> listEnabled(String moduleType) {
if (moduleType == null || moduleType.isBlank()) {
return List.of();
}
try {
List<CozeCredentialEntity> rows = cozeCredentialMapper.selectList(new LambdaQueryWrapper<CozeCredentialEntity>()
.eq(CozeCredentialEntity::getModuleType, moduleType)
.eq(CozeCredentialEntity::getEnabled, 1)
.orderByAsc(CozeCredentialEntity::getSortOrder)
.orderByAsc(CozeCredentialEntity::getId));
if (rows == null || rows.isEmpty()) {
return List.of();
}
return rows.stream()
.filter(Objects::nonNull)
.filter(row -> !blank(row.getCredentialName())
&& !blank(row.getWorkflowId())
&& !blank(row.getToken()))
.map(row -> new CozeCredential(
row.getCredentialName().trim(),
row.getWorkflowId().trim(),
row.getToken().trim(),
row.getMaxConcurrent() == null || row.getMaxConcurrent() <= 0
? Integer.MAX_VALUE
: row.getMaxConcurrent()))
.toList();
} catch (Exception ex) {
log.warn("[coze-credential] list enabled failed moduleType={} err={}", moduleType, ex.getMessage());
return List.of();
}
}
public CozeCredential chooseLeastInflight(String moduleType, List<CozeCredential> credentials) {
return chooseRoundRobin(moduleType, credentials, 1);
}
public CozeCredential chooseRoundRobin(String moduleType, List<CozeCredential> credentials, int stripeSize) {
if (credentials == null || credentials.isEmpty()) {
return null;
}
// stripeSize ≤ 0 时自动按 credentials.size() 适配;正数则按配置走。
// 注意stripe=1 与 stripe=credentials.size() 在轮换正确性上等价,差异只在"每个凭据连续选中次数"。
int safeStripeSize = stripeSize <= 0 ? credentials.size() : stripeSize;
// 仅当 stripe>1 且小于凭据数时 WARN此时凭据被切到下一张前会连续选 stripe 次,但未覆盖所有凭据就回到首张,存在偏向。
if (safeStripeSize > 1 && safeStripeSize < credentials.size() && moduleType != null && !moduleType.isBlank()) {
if (stripeWarnedModules.putIfAbsent(moduleType, Boolean.TRUE) == null) {
log.warn("[coze-credential] stripeSize({}) < credentials.size({}) for moduleType={}, "
+ "round-robin may be biased; consider setting stripeSize == credentials.size() "
+ "or leave it 0/negative to auto-adapt",
safeStripeSize, credentials.size(), moduleType);
}
}
long cursor = nextCursor(moduleType);
int index = (int) ((Math.max(0L, cursor) / safeStripeSize) % credentials.size());
return credentials.get(index);
}
public BorrowedCredential borrow(String moduleType, CozeCredential credential) {
if (credential == null) {
return null;
}
String key = inflightKey(moduleType, credential.name());
try {
Long value = stringRedisTemplate.opsForValue().increment(key);
stringRedisTemplate.expire(key, INFLIGHT_TTL);
long inflight = value == null ? 0L : value;
if (inflight > credential.maxConcurrent()) {
release(moduleType, credential.name());
return null;
}
return new BorrowedCredential(this, moduleType, credential.name());
} catch (Exception ex) {
log.warn("[coze-credential] borrow failed moduleType={} credential={} err={}",
moduleType, credential.name(), ex.getMessage());
return BorrowedCredential.noop();
}
}
public void release(String moduleType, String credentialName) {
if (blank(moduleType) || blank(credentialName)) {
return;
}
try {
Long value = stringRedisTemplate.opsForValue().decrement(inflightKey(moduleType, credentialName));
if (value != null && value <= 0L) {
stringRedisTemplate.delete(inflightKey(moduleType, credentialName));
}
} catch (Exception ex) {
log.warn("[coze-credential] release failed moduleType={} credential={} err={}",
moduleType, credentialName, ex.getMessage());
}
}
private long inflight(String moduleType, String credentialName) {
try {
String raw = stringRedisTemplate.opsForValue().get(inflightKey(moduleType, credentialName));
return raw == null || raw.isBlank() ? 0L : Long.parseLong(raw);
} catch (Exception ex) {
return 0L;
}
}
private long nextCursor(String moduleType) {
if (blank(moduleType)) {
return 0L;
}
try {
Long value = stringRedisTemplate.opsForValue().increment(cursorKey(moduleType));
stringRedisTemplate.expire(cursorKey(moduleType), Duration.ofDays(7));
return value == null ? 0L : Math.max(0L, value - 1L);
} catch (Exception ex) {
log.warn("[coze-credential] cursor increment failed moduleType={} err={}", moduleType, ex.getMessage());
return System.nanoTime();
}
}
private String inflightKey(String moduleType, String credentialName) {
return "coze:credential:inflight:" + moduleType + ":" + credentialName;
}
private String cursorKey(String moduleType) {
return "coze:credential:cursor:" + moduleType;
}
private boolean blank(String value) {
return value == null || value.isBlank();
}
public record CozeCredential(String name,
String workflowId,
String token,
int maxConcurrent) {
}
public static final class BorrowedCredential implements AutoCloseable {
private final CozeCredentialPoolService owner;
private final String moduleType;
private final String credentialName;
private final boolean noop;
private boolean released;
private BorrowedCredential(CozeCredentialPoolService owner, String moduleType, String credentialName) {
this.owner = owner;
this.moduleType = moduleType;
this.credentialName = credentialName;
this.noop = false;
}
private BorrowedCredential() {
this.owner = null;
this.moduleType = null;
this.credentialName = null;
this.noop = true;
}
private static BorrowedCredential noop() {
return new BorrowedCredential();
}
@Override
public void close() {
if (released || noop) {
return;
}
released = true;
owner.release(moduleType, credentialName);
}
}
}

View File

@@ -34,6 +34,8 @@ public class DebugRequestInfoController {
) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("instanceId", instanceMetadata.getInstanceId());
result.put("instanceSource", instanceMetadata.getSource());
result.put("instanceStable", instanceMetadata.isStable());
result.put("hostname", instanceMetadata.getHostname());
result.put("serverName", request.getServerName());
result.put("serverPort", request.getServerPort());

View File

@@ -0,0 +1,44 @@
package com.nanri.aiimage.modules.debug.controller;
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
@RestController
@RequestMapping("/debug/task-recovery")
public class DebugTaskRecoveryController {
private final AppearancePatentTaskService appearancePatentTaskService;
private final SimilarAsinTaskService similarAsinTaskService;
public DebugTaskRecoveryController(AppearancePatentTaskService appearancePatentTaskService,
SimilarAsinTaskService similarAsinTaskService) {
this.appearancePatentTaskService = appearancePatentTaskService;
this.similarAsinTaskService = similarAsinTaskService;
}
@PostMapping("/run-stale-finalize")
public Map<String, Object> runStaleFinalize(@RequestParam("module") String module,
@RequestParam("taskId") Long taskId) {
String normalizedModule = module == null ? "" : module.trim().toUpperCase(Locale.ROOT);
if ("APPEARANCE_PATENT".equals(normalizedModule)) {
appearancePatentTaskService.debugFinalizeStaleTask(taskId);
} else if ("SIMILAR_ASIN".equals(normalizedModule)) {
similarAsinTaskService.debugFinalizeStaleTask(taskId);
} else {
throw new IllegalArgumentException("Unsupported module: " + module);
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("module", normalizedModule);
result.put("taskId", taskId);
result.put("accepted", true);
return result;
}
}

View File

@@ -19,7 +19,10 @@ import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayInputStream;
@@ -41,9 +44,16 @@ public class DedupeTotalDataService {
private static final int COMPARE_BATCH_SIZE = 5000;
private final DedupeTotalDataMapper dedupeTotalDataMapper;
private final PlatformTransactionManager transactionManager;
private final Map<String, DedupeTotalDataImportProgressVo> importProgressMap = new ConcurrentHashMap<>();
private final Map<String, DedupeTotalDataImportProgressVo> deleteImportProgressMap = new ConcurrentHashMap<>();
private TransactionTemplate newRequiresNewTemplate() {
TransactionTemplate template = new TransactionTemplate(transactionManager);
template.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
return template;
}
public DedupeTotalDataPageVo page(long page, long pageSize, String keyword) {
long safePage = Math.max(page, 1);
long safePageSize = Math.min(Math.max(pageSize, 1), 100);
@@ -281,7 +291,6 @@ public class DedupeTotalDataService {
}
}
@Transactional
public DedupeTotalDataImportVo importFromExcel(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new BusinessException("请上传 xlsx 文件");
@@ -295,7 +304,6 @@ public class DedupeTotalDataService {
}
}
@Transactional
public DedupeTotalDataImportVo deleteFromExcel(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new BusinessException("请上传 xlsx 文件");
@@ -309,7 +317,6 @@ public class DedupeTotalDataService {
}
}
@Transactional
private DedupeTotalDataImportVo importFromExcelInternal(InputStream inputStream, String filename, DedupeTotalDataImportProgressVo progress) {
String lowerFilename = filename == null ? "" : filename.toLowerCase();
if (!(lowerFilename.endsWith(".xlsx") || lowerFilename.endsWith(".xls"))) {
@@ -397,10 +404,21 @@ public class DedupeTotalDataService {
}
continue;
}
DedupeTotalDataEntity entity = new DedupeTotalDataEntity();
entity.setDataValue(dataValue);
dedupeTotalDataMapper.insert(entity);
insertedCount++;
final String pendingDataValue = dataValue;
Boolean inserted = newRequiresNewTemplate().execute(status -> {
if (existsDataValue(pendingDataValue)) {
return false;
}
DedupeTotalDataEntity entity = new DedupeTotalDataEntity();
entity.setDataValue(pendingDataValue);
dedupeTotalDataMapper.insert(entity);
return true;
});
if (Boolean.TRUE.equals(inserted)) {
insertedCount++;
} else {
skippedCount++;
}
if (progress != null) {
progress.setProcessedRows(rowNum);
progress.setAsinCount(asinCount);
@@ -422,7 +440,6 @@ public class DedupeTotalDataService {
}
}
@Transactional
private DedupeTotalDataImportVo deleteFromExcelInternal(InputStream inputStream, String filename, DedupeTotalDataImportProgressVo progress) {
String lowerFilename = filename == null ? "" : filename.toLowerCase();
if (!(lowerFilename.endsWith(".xlsx") || lowerFilename.endsWith(".xls"))) {
@@ -501,7 +518,7 @@ public class DedupeTotalDataService {
continue;
}
int deletedThisRow = deleteByDataValue(dataValue);
int deletedThisRow = newRequiresNewTemplate().execute(status -> deleteByDataValue(dataValue));
if (deletedThisRow > 0) {
deletedCount += deletedThisRow;
} else {

View File

@@ -49,7 +49,10 @@ import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
import java.io.File;
@@ -88,6 +91,24 @@ public class DeleteBrandRunService {
private final TaskPressureProperties taskPressureProperties;
private final TaskDistributedLockService taskDistributedLockService;
private final TaskFileJobService taskFileJobService;
private final PlatformTransactionManager transactionManager;
private TransactionTemplate newRequiresNewTemplate() {
TransactionTemplate template = new TransactionTemplate(transactionManager);
template.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
return template;
}
private <T> T inNewTransaction(java.util.function.Supplier<T> action) {
return newRequiresNewTemplate().execute(status -> action.get());
}
private void inNewTransactionVoid(Runnable action) {
newRequiresNewTemplate().execute(status -> {
action.run();
return null;
});
}
private boolean isUsableIndexedMatch(ZiniaoShopMatchResultVo matchResult) {
if (matchResult == null) {
@@ -1254,7 +1275,7 @@ public class DeleteBrandRunService {
return Math.max(0L, (endExclusive - startInclusive) / 1_000_000L);
}
@Transactional
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.NEVER)
public void submitResult(Long taskId, DeleteBrandSubmitResultRequest request) {
if (taskId == null || taskId <= 0) {
throw new BusinessException("taskId invalid");
@@ -1290,11 +1311,13 @@ public class DeleteBrandRunService {
}
if (!"RUNNING".equals(task.getStatus())) {
task.setStatus("RUNNING");
task.setErrorMessage(null);
task.setUpdatedAt(LocalDateTime.now());
task.setFinishedAt(null);
fileTaskMapper.updateById(task);
inNewTransactionVoid(() -> {
task.setStatus("RUNNING");
task.setErrorMessage(null);
task.setUpdatedAt(LocalDateTime.now());
task.setFinishedAt(null);
fileTaskMapper.updateById(task);
});
deleteBrandTaskCacheService.saveTaskCache(task);
}
@@ -1338,7 +1361,9 @@ public class DeleteBrandRunService {
if (isFileCompleted(fileDto)) {
log.info("[DeleteBrand] File chunk transmission fully reached via chunkTotal for taskId: {}, file: {}", taskId, fileDto.getSourceFilename());
enqueueCompletedFileAssembleJob(task, fileIdentity, parsedFile);
final String identityForTx = fileIdentity;
final DeleteBrandParsedFileCacheDto parsedForTx = parsedFile;
inNewTransactionVoid(() -> enqueueCompletedFileAssembleJob(task, identityForTx, parsedForTx));
shouldTryFinalize = true;
}
}

View File

@@ -4,6 +4,8 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.nanri.aiimage.common.service.DistributedJobLockService;
import com.nanri.aiimage.config.DeleteBrandProgressProperties;
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
import com.nanri.aiimage.modules.brand.service.BrandTaskService;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskCacheService;
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskCacheService;
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService;
@@ -12,6 +14,7 @@ import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskCacheServi
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService;
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskCacheService;
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskCacheService;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
@@ -63,6 +66,9 @@ public class DeleteBrandStaleTaskService {
private final PatrolDeleteTaskCacheService patrolDeleteTaskCacheService;
private final QueryAsinTaskService queryAsinTaskService;
private final QueryAsinTaskCacheService queryAsinTaskCacheService;
private final BrandTaskService brandTaskService;
private final AppearancePatentTaskService appearancePatentTaskService;
private final SimilarAsinTaskService similarAsinTaskService;
private final DeleteBrandProgressProperties deleteBrandProgressProperties;
private final DistributedJobLockService distributedJobLockService;
private final TaskDistributedLockService taskDistributedLockService;
@@ -88,6 +94,9 @@ public class DeleteBrandStaleTaskService {
ShopMatchStaleCheckStats shopMatchStats = failStaleShopMatchTasks();
ShopMatchStaleCheckStats patrolDeleteStats = failStalePatrolDeleteTasks();
ShopMatchStaleCheckStats queryAsinStats = failStaleQueryAsinTasks();
runModuleStaleCheck("brand", brandTaskService::failStaleRunningTasks);
runModuleStaleCheck("appearance-patent", appearancePatentTaskService::finalizeStaleTasks);
runModuleStaleCheck("similar-asin", similarAsinTaskService::finalizeStaleTasks);
log.info("[stale-check] product-risk summary scanned={} finalized={} failed={} skipped={} elapsedMs={} thread={}",
stats.scannedTaskCount,
stats.finalizedTaskCount,
@@ -126,6 +135,23 @@ public class DeleteBrandStaleTaskService {
}
}
private void runModuleStaleCheck(String moduleName, Runnable action) {
long startedAt = System.currentTimeMillis();
try {
action.run();
log.info("[stale-check] {} delegated stale check completed elapsedMs={} thread={}",
moduleName,
System.currentTimeMillis() - startedAt,
Thread.currentThread().getName());
} catch (Exception ex) {
log.warn("[stale-check] {} delegated stale check failed elapsedMs={} msg={}",
moduleName,
System.currentTimeMillis() - startedAt,
ex.getMessage(),
ex);
}
}
private void failStaleDeleteBrandTasks() {
long minutes = Math.max(1L, deleteBrandProgressProperties.getHeartbeatTimeoutMinutes());
long initialMinutes = Math.max(minutes, deleteBrandProgressProperties.getDeleteBrandInitialTimeoutMinutes());

View File

@@ -69,14 +69,24 @@ public class RustfsObjectStorageService {
if (!isConfigured()) {
throw new IllegalStateException("transient storage is not configured");
}
try (var stream = buildClient().getObject(GetObjectArgs.builder()
.bucket(properties.getBucket())
.object(objectKey)
.build())) {
return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
} catch (Exception ex) {
throw new IllegalStateException("failed to read payload from transient storage", ex);
Exception last = null;
int maxRetries = Math.max(1, properties.getReadMaxRetries());
for (int attempt = 1; attempt <= maxRetries; attempt++) {
try (var stream = buildClient().getObject(GetObjectArgs.builder()
.bucket(properties.getBucket())
.object(objectKey)
.build())) {
return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
} catch (Exception ex) {
last = ex;
if (attempt < maxRetries) {
// 与 uploadText 对齐的线性退避,避免 rustfs 短暂抖动直接打成 read coze batch failed。
log.warn("[rustfs] read failed, retrying objectKey={} attempt={}/{} err={}", objectKey, attempt, maxRetries, ex.getMessage());
sleepQuietly(500L * attempt);
}
}
}
throw new IllegalStateException("failed to read payload from transient storage", last);
}
public void deleteObject(String objectKey) {

View File

@@ -18,7 +18,10 @@ import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopIndexService;
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.LocalDateTime;
import java.util.ArrayList;
@@ -33,6 +36,13 @@ public class PatrolDeleteResolveService {
private final PatrolDeleteShopCandidateMapper candidateMapper;
private final PatrolDeleteConditionMapper conditionMapper;
private final ZiniaoShopSwitchService ziniaoShopSwitchService;
private final PlatformTransactionManager transactionManager;
private TransactionTemplate newRequiresNewTemplate() {
TransactionTemplate template = new TransactionTemplate(transactionManager);
template.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
return template;
}
public List<ProductRiskCandidateVo> listCandidates(Long userId) {
validateUserId(userId);
@@ -51,14 +61,13 @@ public class PatrolDeleteResolveService {
return list;
}
@Transactional
public ProductRiskCandidateVo addCandidate(ProductRiskCandidateAddRequest request) {
validateUserId(request.getUserId());
String normalized = ziniaoShopSwitchService.normalizeShopName(request.getShopName());
if (normalized.isBlank()) {
throw new BusinessException("店铺名不能为空");
}
ZiniaoShopMatchResultVo indexHit = ziniaoShopSwitchService.findIndexedStoreByName(normalized, false);
ZiniaoShopMatchResultVo indexHit = resolveIndexedShop(normalized);
if (indexHit == null || !indexHit.isMatched()) {
String hint = indexHit != null && indexHit.getMatchMessage() != null && !indexHit.getMatchMessage().isBlank()
? indexHit.getMatchMessage()
@@ -68,9 +77,17 @@ public class PatrolDeleteResolveService {
if (ZiniaoShopIndexService.MATCH_STATUS_CONFLICT.equals(indexHit.getMatchStatus())) {
throw new BusinessException(indexHit.getMatchMessage() != null ? indexHit.getMatchMessage() : "存在多个同名店铺,请人工确认");
}
return newRequiresNewTemplate().execute(status -> persistCandidate(request.getUserId(), normalized));
}
private ZiniaoShopMatchResultVo resolveIndexedShop(String normalizedShopName) {
return ziniaoShopSwitchService.findIndexedStoreByName(normalizedShopName, false);
}
private ProductRiskCandidateVo persistCandidate(Long userId, String normalized) {
PatrolDeleteShopCandidateEntity existing = candidateMapper.selectOne(
new LambdaQueryWrapper<PatrolDeleteShopCandidateEntity>()
.eq(PatrolDeleteShopCandidateEntity::getUserId, request.getUserId())
.eq(PatrolDeleteShopCandidateEntity::getUserId, userId)
.eq(PatrolDeleteShopCandidateEntity::getShopName, normalized)
.last("limit 1"));
if (existing != null) {
@@ -81,7 +98,7 @@ public class PatrolDeleteResolveService {
return vo;
}
PatrolDeleteShopCandidateEntity entity = new PatrolDeleteShopCandidateEntity();
entity.setUserId(request.getUserId());
entity.setUserId(userId);
entity.setShopName(normalized);
entity.setCreatedAt(LocalDateTime.now());
candidateMapper.insert(entity);

View File

@@ -33,6 +33,7 @@ public class PermissionMenuSchemaInitializer {
new DefaultAdminMenu("店铺管理", "admin_shop_manage", "shop-manage", 50),
new DefaultAdminMenu("跳过跟价ASIN", "admin_skip_price_asin", "skip-price-asin", 60),
new DefaultAdminMenu("查询ASIN", "admin_query_asin", "query-asin", 65),
new DefaultAdminMenu("商品类目", "admin_product_categories", "product-categories", 66),
new DefaultAdminMenu("查看生成记录", "admin_history", "history", 70),
new DefaultAdminMenu("版本管理", "admin_version", "version", 80)
);

View File

@@ -0,0 +1,73 @@
package com.nanri.aiimage.modules.productcategory.controller;
import com.nanri.aiimage.common.api.ApiResponse;
import com.nanri.aiimage.modules.productcategory.model.dto.ProductCategorySaveRequest;
import com.nanri.aiimage.modules.productcategory.model.vo.ProductCategoryItemVo;
import com.nanri.aiimage.modules.productcategory.model.vo.ProductCategoryListVo;
import com.nanri.aiimage.modules.productcategory.service.ProductCategoryService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/admin")
@Tag(name = "商品类目管理", description = "维护商品类目树")
public class ProductCategoryController {
private final ProductCategoryService productCategoryService;
@GetMapping("/product-categories")
@Operation(summary = "查询商品类目树")
public ApiResponse<ProductCategoryListVo> list(@RequestParam(value = "keyword", required = false) String keyword) {
return ApiResponse.success(productCategoryService.list(keyword));
}
@GetMapping("/product-categories/children")
@Operation(summary = "分页查询指定父级下的商品类目")
public ApiResponse<ProductCategoryListVo> children(
@RequestParam(value = "parentId", required = false) Long parentId,
@RequestParam(value = "page", defaultValue = "1") Long page,
@RequestParam(value = "pageSize", defaultValue = "20") Long pageSize) {
return ApiResponse.success(productCategoryService.children(parentId, page, pageSize));
}
@GetMapping("/product-categories/search")
@Operation(summary = "分页搜索商品类目")
public ApiResponse<ProductCategoryListVo> search(
@RequestParam(value = "keyword", required = false) String keyword,
@RequestParam(value = "page", defaultValue = "1") Long page,
@RequestParam(value = "pageSize", defaultValue = "20") Long pageSize) {
return ApiResponse.success(productCategoryService.search(keyword, page, pageSize));
}
@PostMapping("/product-category")
@Operation(summary = "新增商品类目")
public ApiResponse<ProductCategoryItemVo> create(@Valid @RequestBody ProductCategorySaveRequest request) {
return ApiResponse.success("创建成功", productCategoryService.create(request));
}
@PutMapping("/product-category/{id}")
@Operation(summary = "更新商品类目")
public ApiResponse<ProductCategoryItemVo> update(@PathVariable Long id,
@Valid @RequestBody ProductCategorySaveRequest request) {
return ApiResponse.success("保存成功", productCategoryService.update(id, request));
}
@DeleteMapping("/product-category/{id}")
@Operation(summary = "删除商品类目")
public ApiResponse<Void> delete(@PathVariable Long id) {
productCategoryService.delete(id);
return ApiResponse.success("删除成功", null);
}
}

View File

@@ -0,0 +1,9 @@
package com.nanri.aiimage.modules.productcategory.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.nanri.aiimage.modules.productcategory.model.entity.ProductCategoryEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ProductCategoryMapper extends BaseMapper<ProductCategoryEntity> {
}

View File

@@ -0,0 +1,26 @@
package com.nanri.aiimage.modules.productcategory.model.dto;
import com.fasterxml.jackson.annotation.JsonAlias;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
@Schema(description = "商品类目保存请求")
public class ProductCategorySaveRequest {
@JsonAlias("parent_id")
@Schema(description = "父级类目 ID空表示顶级")
private Long parentId;
@NotBlank(message = "类目名称不能为空")
@Schema(description = "类目名称", requiredMode = Schema.RequiredMode.REQUIRED)
private String name;
@JsonAlias("sort_order")
@Schema(description = "排序值")
private Integer sortOrder;
@Schema(description = "备注")
private String description;
}

View File

@@ -0,0 +1,24 @@
package com.nanri.aiimage.modules.productcategory.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("biz_product_category")
public class ProductCategoryEntity {
@TableId(type = IdType.AUTO)
private Long id;
private Long parentId;
private String name;
private String categoryKey;
private Integer sortOrder;
private String description;
private Boolean isBuiltin;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,24 @@
package com.nanri.aiimage.modules.productcategory.model.vo;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Data
public class ProductCategoryItemVo {
private Long id;
private Long parentId;
private String name;
private String categoryKey;
private Integer sortOrder;
private String description;
private Boolean isBuiltin;
private Integer childCount;
private Integer level;
private String path;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private List<ProductCategoryItemVo> children = new ArrayList<>();
}

View File

@@ -0,0 +1,15 @@
package com.nanri.aiimage.modules.productcategory.model.vo;
import lombok.Data;
import java.util.List;
@Data
public class ProductCategoryListVo {
private List<ProductCategoryItemVo> tree;
private List<ProductCategoryItemVo> items;
private Long total;
private Long page;
private Long pageSize;
private Boolean hasMore;
}

View File

@@ -0,0 +1,457 @@
package com.nanri.aiimage.modules.productcategory.service;
import cn.hutool.crypto.digest.DigestUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.productcategory.mapper.ProductCategoryMapper;
import com.nanri.aiimage.modules.productcategory.model.dto.ProductCategorySaveRequest;
import com.nanri.aiimage.modules.productcategory.model.entity.ProductCategoryEntity;
import com.nanri.aiimage.modules.productcategory.model.vo.ProductCategoryItemVo;
import com.nanri.aiimage.modules.productcategory.model.vo.ProductCategoryListVo;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
@Service
@RequiredArgsConstructor
public class ProductCategoryService {
private final ProductCategoryMapper productCategoryMapper;
public ProductCategoryListVo list() {
return list(null);
}
public ProductCategoryListVo list(String keyword) {
List<ProductCategoryEntity> rows = productCategoryMapper.selectList(new LambdaQueryWrapper<ProductCategoryEntity>()
.orderByAsc(ProductCategoryEntity::getParentId)
.orderByAsc(ProductCategoryEntity::getSortOrder)
.orderByAsc(ProductCategoryEntity::getId));
rows = filterRowsByKeyword(rows, keyword);
return buildListVo(rows);
}
public ProductCategoryListVo children(Long parentId, long page, long pageSize) {
long safePage = Math.max(page, 1);
long safePageSize = normalizePageSize(pageSize);
Long normalizedParentId = normalizeParentId(parentId);
LambdaQueryWrapper<ProductCategoryEntity> countQuery = new LambdaQueryWrapper<>();
applyParentFilter(countQuery, normalizedParentId);
Long total = productCategoryMapper.selectCount(countQuery);
LambdaQueryWrapper<ProductCategoryEntity> pageQuery = new LambdaQueryWrapper<ProductCategoryEntity>()
.orderByAsc(ProductCategoryEntity::getSortOrder)
.orderByAsc(ProductCategoryEntity::getId)
.last("LIMIT " + ((safePage - 1) * safePageSize) + ", " + safePageSize);
applyParentFilter(pageQuery, normalizedParentId);
List<ProductCategoryEntity> rows = productCategoryMapper.selectList(pageQuery);
return buildPageVo(rows, total, safePage, safePageSize);
}
public ProductCategoryListVo search(String keyword, long page, long pageSize) {
String normalized = normalizeSearchKeyword(keyword);
if (normalized.isBlank()) {
return emptyPageVo(Math.max(page, 1), normalizePageSize(pageSize));
}
long safePage = Math.max(page, 1);
long safePageSize = normalizePageSize(pageSize);
LambdaQueryWrapper<ProductCategoryEntity> countQuery = buildSearchQuery(normalized);
Long total = productCategoryMapper.selectCount(countQuery);
List<ProductCategoryEntity> rows = productCategoryMapper.selectList(buildSearchQuery(normalized)
.orderByAsc(ProductCategoryEntity::getParentId)
.orderByAsc(ProductCategoryEntity::getSortOrder)
.orderByAsc(ProductCategoryEntity::getId)
.last("LIMIT " + ((safePage - 1) * safePageSize) + ", " + safePageSize));
return buildSearchPageVo(rows, total, safePage, safePageSize);
}
@Transactional
public ProductCategoryItemVo create(ProductCategorySaveRequest request) {
String name = normalizeRequired(request.getName(), "类目名称不能为空");
Long parentId = normalizeParentId(request.getParentId());
ensureParentExists(parentId, null);
ensureSiblingNameUnique(parentId, name, null);
ProductCategoryEntity entity = new ProductCategoryEntity();
entity.setParentId(parentId);
entity.setName(name);
entity.setCategoryKey(generateCategoryKey(parentId, name));
entity.setSortOrder(resolveSortOrder(request.getSortOrder()));
entity.setDescription(normalizeDescription(request.getDescription()));
entity.setIsBuiltin(false);
productCategoryMapper.insert(entity);
return findItem(entity.getId());
}
@Transactional
public ProductCategoryItemVo update(Long id, ProductCategorySaveRequest request) {
ProductCategoryEntity entity = getById(id);
String name = normalizeRequired(request.getName(), "类目名称不能为空");
Long parentId = normalizeParentId(request.getParentId());
ensureParentExists(parentId, id);
ensureSiblingNameUnique(parentId, name, id);
entity.setParentId(parentId);
entity.setName(name);
entity.setSortOrder(resolveSortOrder(request.getSortOrder()));
entity.setDescription(normalizeDescription(request.getDescription()));
productCategoryMapper.updateById(entity);
return findItem(id);
}
@Transactional
public void delete(Long id) {
ProductCategoryEntity entity = getById(id);
Long childCount = productCategoryMapper.selectCount(new LambdaQueryWrapper<ProductCategoryEntity>()
.eq(ProductCategoryEntity::getParentId, entity.getId()));
if (childCount != null && childCount > 0) {
throw new BusinessException("请先删除该类目下的子类目");
}
productCategoryMapper.deleteById(entity.getId());
}
private ProductCategoryItemVo findItem(Long id) {
ProductCategoryEntity entity = getById(id);
Long childCount = productCategoryMapper.selectCount(new LambdaQueryWrapper<ProductCategoryEntity>()
.eq(ProductCategoryEntity::getParentId, entity.getId()));
ProductCategoryItemVo item = toItemVo(entity, childCount == null ? 0 : childCount.intValue());
item.setLevel(resolveLevel(entity));
item.setPath(resolvePath(entity));
return item;
}
private ProductCategoryListVo buildListVo(List<ProductCategoryEntity> rows) {
Map<Long, Integer> childCountById = new HashMap<>();
Map<Long, List<ProductCategoryEntity>> byParent = new HashMap<>();
for (ProductCategoryEntity row : rows) {
byParent.computeIfAbsent(row.getParentId(), ignored -> new ArrayList<>()).add(row);
if (row.getParentId() != null) {
childCountById.merge(row.getParentId(), 1, Integer::sum);
}
}
byParent.values().forEach(list -> list.sort(Comparator
.comparing(ProductCategoryEntity::getSortOrder, Comparator.nullsLast(Integer::compareTo))
.thenComparing(ProductCategoryEntity::getId, Comparator.nullsLast(Long::compareTo))));
List<ProductCategoryItemVo> flat = new ArrayList<>();
List<ProductCategoryItemVo> tree = buildChildren(null, 0, "", byParent, childCountById, flat);
ProductCategoryListVo vo = new ProductCategoryListVo();
vo.setTree(tree);
vo.setItems(flat);
vo.setTotal((long) flat.size());
vo.setPage(1L);
vo.setPageSize((long) flat.size());
vo.setHasMore(false);
return vo;
}
private ProductCategoryListVo buildPageVo(List<ProductCategoryEntity> rows, Long total, long page, long pageSize) {
Map<Long, Integer> childCountById = buildChildCountById(rows);
List<ProductCategoryItemVo> items = rows.stream()
.map(row -> toItemVo(row, childCountById.getOrDefault(row.getId(), 0)))
.toList();
ProductCategoryListVo vo = new ProductCategoryListVo();
vo.setItems(items);
vo.setTree(List.of());
vo.setTotal(total == null ? 0L : total);
vo.setPage(page);
vo.setPageSize(pageSize);
vo.setHasMore(page * pageSize < vo.getTotal());
return vo;
}
private ProductCategoryListVo buildSearchPageVo(List<ProductCategoryEntity> rows, Long total, long page, long pageSize) {
Map<Long, Integer> childCountById = buildChildCountById(rows);
List<ProductCategoryItemVo> items = rows.stream()
.map(row -> {
ProductCategoryItemVo item = toItemVo(row, childCountById.getOrDefault(row.getId(), 0));
item.setLevel(resolveLevel(row));
item.setPath(resolvePath(row));
return item;
})
.toList();
ProductCategoryListVo vo = new ProductCategoryListVo();
vo.setItems(items);
vo.setTree(List.of());
vo.setTotal(total == null ? 0L : total);
vo.setPage(page);
vo.setPageSize(pageSize);
vo.setHasMore(page * pageSize < vo.getTotal());
return vo;
}
private ProductCategoryListVo emptyPageVo(long page, long pageSize) {
ProductCategoryListVo vo = new ProductCategoryListVo();
vo.setItems(List.of());
vo.setTree(List.of());
vo.setTotal(0L);
vo.setPage(page);
vo.setPageSize(pageSize);
vo.setHasMore(false);
return vo;
}
private Map<Long, Integer> buildChildCountById(List<ProductCategoryEntity> rows) {
Map<Long, Integer> childCountById = new HashMap<>();
if (rows.isEmpty()) {
return childCountById;
}
for (ProductCategoryEntity row : rows) {
Long childCount = productCategoryMapper.selectCount(new LambdaQueryWrapper<ProductCategoryEntity>()
.eq(ProductCategoryEntity::getParentId, row.getId()));
childCountById.put(row.getId(), childCount == null ? 0 : childCount.intValue());
}
return childCountById;
}
private void applyParentFilter(LambdaQueryWrapper<ProductCategoryEntity> query, Long parentId) {
if (parentId == null) {
query.isNull(ProductCategoryEntity::getParentId);
} else {
query.eq(ProductCategoryEntity::getParentId, parentId);
}
}
private LambdaQueryWrapper<ProductCategoryEntity> buildSearchQuery(String keyword) {
return new LambdaQueryWrapper<ProductCategoryEntity>()
.like(ProductCategoryEntity::getName, keyword)
.or()
.like(ProductCategoryEntity::getCategoryKey, keyword)
.or()
.like(ProductCategoryEntity::getDescription, keyword);
}
private List<ProductCategoryEntity> filterRowsByKeyword(List<ProductCategoryEntity> rows, String keyword) {
String normalized = normalizeSearchKeyword(keyword);
if (normalized.isBlank()) {
return rows;
}
Map<Long, ProductCategoryEntity> byId = new HashMap<>();
Map<Long, List<ProductCategoryEntity>> byParent = new HashMap<>();
for (ProductCategoryEntity row : rows) {
byId.put(row.getId(), row);
byParent.computeIfAbsent(row.getParentId(), ignored -> new ArrayList<>()).add(row);
}
Set<Long> includedIds = new HashSet<>();
for (ProductCategoryEntity row : rows) {
if (!matchesKeyword(row, normalized)) {
continue;
}
includeAncestors(row, byId, includedIds);
includeDescendants(row, byParent, includedIds);
}
if (includedIds.isEmpty()) {
return List.of();
}
return rows.stream()
.filter(row -> includedIds.contains(row.getId()))
.toList();
}
private String normalizeSearchKeyword(String keyword) {
return keyword == null ? "" : keyword.trim().toLowerCase(Locale.ROOT);
}
private boolean matchesKeyword(ProductCategoryEntity row, String keyword) {
return containsKeyword(row.getName(), keyword)
|| containsKeyword(row.getCategoryKey(), keyword)
|| containsKeyword(row.getDescription(), keyword);
}
private boolean containsKeyword(String value, String keyword) {
return value != null && value.toLowerCase(Locale.ROOT).contains(keyword);
}
private void includeAncestors(ProductCategoryEntity row,
Map<Long, ProductCategoryEntity> byId,
Set<Long> includedIds) {
ProductCategoryEntity cursor = row;
Set<Long> visited = new HashSet<>();
while (cursor != null && cursor.getId() != null && visited.add(cursor.getId())) {
includedIds.add(cursor.getId());
cursor = cursor.getParentId() == null ? null : byId.get(cursor.getParentId());
}
}
private void includeDescendants(ProductCategoryEntity row,
Map<Long, List<ProductCategoryEntity>> byParent,
Set<Long> includedIds) {
if (row == null || row.getId() == null) {
return;
}
includedIds.add(row.getId());
for (ProductCategoryEntity child : byParent.getOrDefault(row.getId(), List.of())) {
includeDescendants(child, byParent, includedIds);
}
}
private List<ProductCategoryItemVo> buildChildren(
Long parentId,
int level,
String parentPath,
Map<Long, List<ProductCategoryEntity>> byParent,
Map<Long, Integer> childCountById,
List<ProductCategoryItemVo> flat) {
List<ProductCategoryItemVo> result = new ArrayList<>();
for (ProductCategoryEntity entity : byParent.getOrDefault(parentId, List.of())) {
ProductCategoryItemVo item = toItemVo(entity, childCountById.getOrDefault(entity.getId(), 0));
item.setLevel(level);
item.setPath(parentPath.isBlank() ? item.getName() : parentPath + " / " + item.getName());
flat.add(copyWithoutChildren(item));
item.setChildren(buildChildren(entity.getId(), level + 1, item.getPath(), byParent, childCountById, flat));
result.add(item);
}
return result;
}
private ProductCategoryItemVo toItemVo(ProductCategoryEntity entity, int childCount) {
ProductCategoryItemVo vo = new ProductCategoryItemVo();
vo.setId(entity.getId());
vo.setParentId(entity.getParentId());
vo.setName(entity.getName());
vo.setCategoryKey(entity.getCategoryKey());
vo.setSortOrder(entity.getSortOrder());
vo.setDescription(entity.getDescription());
vo.setIsBuiltin(Boolean.TRUE.equals(entity.getIsBuiltin()));
vo.setChildCount(childCount);
vo.setCreatedAt(entity.getCreatedAt());
vo.setUpdatedAt(entity.getUpdatedAt());
return vo;
}
private ProductCategoryItemVo copyWithoutChildren(ProductCategoryItemVo source) {
ProductCategoryItemVo vo = new ProductCategoryItemVo();
vo.setId(source.getId());
vo.setParentId(source.getParentId());
vo.setName(source.getName());
vo.setCategoryKey(source.getCategoryKey());
vo.setSortOrder(source.getSortOrder());
vo.setDescription(source.getDescription());
vo.setIsBuiltin(source.getIsBuiltin());
vo.setChildCount(source.getChildCount());
vo.setLevel(source.getLevel());
vo.setPath(source.getPath());
vo.setCreatedAt(source.getCreatedAt());
vo.setUpdatedAt(source.getUpdatedAt());
return vo;
}
private int resolveLevel(ProductCategoryEntity entity) {
int level = 0;
ProductCategoryEntity cursor = entity;
Set<Long> visited = new HashSet<>();
while (cursor != null && cursor.getParentId() != null && visited.add(cursor.getId())) {
ProductCategoryEntity parent = productCategoryMapper.selectById(cursor.getParentId());
if (parent == null) {
break;
}
level += 1;
cursor = parent;
}
return level;
}
private String resolvePath(ProductCategoryEntity entity) {
List<String> names = new ArrayList<>();
ProductCategoryEntity cursor = entity;
Set<Long> visited = new HashSet<>();
while (cursor != null && cursor.getId() != null && visited.add(cursor.getId())) {
names.add(cursor.getName());
cursor = cursor.getParentId() == null ? null : productCategoryMapper.selectById(cursor.getParentId());
}
List<String> ordered = new ArrayList<>();
for (int i = names.size() - 1; i >= 0; i--) {
ordered.add(names.get(i));
}
return String.join(" / ", ordered);
}
private ProductCategoryEntity getById(Long id) {
ProductCategoryEntity entity = productCategoryMapper.selectById(id);
if (entity == null) {
throw new BusinessException("类目不存在");
}
return entity;
}
private void ensureParentExists(Long parentId, Long currentId) {
if (parentId == null) {
return;
}
if (currentId != null && currentId.equals(parentId)) {
throw new BusinessException("父级类目不能选择自己");
}
ProductCategoryEntity parent = productCategoryMapper.selectById(parentId);
if (parent == null) {
throw new BusinessException("父级类目不存在");
}
Set<Long> visited = new HashSet<>();
Long cursor = parent.getParentId();
while (cursor != null) {
if (!visited.add(cursor)) {
throw new BusinessException("类目层级存在循环");
}
if (currentId != null && currentId.equals(cursor)) {
throw new BusinessException("父级类目不能选择自己的子级");
}
ProductCategoryEntity row = productCategoryMapper.selectById(cursor);
cursor = row == null ? null : row.getParentId();
}
}
private void ensureSiblingNameUnique(Long parentId, String name, Long excludeId) {
LambdaQueryWrapper<ProductCategoryEntity> query = new LambdaQueryWrapper<ProductCategoryEntity>()
.eq(ProductCategoryEntity::getName, name);
if (parentId == null) {
query.isNull(ProductCategoryEntity::getParentId);
} else {
query.eq(ProductCategoryEntity::getParentId, parentId);
}
if (excludeId != null) {
query.ne(ProductCategoryEntity::getId, excludeId);
}
Long count = productCategoryMapper.selectCount(query);
if (count != null && count > 0) {
throw new BusinessException("同级类目名称已存在");
}
}
private String normalizeRequired(String value, String message) {
String normalized = value == null ? "" : value.trim();
if (normalized.isBlank()) {
throw new BusinessException(message);
}
return normalized;
}
private Long normalizeParentId(Long value) {
return value == null || value <= 0 ? null : value;
}
private Integer resolveSortOrder(Integer value) {
return value == null ? 100 : value;
}
private long normalizePageSize(long value) {
return Math.min(Math.max(value, 1), 100);
}
private String normalizeDescription(String value) {
String normalized = value == null ? "" : value.trim();
return normalized.length() > 512 ? normalized.substring(0, 512) : normalized;
}
private String generateCategoryKey(Long parentId, String name) {
String source = (parentId == null ? "root" : String.valueOf(parentId)) + ":" + name;
return "custom_" + DigestUtil.sha1Hex(source).substring(0, 20);
}
}

View File

@@ -1,9 +1,15 @@
package com.nanri.aiimage.modules.productrisk.model.vo;
import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.nanri.aiimage.modules.shopkey.model.dto.SkipPriceAsinDetailDto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Data
@Schema(description = "单店结果项:列表接口与任务详情中共用,对应 biz_file_result 一行 + 任务/请求衍生字段")
public class ProductRiskResultItemVo {
@@ -65,4 +71,12 @@ public class ProductRiskResultItemVo {
@JsonProperty("scheduledAt")
@Schema(description = "定时执行时间,未设置时为空")
private String scheduledAt;
@JsonAlias("skip_asins_by_country")
@JsonProperty("skipAsinsByCountry")
private Map<String, List<String>> skipAsinsByCountry = new LinkedHashMap<>();
@JsonAlias("skip_asin_details_by_country")
@JsonProperty("skipAsinDetailsByCountry")
private Map<String, List<SkipPriceAsinDetailDto>> skipAsinDetailsByCountry = new LinkedHashMap<>();
}

View File

@@ -1,12 +1,16 @@
package com.nanri.aiimage.modules.productrisk.model.vo;
import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCountryAsinsDto;
import com.nanri.aiimage.modules.shopkey.model.dto.SkipPriceAsinDetailDto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 与 Python 队列约定字段对齐,供前端原样推入 enqueue_json。
@@ -52,4 +56,12 @@ public class ProductRiskShopQueueItemVo {
@JsonProperty("queryAsins")
@Schema(description = "查询 ASIN 模块返回的后台维护 ASIN 数据;按全表聚合返回,不按当前店铺过滤")
private List<QueryAsinCountryAsinsDto> queryAsins = new ArrayList<>();
@JsonAlias("skip_asins_by_country")
@JsonProperty("skipAsinsByCountry")
private Map<String, List<String>> skipAsinsByCountry = new LinkedHashMap<>();
@JsonAlias("skip_asin_details_by_country")
@JsonProperty("skipAsinDetailsByCountry")
private Map<String, List<SkipPriceAsinDetailDto>> skipAsinDetailsByCountry = new LinkedHashMap<>();
}

View File

@@ -17,7 +17,10 @@ import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopIndexService;
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.LocalDateTime;
import java.util.ArrayList;
@@ -34,6 +37,13 @@ public class QueryAsinResolveService {
private final QueryAsinShopCandidateMapper candidateMapper;
private final ZiniaoShopSwitchService ziniaoShopSwitchService;
private final QueryAsinMapper queryAsinMapper;
private final PlatformTransactionManager transactionManager;
private TransactionTemplate newRequiresNewTemplate() {
TransactionTemplate template = new TransactionTemplate(transactionManager);
template.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
return template;
}
public List<ProductRiskCandidateVo> listCandidates(Long userId) {
validateUserId(userId);
@@ -52,14 +62,13 @@ public class QueryAsinResolveService {
return list;
}
@Transactional
public ProductRiskCandidateVo addCandidate(ProductRiskCandidateAddRequest request) {
validateUserId(request.getUserId());
String normalized = ziniaoShopSwitchService.normalizeShopName(request.getShopName());
if (normalized.isBlank()) {
throw new BusinessException("店铺名不能为空");
}
ZiniaoShopMatchResultVo indexHit = ziniaoShopSwitchService.findIndexedStoreByName(normalized, false);
ZiniaoShopMatchResultVo indexHit = resolveIndexedShop(normalized);
if (indexHit == null || !indexHit.isMatched()) {
String hint = indexHit != null && indexHit.getMatchMessage() != null && !indexHit.getMatchMessage().isBlank()
? indexHit.getMatchMessage()
@@ -69,9 +78,17 @@ public class QueryAsinResolveService {
if (ZiniaoShopIndexService.MATCH_STATUS_CONFLICT.equals(indexHit.getMatchStatus())) {
throw new BusinessException(indexHit.getMatchMessage() != null ? indexHit.getMatchMessage() : "存在多个同名店铺,请人工确认");
}
return newRequiresNewTemplate().execute(status -> persistCandidate(request.getUserId(), normalized));
}
private ZiniaoShopMatchResultVo resolveIndexedShop(String normalizedShopName) {
return ziniaoShopSwitchService.findIndexedStoreByName(normalizedShopName, false);
}
private ProductRiskCandidateVo persistCandidate(Long userId, String normalized) {
QueryAsinShopCandidateEntity existing = candidateMapper.selectOne(
new LambdaQueryWrapper<QueryAsinShopCandidateEntity>()
.eq(QueryAsinShopCandidateEntity::getUserId, request.getUserId())
.eq(QueryAsinShopCandidateEntity::getUserId, userId)
.eq(QueryAsinShopCandidateEntity::getShopName, normalized)
.last("limit 1"));
if (existing != null) {
@@ -82,7 +99,7 @@ public class QueryAsinResolveService {
return vo;
}
QueryAsinShopCandidateEntity entity = new QueryAsinShopCandidateEntity();
entity.setUserId(request.getUserId());
entity.setUserId(userId);
entity.setShopName(normalized);
entity.setCreatedAt(LocalDateTime.now());
candidateMapper.insert(entity);

View File

@@ -1,6 +1,7 @@
package com.nanri.aiimage.modules.shopkey.controller;
import com.nanri.aiimage.common.api.ApiResponse;
import com.nanri.aiimage.common.util.DownloadHeaderUtil;
import com.nanri.aiimage.modules.shopkey.model.dto.QueryAsinCountryUpdateRequest;
import com.nanri.aiimage.modules.shopkey.model.dto.QueryAsinCreateRequest;
import com.nanri.aiimage.modules.shopkey.model.vo.QueryAsinImportProgressVo;
@@ -13,6 +14,9 @@ import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -24,12 +28,17 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/admin/query-asins")
@Tag(name = "查询 ASIN", description = "维护店铺按国家查询的 ASIN")
public class QueryAsinController {
private static final DateTimeFormatter EXPORT_FILENAME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss");
private final QueryAsinService queryAsinService;
@GetMapping
@@ -52,6 +61,23 @@ public class QueryAsinController {
Boolean.TRUE.equals(superAdmin)));
}
@GetMapping("/export")
@Operation(summary = "导出查询 ASIN")
public ResponseEntity<byte[]> export(
@Parameter(description = "分组ID") @RequestParam(required = false) Long groupId,
@Parameter(description = "店铺名") @RequestParam(required = false) String shopName,
@Parameter(description = "ASIN") @RequestParam(required = false) String asin,
@Parameter(description = "当前操作人用户ID") @RequestParam(required = false) Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) {
byte[] bytes = queryAsinService.export(groupId, shopName, asin, operatorId, Boolean.TRUE.equals(superAdmin));
String filename = "query-asin-" + LocalDateTime.now().format(EXPORT_FILENAME_FORMATTER) + ".xlsx";
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, DownloadHeaderUtil.contentDisposition(filename))
.contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
.contentLength(bytes.length)
.body(bytes);
}
@PostMapping
@Operation(summary = "新增或覆盖查询 ASIN")
public ApiResponse<QueryAsinItemVo> create(

View File

@@ -1,6 +1,7 @@
package com.nanri.aiimage.modules.shopkey.controller;
import com.nanri.aiimage.common.api.ApiResponse;
import com.nanri.aiimage.common.util.DownloadHeaderUtil;
import com.nanri.aiimage.modules.shopkey.model.dto.SkipPriceAsinCountryUpdateRequest;
import com.nanri.aiimage.modules.shopkey.model.dto.SkipPriceAsinCreateRequest;
import com.nanri.aiimage.modules.shopkey.model.vo.QueryAsinImportProgressVo;
@@ -13,6 +14,9 @@ import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -24,24 +28,36 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/admin/skip-price-asins")
@Tag(name = "跳过跟价 ASIN", description = "维护店铺按国家跳过跟价的 ASIN")
@RequestMapping({
"/admin/skip-price-asins",
"/admin/skip-price-asin",
"/api/admin/skip-price-asins",
"/api/admin/skip-price-asin",
"/skip-price-asins",
"/skip-price-asin"
})
@Tag(name = "Skip Price ASIN", description = "Manage skip price asin data")
public class SkipPriceAsinController {
private static final DateTimeFormatter EXPORT_FILENAME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss");
private final SkipPriceAsinService skipPriceAsinService;
@GetMapping
@Operation(summary = "分页查询跳过跟价 ASIN")
@Operation(summary = "Page query skip price asins")
public ApiResponse<SkipPriceAsinPageVo> page(
@Parameter(description = "页码") @RequestParam(defaultValue = "1") Long page,
@Parameter(description = "每页数量") @RequestParam(defaultValue = "15") Long pageSize,
@Parameter(description = "分组ID") @RequestParam(required = false) Long groupId,
@Parameter(description = "店铺名") @RequestParam(required = false) String shopName,
@Parameter(description = "ASIN") @RequestParam(required = false) String asin,
@Parameter(description = "当前操作人用户ID") @RequestParam(required = false) Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) {
@Parameter(description = "page") @RequestParam(name = "page", defaultValue = "1") Long page,
@Parameter(description = "page size") @RequestParam(name = "page_size", defaultValue = "15") Long pageSize,
@Parameter(description = "group id") @RequestParam(name = "group_id", required = false) Long groupId,
@Parameter(description = "shop name") @RequestParam(name = "shop_name", required = false) String shopName,
@Parameter(description = "asin") @RequestParam(name = "asin", required = false) String asin,
@Parameter(description = "operator user id") @RequestParam(name = "operator_id", required = false) Long operatorId,
@Parameter(description = "is super admin") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) {
return ApiResponse.success(skipPriceAsinService.page(
page,
pageSize,
@@ -52,59 +68,76 @@ public class SkipPriceAsinController {
Boolean.TRUE.equals(superAdmin)));
}
@GetMapping("/export")
@Operation(summary = "Export skip price asins")
public ResponseEntity<byte[]> export(
@Parameter(description = "group id") @RequestParam(name = "group_id", required = false) Long groupId,
@Parameter(description = "shop name") @RequestParam(name = "shop_name", required = false) String shopName,
@Parameter(description = "asin") @RequestParam(name = "asin", required = false) String asin,
@Parameter(description = "operator user id") @RequestParam(name = "operator_id", required = false) Long operatorId,
@Parameter(description = "is super admin") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) {
byte[] bytes = skipPriceAsinService.export(groupId, shopName, asin, operatorId, Boolean.TRUE.equals(superAdmin));
String filename = "skip-price-asin-" + LocalDateTime.now().format(EXPORT_FILENAME_FORMATTER) + ".xlsx";
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, DownloadHeaderUtil.contentDisposition(filename))
.contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
.contentLength(bytes.length)
.body(bytes);
}
@PostMapping
@Operation(summary = "新增或覆盖跳过跟价 ASIN")
@Operation(summary = "Create or update skip price asin")
public ApiResponse<SkipPriceAsinItemVo> create(
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin,
@Parameter(description = "operator user id") @RequestParam(name = "operator_id") Long operatorId,
@Parameter(description = "is super admin") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin,
@Valid @RequestBody SkipPriceAsinCreateRequest request) {
return ApiResponse.success("保存成功",
return ApiResponse.success("\u4fdd\u5b58\u6210\u529f",
skipPriceAsinService.createOrUpdate(request, operatorId, Boolean.TRUE.equals(superAdmin)));
}
@PostMapping("/import")
@Operation(summary = "导入新增跳过跟价 ASIN")
@Operation(summary = "Import skip price asin")
public ApiResponse<QueryAsinImportStartVo> importExcel(
@Parameter(description = "xlsx/xls 文件,文件名必须等于店铺名", required = true) @RequestParam("file") MultipartFile file,
@Parameter(description = "页面选择的分组ID", required = true) @RequestParam Long groupId,
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) {
return ApiResponse.success("开始导入",
@Parameter(description = "xlsx or xls file", required = true) @RequestParam("file") MultipartFile file,
@Parameter(description = "group id", required = true) @RequestParam(name = "group_id") Long groupId,
@Parameter(description = "operator user id") @RequestParam(name = "operator_id") Long operatorId,
@Parameter(description = "is super admin") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) {
return ApiResponse.success("\u5f00\u59cb\u5bfc\u5165",
skipPriceAsinService.startImport(file, groupId, operatorId, Boolean.TRUE.equals(superAdmin)));
}
@GetMapping("/import/{importId}")
@Operation(summary = "查询导入新增进度")
@Operation(summary = "Query import progress")
public ApiResponse<QueryAsinImportProgressVo> importProgress(@PathVariable String importId) {
return ApiResponse.success(skipPriceAsinService.getImportProgress(importId));
}
@PostMapping("/delete-import")
@Operation(summary = "导入删除跳过跟价 ASIN")
@Operation(summary = "Import delete skip price asin")
public ApiResponse<QueryAsinImportStartVo> deleteImportExcel(
@Parameter(description = "xlsx/xls 文件,文件名必须等于店铺名", required = true) @RequestParam("file") MultipartFile file,
@Parameter(description = "页面选择的分组ID", required = true) @RequestParam Long groupId,
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) {
return ApiResponse.success("开始删除",
@Parameter(description = "xlsx or xls file", required = true) @RequestParam("file") MultipartFile file,
@Parameter(description = "group id", required = true) @RequestParam(name = "group_id") Long groupId,
@Parameter(description = "operator user id") @RequestParam(name = "operator_id") Long operatorId,
@Parameter(description = "is super admin") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) {
return ApiResponse.success("\u5f00\u59cb\u5220\u9664",
skipPriceAsinService.startDeleteImport(file, groupId, operatorId, Boolean.TRUE.equals(superAdmin)));
}
@GetMapping("/delete-import/{importId}")
@Operation(summary = "查询导入删除进度")
@Operation(summary = "Query delete import progress")
public ApiResponse<QueryAsinImportProgressVo> deleteImportProgress(@PathVariable String importId) {
return ApiResponse.success(skipPriceAsinService.getDeleteImportProgress(importId));
}
@PutMapping("/{id}/countries/{country}")
@Operation(summary = "编辑指定国家的跳过跟价 ASIN")
@PutMapping({"/{id}/countries/{country}", "/{id}/country/{country}"})
@Operation(summary = "Update one country skip price asin")
public ApiResponse<SkipPriceAsinItemVo> updateCountry(
@Parameter(description = "主键ID", required = true) @PathVariable Long id,
@Parameter(description = "国家编码", required = true) @PathVariable String country,
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin,
@Parameter(description = "id", required = true) @PathVariable Long id,
@Parameter(description = "country code", required = true) @PathVariable String country,
@Parameter(description = "operator user id") @RequestParam(name = "operator_id") Long operatorId,
@Parameter(description = "is super admin") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin,
@Valid @RequestBody SkipPriceAsinCountryUpdateRequest request) {
return ApiResponse.success("保存成功", skipPriceAsinService.updateCountry(
return ApiResponse.success("\u4fdd\u5b58\u6210\u529f", skipPriceAsinService.updateCountry(
id,
country,
request.getAsin(),
@@ -113,14 +146,14 @@ public class SkipPriceAsinController {
Boolean.TRUE.equals(superAdmin)));
}
@DeleteMapping("/{id}/countries/{country}")
@Operation(summary = "删除指定国家的跳过跟价 ASIN")
@DeleteMapping({"/{id}/countries/{country}", "/{id}/country/{country}"})
@Operation(summary = "Delete one country skip price asin")
public ApiResponse<Void> deleteCountry(
@Parameter(description = "主键ID", required = true) @PathVariable Long id,
@Parameter(description = "国家编码", required = true) @PathVariable String country,
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) {
@Parameter(description = "id", required = true) @PathVariable Long id,
@Parameter(description = "country code", required = true) @PathVariable String country,
@Parameter(description = "operator user id") @RequestParam(name = "operator_id") Long operatorId,
@Parameter(description = "is super admin") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) {
skipPriceAsinService.deleteCountry(id, country, operatorId, Boolean.TRUE.equals(superAdmin));
return ApiResponse.success("删除成功", null);
return ApiResponse.success("\u5220\u9664\u6210\u529f", null);
}
}

View File

@@ -0,0 +1,17 @@
package com.nanri.aiimage.modules.shopkey.model.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "跳过 ASIN 明细")
public class SkipPriceAsinDetailDto {
@Schema(description = "ASIN")
private String asin;
@JsonProperty("minimumPrice")
@Schema(description = "最低价,未配置时为空字符串")
private String minimumPrice;
}

View File

@@ -16,12 +16,18 @@ import com.nanri.aiimage.modules.shopkey.model.vo.QueryAsinPageVo;
import com.nanri.aiimage.modules.shopkey.model.vo.ShopManageGroupItemVo;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.nio.file.Files;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
@@ -41,6 +47,7 @@ public class QueryAsinService {
private static final List<String> SUPPORTED_COUNTRIES = List.of("DE", "UK", "FR", "IT", "ES");
private static final String KEY_SEPARATOR = Character.toString((char) 1);
private static final String UTF8_BOM = String.valueOf((char) 0xFEFF);
private static final DateTimeFormatter EXPORT_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final QueryAsinMapper queryAsinMapper;
private final ShopManageGroupService shopManageGroupService;
@@ -52,32 +59,15 @@ public class QueryAsinService {
Long operatorId, boolean superAdmin) {
long safePage = Math.max(page, 1);
long safePageSize = Math.min(Math.max(pageSize, 1), 100);
String safeShopName = normalizeBlank(shopName);
String safeAsin = normalizeBlank(asin);
Set<Long> accessibleGroupIds = superAdmin ? Set.of() : shopManageGroupService.listAccessibleGroupIds(operatorId, false);
LambdaQueryWrapper<QueryAsinEntity> query = new LambdaQueryWrapper<QueryAsinEntity>()
.eq(groupId != null && groupId > 0, QueryAsinEntity::getGroupId, groupId)
.like(!safeShopName.isEmpty(), QueryAsinEntity::getShopName, safeShopName)
.and(!safeAsin.isEmpty(), wrapper -> wrapper
.like(QueryAsinEntity::getAsinDe, safeAsin)
.or().like(QueryAsinEntity::getAsinUk, safeAsin)
.or().like(QueryAsinEntity::getAsinFr, safeAsin)
.or().like(QueryAsinEntity::getAsinIt, safeAsin)
.or().like(QueryAsinEntity::getAsinEs, safeAsin))
.orderByDesc(QueryAsinEntity::getId);
if (!superAdmin) {
if (accessibleGroupIds.isEmpty()) {
query.eq(QueryAsinEntity::getId, -1L);
} else {
query.in(QueryAsinEntity::getGroupId, accessibleGroupIds);
}
}
Long total = queryAsinMapper.selectCount(query);
List<QueryAsinEntity> rows = queryAsinMapper
.selectList(query.last("LIMIT " + ((safePage - 1) * safePageSize) + ", " + safePageSize));
Long total = countFilteredRows(groupId, shopName, asin, operatorId, superAdmin);
List<QueryAsinEntity> rows = listFilteredRows(
groupId,
shopName,
asin,
operatorId,
superAdmin,
(safePage - 1) * safePageSize,
safePageSize);
Map<Long, String> groupNameById = shopManageGroupService.buildGroupNameMap(rows.stream()
.map(QueryAsinEntity::getGroupId)
@@ -93,6 +83,110 @@ public class QueryAsinService {
return vo;
}
public byte[] export(Long groupId, String shopName, String asin, Long operatorId, boolean superAdmin) {
List<QueryAsinEntity> rows = listFilteredRows(groupId, shopName, asin, operatorId, superAdmin, null, null);
Map<Long, String> groupNameById = shopManageGroupService.buildGroupNameMap(rows.stream()
.map(QueryAsinEntity::getGroupId)
.filter(id -> id != null && id > 0)
.distinct()
.toList());
try (SXSSFWorkbook workbook = new SXSSFWorkbook(100);
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
Sheet sheet = workbook.createSheet("QueryAsin");
writeExportHeader(sheet);
for (int index = 0; index < rows.size(); index++) {
writeExportRow(sheet, index + 1, rows.get(index), groupNameById.get(rows.get(index).getGroupId()));
}
applyExportColumnWidths(sheet);
workbook.write(outputStream);
workbook.dispose();
return outputStream.toByteArray();
} catch (Exception ex) {
throw new BusinessException("导出查询 ASIN 失败");
}
}
private Long countFilteredRows(Long groupId, String shopName, String asin, Long operatorId, boolean superAdmin) {
return queryAsinMapper.selectCount(buildFilterQuery(groupId, shopName, asin, operatorId, superAdmin));
}
private List<QueryAsinEntity> listFilteredRows(Long groupId, String shopName, String asin,
Long operatorId, boolean superAdmin,
Long offset, Long limit) {
LambdaQueryWrapper<QueryAsinEntity> query = buildFilterQuery(groupId, shopName, asin, operatorId, superAdmin);
if (offset != null && limit != null) {
query.last("LIMIT " + Math.max(0L, offset) + ", " + Math.max(1L, limit));
}
return queryAsinMapper.selectList(query);
}
private LambdaQueryWrapper<QueryAsinEntity> buildFilterQuery(Long groupId, String shopName, String asin,
Long operatorId, boolean superAdmin) {
String safeShopName = normalizeBlank(shopName);
String safeAsin = normalizeBlank(asin);
Set<Long> accessibleGroupIds = superAdmin ? Set.of() : shopManageGroupService.listAccessibleGroupIds(operatorId, false);
LambdaQueryWrapper<QueryAsinEntity> query = new LambdaQueryWrapper<QueryAsinEntity>()
.eq(groupId != null && groupId > 0, QueryAsinEntity::getGroupId, groupId)
.like(!safeShopName.isEmpty(), QueryAsinEntity::getShopName, safeShopName)
.and(!safeAsin.isEmpty(), wrapper -> wrapper
.like(QueryAsinEntity::getAsinDe, safeAsin)
.or().like(QueryAsinEntity::getAsinUk, safeAsin)
.or().like(QueryAsinEntity::getAsinFr, safeAsin)
.or().like(QueryAsinEntity::getAsinIt, safeAsin)
.or().like(QueryAsinEntity::getAsinEs, safeAsin))
.orderByDesc(QueryAsinEntity::getId);
if (!superAdmin) {
if (accessibleGroupIds.isEmpty()) {
query.eq(QueryAsinEntity::getId, -1L);
} else {
query.in(QueryAsinEntity::getGroupId, accessibleGroupIds);
}
}
return query;
}
private void writeExportHeader(Sheet sheet) {
Row header = sheet.createRow(0);
String[] headers = {
"序号", "分组", "店铺名",
"德国ASIN", "英国ASIN", "法国ASIN", "意大利ASIN", "西班牙ASIN",
"创建时间", "更新时间"
};
for (int i = 0; i < headers.length; i++) {
header.createCell(i).setCellValue(headers[i]);
}
}
private void writeExportRow(Sheet sheet, int rowNo, QueryAsinEntity entity, String groupName) {
Row row = sheet.createRow(rowNo);
int col = 0;
row.createCell(col++).setCellValue(rowNo);
row.createCell(col++).setCellValue(blankToEmpty(groupName));
row.createCell(col++).setCellValue(blankToEmpty(entity.getShopName()));
row.createCell(col++).setCellValue(blankToEmpty(entity.getAsinDe()));
row.createCell(col++).setCellValue(blankToEmpty(entity.getAsinUk()));
row.createCell(col++).setCellValue(blankToEmpty(entity.getAsinFr()));
row.createCell(col++).setCellValue(blankToEmpty(entity.getAsinIt()));
row.createCell(col++).setCellValue(blankToEmpty(entity.getAsinEs()));
row.createCell(col++).setCellValue(formatExportTime(entity.getCreatedAt()));
row.createCell(col).setCellValue(formatExportTime(entity.getUpdatedAt()));
}
private void applyExportColumnWidths(Sheet sheet) {
int[] widths = {
2800, 5200, 5200,
4600, 4600, 4600, 4600, 4600,
5200, 5200
};
for (int i = 0; i < widths.length; i++) {
sheet.setColumnWidth(i, widths[i]);
}
}
private String formatExportTime(LocalDateTime value) {
return value == null ? "" : value.format(EXPORT_TIME_FORMATTER);
}
@Transactional
public QueryAsinItemVo createOrUpdate(QueryAsinCreateRequest request, Long operatorId, boolean superAdmin) {
ShopManageGroupEntity group = shopManageGroupService.getAccessibleById(request.getGroupId(), operatorId, superAdmin);

View File

@@ -6,6 +6,7 @@ import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.shopkey.mapper.SkipPriceAsinMapper;
import com.nanri.aiimage.modules.shopkey.mapper.ShopManageMapper;
import com.nanri.aiimage.modules.shopkey.model.dto.SkipPriceAsinCreateRequest;
import com.nanri.aiimage.modules.shopkey.model.dto.SkipPriceAsinDetailDto;
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageEntity;
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageGroupEntity;
import com.nanri.aiimage.modules.shopkey.model.entity.SkipPriceAsinEntity;
@@ -21,16 +22,19 @@ 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.ss.usermodel.WorkbookFactory;
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.nio.file.Files;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
@@ -45,6 +49,7 @@ import java.util.concurrent.ConcurrentHashMap;
public class SkipPriceAsinService {
private static final List<String> SUPPORTED_COUNTRIES = List.of("DE", "UK", "FR", "IT", "ES");
private static final DateTimeFormatter EXPORT_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final SkipPriceAsinMapper skipPriceAsinMapper;
private final ShopManageMapper shopManageMapper;
@@ -56,32 +61,15 @@ public class SkipPriceAsinService {
Long operatorId, boolean superAdmin) {
long safePage = Math.max(page, 1);
long safePageSize = Math.min(Math.max(pageSize, 1), 100);
String safeShopName = normalizeBlank(shopName);
String safeAsin = normalizeBlank(asin);
Set<Long> accessibleGroupIds = superAdmin ? Set.of() : shopManageGroupService.listAccessibleGroupIds(operatorId, false);
LambdaQueryWrapper<SkipPriceAsinEntity> query = new LambdaQueryWrapper<SkipPriceAsinEntity>()
.eq(groupId != null && groupId > 0, SkipPriceAsinEntity::getGroupId, groupId)
.like(!safeShopName.isEmpty(), SkipPriceAsinEntity::getShopName, safeShopName)
.and(!safeAsin.isEmpty(), wrapper -> wrapper
.like(SkipPriceAsinEntity::getAsinDe, safeAsin)
.or().like(SkipPriceAsinEntity::getAsinUk, safeAsin)
.or().like(SkipPriceAsinEntity::getAsinFr, safeAsin)
.or().like(SkipPriceAsinEntity::getAsinIt, safeAsin)
.or().like(SkipPriceAsinEntity::getAsinEs, safeAsin))
.orderByDesc(SkipPriceAsinEntity::getId);
if (!superAdmin) {
if (accessibleGroupIds.isEmpty()) {
query.eq(SkipPriceAsinEntity::getId, -1L);
} else {
query.in(SkipPriceAsinEntity::getGroupId, accessibleGroupIds);
}
}
Long total = skipPriceAsinMapper.selectCount(query);
List<SkipPriceAsinEntity> rows = skipPriceAsinMapper
.selectList(query.last("LIMIT " + ((safePage - 1) * safePageSize) + ", " + safePageSize));
Long total = countFilteredRows(groupId, shopName, asin, operatorId, superAdmin);
List<SkipPriceAsinEntity> rows = listFilteredRows(
groupId,
shopName,
asin,
operatorId,
superAdmin,
(safePage - 1) * safePageSize,
safePageSize);
Map<Long, String> groupNameById = shopManageGroupService.buildGroupNameMap(rows.stream()
.map(SkipPriceAsinEntity::getGroupId)
@@ -97,6 +85,127 @@ public class SkipPriceAsinService {
return vo;
}
public byte[] export(Long groupId, String shopName, String asin, Long operatorId, boolean superAdmin) {
List<SkipPriceAsinEntity> rows = listFilteredRows(groupId, shopName, asin, operatorId, superAdmin, null, null);
Map<Long, String> groupNameById = shopManageGroupService.buildGroupNameMap(rows.stream()
.map(SkipPriceAsinEntity::getGroupId)
.filter(id -> id != null && id > 0)
.distinct()
.toList());
try (SXSSFWorkbook workbook = new SXSSFWorkbook(100);
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
Sheet sheet = workbook.createSheet("SkipPriceAsin");
writeExportHeader(sheet);
for (int index = 0; index < rows.size(); index++) {
writeExportRow(sheet, index + 1, rows.get(index), groupNameById.get(rows.get(index).getGroupId()));
}
applyExportColumnWidths(sheet);
workbook.write(outputStream);
workbook.dispose();
return outputStream.toByteArray();
} catch (Exception ex) {
throw new BusinessException("导出跳过跟价 ASIN 失败");
}
}
private Long countFilteredRows(Long groupId, String shopName, String asin, Long operatorId, boolean superAdmin) {
return skipPriceAsinMapper.selectCount(buildFilterQuery(groupId, shopName, asin, operatorId, superAdmin));
}
private List<SkipPriceAsinEntity> listFilteredRows(Long groupId, String shopName, String asin,
Long operatorId, boolean superAdmin,
Long offset, Long limit) {
LambdaQueryWrapper<SkipPriceAsinEntity> query = buildFilterQuery(groupId, shopName, asin, operatorId, superAdmin);
if (offset != null && limit != null) {
query.last("LIMIT " + Math.max(0L, offset) + ", " + Math.max(1L, limit));
}
return skipPriceAsinMapper.selectList(query);
}
private LambdaQueryWrapper<SkipPriceAsinEntity> buildFilterQuery(Long groupId, String shopName, String asin,
Long operatorId, boolean superAdmin) {
String safeShopName = normalizeBlank(shopName);
String safeAsin = normalizeBlank(asin);
Set<Long> accessibleGroupIds = superAdmin ? Set.of() : shopManageGroupService.listAccessibleGroupIds(operatorId, false);
LambdaQueryWrapper<SkipPriceAsinEntity> query = new LambdaQueryWrapper<SkipPriceAsinEntity>()
.eq(groupId != null && groupId > 0, SkipPriceAsinEntity::getGroupId, groupId)
.like(!safeShopName.isEmpty(), SkipPriceAsinEntity::getShopName, safeShopName)
.and(!safeAsin.isEmpty(), wrapper -> wrapper
.like(SkipPriceAsinEntity::getAsinDe, safeAsin)
.or().like(SkipPriceAsinEntity::getAsinUk, safeAsin)
.or().like(SkipPriceAsinEntity::getAsinFr, safeAsin)
.or().like(SkipPriceAsinEntity::getAsinIt, safeAsin)
.or().like(SkipPriceAsinEntity::getAsinEs, safeAsin))
.orderByDesc(SkipPriceAsinEntity::getId);
if (!superAdmin) {
if (accessibleGroupIds.isEmpty()) {
query.eq(SkipPriceAsinEntity::getId, -1L);
} else {
query.in(SkipPriceAsinEntity::getGroupId, accessibleGroupIds);
}
}
return query;
}
private void writeExportHeader(Sheet sheet) {
Row header = sheet.createRow(0);
String[] headers = {
"序号", "分组", "店铺名",
"德国ASIN", "德国最低价",
"英国ASIN", "英国最低价",
"法国ASIN", "法国最低价",
"意大利ASIN", "意大利最低价",
"西班牙ASIN", "西班牙最低价",
"创建时间", "更新时间"
};
for (int i = 0; i < headers.length; i++) {
header.createCell(i).setCellValue(headers[i]);
}
}
private void writeExportRow(Sheet sheet, int rowNo, SkipPriceAsinEntity entity, String groupName) {
Row row = sheet.createRow(rowNo);
int col = 0;
row.createCell(col++).setCellValue(rowNo);
row.createCell(col++).setCellValue(blankToEmpty(groupName));
row.createCell(col++).setCellValue(blankToEmpty(entity.getShopName()));
row.createCell(col++).setCellValue(blankToEmpty(entity.getAsinDe()));
row.createCell(col++).setCellValue(formatExportPrice(entity.getMinimumPriceDe()));
row.createCell(col++).setCellValue(blankToEmpty(entity.getAsinUk()));
row.createCell(col++).setCellValue(formatExportPrice(entity.getMinimumPriceUk()));
row.createCell(col++).setCellValue(blankToEmpty(entity.getAsinFr()));
row.createCell(col++).setCellValue(formatExportPrice(entity.getMinimumPriceFr()));
row.createCell(col++).setCellValue(blankToEmpty(entity.getAsinIt()));
row.createCell(col++).setCellValue(formatExportPrice(entity.getMinimumPriceIt()));
row.createCell(col++).setCellValue(blankToEmpty(entity.getAsinEs()));
row.createCell(col++).setCellValue(formatExportPrice(entity.getMinimumPriceEs()));
row.createCell(col++).setCellValue(formatExportTime(entity.getCreatedAt()));
row.createCell(col).setCellValue(formatExportTime(entity.getUpdatedAt()));
}
private void applyExportColumnWidths(Sheet sheet) {
int[] widths = {
2800, 5200, 5200,
4600, 3600,
4600, 3600,
4600, 3600,
4600, 3600,
4600, 3600,
5200, 5200
};
for (int i = 0; i < widths.length; i++) {
sheet.setColumnWidth(i, widths[i]);
}
}
private String formatExportPrice(BigDecimal value) {
return value == null ? "" : value.stripTrailingZeros().toPlainString();
}
private String formatExportTime(LocalDateTime value) {
return value == null ? "" : value.format(EXPORT_TIME_FORMATTER);
}
@Transactional
public SkipPriceAsinItemVo createOrUpdate(SkipPriceAsinCreateRequest request, Long operatorId, boolean superAdmin) {
ShopManageGroupEntity group = shopManageGroupService.getAccessibleById(request.getGroupId(), operatorId, superAdmin);
@@ -833,6 +942,72 @@ public class SkipPriceAsinService {
return result;
}
public Map<String, Map<String, List<SkipPriceAsinDetailDto>>> listSkipAsinDetailsByShopAndCountries(
List<String> shopNames, List<String> countryCodes) {
if (shopNames == null || shopNames.isEmpty()) {
return Map.of();
}
LinkedHashSet<String> normalizedShopNames = new LinkedHashSet<>();
for (String shopName : shopNames) {
String normalized = normalizeBlank(shopName);
if (!normalized.isEmpty()) {
normalizedShopNames.add(normalized);
}
}
if (normalizedShopNames.isEmpty()) {
return Map.of();
}
LinkedHashSet<String> normalizedCountries = new LinkedHashSet<>();
if (countryCodes != null) {
for (String countryCode : countryCodes) {
String normalized = normalizeBlank(countryCode).toUpperCase(Locale.ROOT);
if (SUPPORTED_COUNTRIES.contains(normalized)) {
normalizedCountries.add(normalized);
}
}
}
if (normalizedCountries.isEmpty()) {
normalizedCountries.addAll(SUPPORTED_COUNTRIES);
}
List<SkipPriceAsinEntity> rows = skipPriceAsinMapper.selectList(new LambdaQueryWrapper<SkipPriceAsinEntity>()
.in(SkipPriceAsinEntity::getShopName, normalizedShopNames)
.orderByDesc(SkipPriceAsinEntity::getId));
Map<String, Map<String, LinkedHashMap<String, SkipPriceAsinDetailDto>>> grouped = new LinkedHashMap<>();
for (SkipPriceAsinEntity row : rows) {
String shopName = normalizeBlank(row.getShopName());
if (shopName.isEmpty()) {
continue;
}
Map<String, LinkedHashMap<String, SkipPriceAsinDetailDto>> countryMap =
grouped.computeIfAbsent(shopName, ignored -> new LinkedHashMap<>());
for (String country : normalizedCountries) {
String asin = getCountryAsin(row, country);
if (asin.isEmpty()) {
continue;
}
SkipPriceAsinDetailDto detail = new SkipPriceAsinDetailDto();
detail.setAsin(asin);
BigDecimal minimumPrice = getCountryMinimumPrice(row, country);
detail.setMinimumPrice(minimumPrice == null ? "" : minimumPrice.toPlainString());
countryMap.computeIfAbsent(country, ignored -> new LinkedHashMap<>()).putIfAbsent(asin, detail);
}
}
Map<String, Map<String, List<SkipPriceAsinDetailDto>>> result = new LinkedHashMap<>();
for (Map.Entry<String, Map<String, LinkedHashMap<String, SkipPriceAsinDetailDto>>> shopEntry : grouped.entrySet()) {
Map<String, List<SkipPriceAsinDetailDto>> countryResult = new LinkedHashMap<>();
for (String country : normalizedCountries) {
LinkedHashMap<String, SkipPriceAsinDetailDto> details = shopEntry.getValue().get(country);
if (details != null && !details.isEmpty()) {
countryResult.put(country, List.copyOf(details.values()));
}
}
result.put(shopEntry.getKey(), countryResult);
}
return result;
}
public Map<String, List<String>> listAllSkipAsinsByCountry() {
List<SkipPriceAsinEntity> rows = skipPriceAsinMapper.selectList(
new LambdaQueryWrapper<SkipPriceAsinEntity>()

View File

@@ -1,5 +1,6 @@
package com.nanri.aiimage.modules.shopmatch.model.dto;
import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@@ -11,6 +12,11 @@ public class ShopMatchRowDto {
@Schema(description = "ASIN")
private String asin;
@JsonAlias("minimum_price")
@JsonProperty("minimumPrice")
@Schema(description = "最低价")
private String minimumPrice;
@Schema(description = "状态")
private String status;

View File

@@ -1,7 +1,9 @@
package com.nanri.aiimage.modules.shopmatch.model.dto;
import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.nanri.aiimage.modules.productrisk.model.enums.ProductRiskCountryCode;
import com.nanri.aiimage.modules.shopkey.model.dto.SkipPriceAsinDetailDto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@@ -22,4 +24,14 @@ public class ShopMatchShopPayloadDto {
@Schema(description = "按国家分组的 ASIN/状态 列表")
private Map<ProductRiskCountryCode, List<ShopMatchRowDto>> countries = new LinkedHashMap<>();
@JsonAlias("skip_asin_details_by_country")
@JsonProperty("skipAsinDetailsByCountry")
@Schema(description = "Python 回传的跳过 ASIN 明细,按国家携带 asin 和最低价")
private Map<ProductRiskCountryCode, List<SkipPriceAsinDetailDto>> skipAsinDetailsByCountry = new LinkedHashMap<>();
@JsonAlias("skip_asins_by_country")
@JsonProperty("skipAsinsByCountry")
@Schema(description = "Python 回传的跳过 ASIN 列表,兼容旧格式")
private Map<ProductRiskCountryCode, List<String>> skipAsinsByCountry = new LinkedHashMap<>();
}

View File

@@ -30,8 +30,9 @@ public class ShopMatchExcelAssemblyService {
String sheetName = code.getSheetName();
Sheet sheet = workbook.createSheet(sheetName);
Row header = sheet.createRow(0);
header.createCell(0).setCellValue(HEADER[0]);
header.createCell(1).setCellValue(HEADER[1]);
header.createCell(0).setCellValue("ASIN");
header.createCell(1).setCellValue("最低价");
header.createCell(2).setCellValue("状态");
List<ShopMatchRowDto> rows = safe.getOrDefault(sheetName, List.of());
int rowIndex = 1;
for (ShopMatchRowDto item : rows) {
@@ -40,10 +41,12 @@ public class ShopMatchExcelAssemblyService {
}
Row row = sheet.createRow(rowIndex++);
row.createCell(0).setCellValue(item.getAsin() == null ? "" : item.getAsin());
row.createCell(1).setCellValue(item.getStatus() == null ? "" : item.getStatus());
row.createCell(1).setCellValue(item.getMinimumPrice() == null ? "" : item.getMinimumPrice());
row.createCell(2).setCellValue(item.getStatus() == null ? "" : item.getStatus());
}
sheet.setColumnWidth(0, 20 * 256);
sheet.setColumnWidth(1, 18 * 256);
sheet.setColumnWidth(2, 18 * 256);
}
workbook.write(fos);
} catch (Exception ex) {

View File

@@ -24,6 +24,8 @@ import com.nanri.aiimage.modules.shopmatch.model.dto.ShopMatchShopPayloadDto;
import com.nanri.aiimage.modules.shopmatch.model.dto.ShopMatchStageCompleteRequest;
import com.nanri.aiimage.modules.shopmatch.model.dto.ShopMatchSubmitResultRequest;
import com.nanri.aiimage.modules.shopmatch.model.entity.ShopMatchShopCandidateEntity;
import com.nanri.aiimage.modules.shopkey.model.dto.SkipPriceAsinDetailDto;
import com.nanri.aiimage.modules.shopkey.service.SkipPriceAsinService;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
@@ -69,6 +71,7 @@ public class ShopMatchTaskService {
private final TaskResultPayloadService taskResultPayloadService;
private final TaskFileJobService taskFileJobService;
private final TaskDistributedLockService taskDistributedLockService;
private final SkipPriceAsinService skipPriceAsinService;
private FileTaskEntity loadTaskForExecution(Long taskId) {
Map<Long, FileTaskEntity> cachedTasks = shopMatchTaskCacheService.getTaskCacheBatch(List.of(taskId));
@@ -381,6 +384,7 @@ public class ShopMatchTaskService {
}
List<String> countryCodes = normalizeCountryCodes(request.getCountryCodes());
enrichItemsWithSkipAsins(uniqueItems, countryCodes);
List<LocalDateTime> scheduleTimes = normalizeScheduleTimes(request.getScheduleTimes());
LocalDateTime now = now();
@@ -962,6 +966,8 @@ public class ShopMatchTaskService {
vo.setMatched(item.isMatched());
vo.setMatchStatus(item.getMatchStatus());
vo.setMatchMessage(item.getMatchMessage());
vo.setSkipAsinsByCountry(item.getSkipAsinsByCountry());
vo.setSkipAsinDetailsByCountry(item.getSkipAsinDetailsByCountry());
vo.setTaskStatus(taskStatus);
vo.setSuccess(false);
vo.setError(null);
@@ -1078,6 +1084,8 @@ public class ShopMatchTaskService {
vo.setCompanyName(item.getCompanyName());
vo.setMatchStatus(item.getMatchStatus());
vo.setMatchMessage(item.getMatchMessage());
vo.setSkipAsinsByCountry(item.getSkipAsinsByCountry());
vo.setSkipAsinDetailsByCountry(item.getSkipAsinDetailsByCountry());
break;
}
}
@@ -1164,6 +1172,36 @@ public class ShopMatchTaskService {
return new ArrayList<>(byNorm.values());
}
private void enrichItemsWithSkipAsins(List<ProductRiskShopQueueItemVo> items, List<String> countryCodes) {
List<String> shopNames = items.stream()
.map(ProductRiskShopQueueItemVo::getShopName)
.filter(name -> name != null && !name.isBlank())
.distinct()
.toList();
Map<String, Map<String, List<SkipPriceAsinDetailDto>>> detailsByShop =
skipPriceAsinService.listSkipAsinDetailsByShopAndCountries(shopNames, countryCodes);
for (ProductRiskShopQueueItemVo item : items) {
Map<String, List<SkipPriceAsinDetailDto>> detailMap =
detailsByShop.getOrDefault(item.getShopName(), Map.of());
Map<String, List<SkipPriceAsinDetailDto>> normalizedDetails = new LinkedHashMap<>();
Map<String, List<String>> asinsByCountry = new LinkedHashMap<>();
for (String countryCode : countryCodes) {
List<SkipPriceAsinDetailDto> details = detailMap.getOrDefault(countryCode, List.of());
if (details.isEmpty()) {
continue;
}
normalizedDetails.put(countryCode, details);
asinsByCountry.put(countryCode, details.stream()
.map(SkipPriceAsinDetailDto::getAsin)
.filter(asin -> asin != null && !asin.isBlank())
.distinct()
.toList());
}
item.setSkipAsinDetailsByCountry(normalizedDetails);
item.setSkipAsinsByCountry(asinsByCountry);
}
}
private List<String> normalizeCountryCodes(List<String> raw) {
if (raw == null || raw.isEmpty()) {
throw new BusinessException("country_codes 不能为空");
@@ -1234,6 +1272,7 @@ public class ShopMatchTaskService {
if (incoming.getError() != null && !incoming.getError().isBlank()) {
merged.setError(incoming.getError().trim());
}
mergeSkipAsinPayload(merged, incoming);
Map<com.nanri.aiimage.modules.productrisk.model.enums.ProductRiskCountryCode, List<ShopMatchRowDto>> nextCountries =
merged.getCountries() == null ? new LinkedHashMap<>() : new LinkedHashMap<>(merged.getCountries());
if (incoming.getCountries() != null && !incoming.getCountries().isEmpty()) {
@@ -1245,10 +1284,131 @@ public class ShopMatchTaskService {
}
}
merged.setCountries(nextCountries);
appendSkipAsinRows(merged);
shopMatchTaskCacheService.saveShopMergedPayload(taskId, shopKey, merged);
return merged;
}
private void mergeSkipAsinPayload(ShopMatchShopPayloadDto merged, ShopMatchShopPayloadDto incoming) {
Map<ProductRiskCountryCode, List<SkipPriceAsinDetailDto>> nextDetails =
merged.getSkipAsinDetailsByCountry() == null ? new LinkedHashMap<>()
: new LinkedHashMap<>(merged.getSkipAsinDetailsByCountry());
if (incoming.getSkipAsinDetailsByCountry() != null && !incoming.getSkipAsinDetailsByCountry().isEmpty()) {
for (Map.Entry<ProductRiskCountryCode, List<SkipPriceAsinDetailDto>> entry : incoming.getSkipAsinDetailsByCountry().entrySet()) {
if (entry.getKey() == null || entry.getValue() == null || entry.getValue().isEmpty()) {
continue;
}
nextDetails.put(entry.getKey(), mergeSkipAsinDetails(nextDetails.get(entry.getKey()), entry.getValue()));
}
}
Map<ProductRiskCountryCode, List<String>> nextAsins =
merged.getSkipAsinsByCountry() == null ? new LinkedHashMap<>()
: new LinkedHashMap<>(merged.getSkipAsinsByCountry());
if (incoming.getSkipAsinsByCountry() != null && !incoming.getSkipAsinsByCountry().isEmpty()) {
for (Map.Entry<ProductRiskCountryCode, List<String>> entry : incoming.getSkipAsinsByCountry().entrySet()) {
if (entry.getKey() == null || entry.getValue() == null || entry.getValue().isEmpty()) {
continue;
}
nextAsins.put(entry.getKey(), mergeSkipAsins(nextAsins.get(entry.getKey()), entry.getValue()));
}
}
merged.setSkipAsinDetailsByCountry(nextDetails);
merged.setSkipAsinsByCountry(nextAsins);
}
private List<SkipPriceAsinDetailDto> mergeSkipAsinDetails(List<SkipPriceAsinDetailDto> existing,
List<SkipPriceAsinDetailDto> incoming) {
LinkedHashMap<String, SkipPriceAsinDetailDto> byAsin = new LinkedHashMap<>();
if (existing != null) {
for (SkipPriceAsinDetailDto detail : existing) {
putSkipAsinDetail(byAsin, detail);
}
}
if (incoming != null) {
for (SkipPriceAsinDetailDto detail : incoming) {
putSkipAsinDetail(byAsin, detail);
}
}
return new ArrayList<>(byAsin.values());
}
private void putSkipAsinDetail(Map<String, SkipPriceAsinDetailDto> byAsin, SkipPriceAsinDetailDto detail) {
String asin = normalizeAsin(detail == null ? null : detail.getAsin());
if (asin.isBlank()) {
return;
}
SkipPriceAsinDetailDto normalized = new SkipPriceAsinDetailDto();
normalized.setAsin(asin);
normalized.setMinimumPrice(detail.getMinimumPrice() == null ? "" : detail.getMinimumPrice().trim());
byAsin.put(asin, normalized);
}
private List<String> mergeSkipAsins(List<String> existing, List<String> incoming) {
LinkedHashSet<String> asins = new LinkedHashSet<>();
if (existing != null) {
existing.stream().map(this::normalizeAsin).filter(asin -> !asin.isBlank()).forEach(asins::add);
}
if (incoming != null) {
incoming.stream().map(this::normalizeAsin).filter(asin -> !asin.isBlank()).forEach(asins::add);
}
return new ArrayList<>(asins);
}
private void appendSkipAsinRows(ShopMatchShopPayloadDto payload) {
Map<ProductRiskCountryCode, List<ShopMatchRowDto>> countries =
payload.getCountries() == null ? new LinkedHashMap<>() : new LinkedHashMap<>(payload.getCountries());
Map<ProductRiskCountryCode, List<SkipPriceAsinDetailDto>> detailsByCountry =
payload.getSkipAsinDetailsByCountry() == null ? Map.of() : payload.getSkipAsinDetailsByCountry();
Map<ProductRiskCountryCode, List<String>> asinsByCountry =
payload.getSkipAsinsByCountry() == null ? Map.of() : payload.getSkipAsinsByCountry();
LinkedHashSet<ProductRiskCountryCode> countryCodes = new LinkedHashSet<>();
countryCodes.addAll(detailsByCountry.keySet());
countryCodes.addAll(asinsByCountry.keySet());
for (ProductRiskCountryCode countryCode : countryCodes) {
if (countryCode == null) {
continue;
}
LinkedHashMap<String, ShopMatchRowDto> rowsByAsin = new LinkedHashMap<>();
List<ShopMatchRowDto> existingRows = countries.get(countryCode);
if (existingRows != null) {
for (ShopMatchRowDto row : existingRows) {
if (row == null || isEmptyBusinessRow(row)) {
continue;
}
rowsByAsin.put(normalizeAsin(row.getAsin()), cloneRow(row));
}
}
for (SkipPriceAsinDetailDto detail : detailsByCountry.getOrDefault(countryCode, List.of())) {
upsertSkipAsinRow(rowsByAsin, detail == null ? null : detail.getAsin(),
detail == null ? null : detail.getMinimumPrice());
}
for (String asin : asinsByCountry.getOrDefault(countryCode, List.of())) {
upsertSkipAsinRow(rowsByAsin, asin, null);
}
countries.put(countryCode, new ArrayList<>(rowsByAsin.values()));
}
payload.setCountries(countries);
}
private void upsertSkipAsinRow(Map<String, ShopMatchRowDto> rowsByAsin, String asin, String minimumPrice) {
String normalizedAsin = normalizeAsin(asin);
if (normalizedAsin.isBlank()) {
return;
}
ShopMatchRowDto row = rowsByAsin.get(normalizedAsin);
if (row == null) {
row = new ShopMatchRowDto();
row.setAsin(normalizedAsin);
rowsByAsin.put(normalizedAsin, row);
}
if (minimumPrice != null && !minimumPrice.isBlank()) {
row.setMinimumPrice(minimumPrice.trim());
}
row.setStatus("跳过");
}
private List<ShopMatchRowDto> mergeCountryRows(List<ShopMatchRowDto> existingRows, List<ShopMatchRowDto> incomingRows) {
LinkedHashMap<String, ShopMatchRowDto> byKey = new LinkedHashMap<>();
if (existingRows != null) {
@@ -1289,6 +1449,9 @@ public class ShopMatchTaskService {
if (incoming.getAsin() != null && !incoming.getAsin().isBlank()) {
merged.setAsin(incoming.getAsin());
}
if (incoming.getMinimumPrice() != null && !incoming.getMinimumPrice().isBlank()) {
merged.setMinimumPrice(incoming.getMinimumPrice());
}
if (incoming.getStatus() != null && !incoming.getStatus().isBlank()) {
merged.setStatus(incoming.getStatus());
}
@@ -1300,12 +1463,17 @@ public class ShopMatchTaskService {
ShopMatchRowDto out = new ShopMatchRowDto();
if (row != null) {
out.setAsin(row.getAsin());
out.setMinimumPrice(row.getMinimumPrice());
out.setStatus(row.getStatus());
out.setDone(row.getDone());
}
return out;
}
private String normalizeAsin(String asin) {
return asin == null ? "" : asin.trim().toUpperCase(Locale.ROOT);
}
private boolean isShopPayloadCompleted(ShopMatchShopPayloadDto payload) {
return countPayloadRows(payload) > 0 && payloadHasAnyDoneTrue(payload);
}
@@ -1329,9 +1497,12 @@ public class ShopMatchTaskService {
}
private boolean payloadHasAnyDoneTrue(ShopMatchShopPayloadDto payload) {
if (payload == null || payload.getCountries() == null) {
if (payload == null) {
return false;
}
if (payload.getCountries() == null || payload.getCountries().isEmpty()) {
return hasSkipAsinPayload(payload);
}
for (List<ShopMatchRowDto> rows : payload.getCountries().values()) {
if (rows == null) {
continue;
@@ -1345,6 +1516,27 @@ public class ShopMatchTaskService {
return false;
}
private boolean hasSkipAsinPayload(ShopMatchShopPayloadDto payload) {
if (payload == null) {
return false;
}
if (payload.getSkipAsinDetailsByCountry() != null) {
for (List<SkipPriceAsinDetailDto> details : payload.getSkipAsinDetailsByCountry().values()) {
if (details != null && details.stream().anyMatch(detail -> detail != null && !normalizeAsin(detail.getAsin()).isBlank())) {
return true;
}
}
}
if (payload.getSkipAsinsByCountry() != null) {
for (List<String> asins : payload.getSkipAsinsByCountry().values()) {
if (asins != null && asins.stream().anyMatch(asin -> !normalizeAsin(asin).isBlank())) {
return true;
}
}
}
return false;
}
private boolean isEmptyBusinessRow(ShopMatchRowDto row) {
return row == null || row.getAsin() == null || row.getAsin().trim().isEmpty();
}

View File

@@ -3,6 +3,7 @@ package com.nanri.aiimage.modules.similarasin.client;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.config.SimilarAsinProperties;
import com.nanri.aiimage.modules.coze.service.CozeCredentialPoolService;
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -19,22 +20,26 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.atomic.AtomicLong;
@Component
@RequiredArgsConstructor
@Slf4j
public class SimilarAsinCozeClient {
private static final String MODULE_TYPE = "SIMILAR_ASIN";
private static final MediaType APPLICATION_JSON_UTF8 = new MediaType("application", "json", StandardCharsets.UTF_8);
private final SimilarAsinProperties properties;
private final ObjectMapper objectMapper;
private final CozeCredentialPoolService cozeCredentialPoolService;
private final AtomicLong credentialCursor = new AtomicLong();
public List<SimilarAsinResultRowDto> inspect(List<SimilarAsinResultRowDto> rows, String prompt, String apiKey) {
if (rows == null || rows.isEmpty()) {
return List.of();
}
if (properties.getCozeToken() == null || properties.getCozeToken().isBlank()) {
if (!hasConfiguredCredential()) {
log.warn("[similar-asin] coze token not configured, keep raw rows size={}", rows.size());
return rows.stream().map(this::copy).toList();
}
@@ -48,17 +53,31 @@ public class SimilarAsinCozeClient {
}
public CozeSubmitResponse submitWorkflow(List<SimilarAsinResultRowDto> rows, String prompt, String apiKey) throws Exception {
JsonNode submitRoot = objectMapper.readTree(postWorkflow(rows, prompt, apiKey));
return submitWorkflow(rows, prompt, apiKey, nextCredential());
}
public CozeSubmitResponse submitWorkflow(List<SimilarAsinResultRowDto> rows,
String prompt,
String apiKey,
CozeCredentialRef credential) throws Exception {
CozeCredentialRef resolvedCredential = resolveCredential(credential);
JsonNode submitRoot = objectMapper.readTree(postWorkflow(rows, prompt, apiKey, resolvedCredential));
ensureSuccess(submitRoot);
return new CozeSubmitResponse(
extractExecuteId(submitRoot),
extractResultDataText(submitRoot),
writeJson(submitRoot)
writeJson(submitRoot),
resolvedCredential.name()
);
}
public CozePollResponse pollWorkflow(String executeId) throws Exception {
JsonNode pollRoot = objectMapper.readTree(getWorkflowHistory(executeId));
return pollWorkflow(executeId, null);
}
public CozePollResponse pollWorkflow(String executeId, CozeCredentialRef credential) throws Exception {
CozeCredentialRef resolvedCredential = resolveCredential(credential);
JsonNode pollRoot = objectMapper.readTree(getWorkflowHistory(executeId, resolvedCredential));
ensureSuccess(pollRoot);
String status = normalize(resolveWorkflowStatus(pollRoot)).toUpperCase(Locale.ROOT);
String dataText = extractResultDataText(pollRoot);
@@ -66,7 +85,8 @@ public class SimilarAsinCozeClient {
String failureMessage = isFailedWorkflowStatus(status)
? firstNonBlank(resolveFailureMessage(pollRoot), "Coze async workflow failed")
: "";
return new CozePollResponse(executeId, status, dataText, outputText, failureMessage, writeJson(pollRoot));
return new CozePollResponse(executeId, status, dataText, outputText, failureMessage, writeJson(pollRoot),
resolvedCredential.name());
}
public List<SimilarAsinResultRowDto> mergeRowsFromDataText(List<SimilarAsinResultRowDto> rows, String dataText) throws Exception {
@@ -156,15 +176,13 @@ public class SimilarAsinCozeClient {
private InspectAttempt inspectOnce(List<SimilarAsinResultRowDto> rows, String prompt, String apiKey) throws Exception {
String raw = runWorkflowAsyncAndWait(rows, prompt, apiKey);
List<CozeResult> results = parseResults(raw);
if (rows.size() > 1 && !results.isEmpty() && results.stream().noneMatch(this::hasIdentity)) {
throw new PartialCozeResultException(0, rows.size(), results.size());
}
List<SimilarAsinResultRowDto> merged = mergeRows(rows, results);
return new InspectAttempt(raw, merged, resolvedCount(merged), results.size());
}
private String runWorkflowAsyncAndWait(List<SimilarAsinResultRowDto> rows, String prompt, String apiKey) throws Exception {
JsonNode submitRoot = objectMapper.readTree(postWorkflow(rows, prompt, apiKey));
CozeCredentialRef credential = nextCredential();
JsonNode submitRoot = objectMapper.readTree(postWorkflow(rows, prompt, apiKey, credential));
ensureSuccess(submitRoot);
String immediateData = extractResultDataText(submitRoot);
@@ -180,7 +198,7 @@ public class SimilarAsinCozeClient {
long deadline = System.currentTimeMillis() + Math.max(10000, properties.getCozePollTimeoutMillis());
while (System.currentTimeMillis() < deadline) {
ensureNotInterrupted();
JsonNode pollRoot = objectMapper.readTree(getWorkflowHistory(executeId));
JsonNode pollRoot = objectMapper.readTree(getWorkflowHistory(executeId, credential));
ensureSuccess(pollRoot);
String dataText = extractResultDataText(pollRoot);
@@ -205,20 +223,24 @@ public class SimilarAsinCozeClient {
throw new IllegalStateException("Coze async workflow poll timeout");
}
private String postWorkflow(List<SimilarAsinResultRowDto> rows, String prompt, String apiKey) {
private String postWorkflow(List<SimilarAsinResultRowDto> rows,
String prompt,
String apiKey,
CozeCredentialRef credential) {
Map<String, Object> parameters = buildParameters(rows, prompt, apiKey);
Map<String, Object> body = new LinkedHashMap<>();
body.put("workflow_id", properties.getCozeWorkflowId());
body.put("workflow_id", credential.workflowId());
body.put("parameters", parameters);
body.put("is_async", Boolean.TRUE);
log.info("[similar-asin] coze request url={} body={}",
log.info("[similar-asin] coze request credential={} url={} body={}",
credential.name(),
joinUrl(properties.getCozeBaseUrl(), properties.getCozeWorkflowPath()),
writeJson(maskCozeRequestBody(body)));
RestClient.RequestBodySpec request = restClient().post()
.uri(joinUrl(properties.getCozeBaseUrl(), properties.getCozeWorkflowPath()))
.headers(headers -> {
headers.setBearerAuth(stripBearer(properties.getCozeToken()));
headers.setBearerAuth(stripBearer(credential.token()));
headers.setContentType(APPLICATION_JSON_UTF8);
headers.set(HttpHeaders.ACCEPT_CHARSET, StandardCharsets.UTF_8.name());
});
@@ -233,45 +255,113 @@ public class SimilarAsinCozeClient {
});
}
private String getWorkflowHistory(String executeId) {
private String getWorkflowHistory(String executeId, CozeCredentialRef credential) {
String path = properties.getCozeWorkflowHistoryPath()
.replace("{workflow_id}", properties.getCozeWorkflowId())
.replace("{workflow_id}", credential.workflowId())
.replace("{execute_id}", executeId);
return restClient().get()
.uri(joinUrl(properties.getCozeBaseUrl(), path))
.headers(headers -> {
headers.setBearerAuth(stripBearer(properties.getCozeToken()));
headers.setBearerAuth(stripBearer(credential.token()));
headers.setContentType(APPLICATION_JSON_UTF8);
headers.set(HttpHeaders.ACCEPT_CHARSET, StandardCharsets.UTF_8.name());
})
.exchange((clientRequest, clientResponse) -> {
byte[] responseBytes = StreamUtils.copyToByteArray(clientResponse.getBody());
String responseText = responseBytes.length == 0 ? "" : new String(responseBytes, StandardCharsets.UTF_8);
log.info("[similar-asin] coze history response executeId={} status={} body={}",
executeId,
log.info("[similar-asin] coze history response credential={} executeId={} status={} body={}",
credential.name(), executeId,
clientResponse.getStatusCode(),
responseText);
return responseText;
});
}
public CozeCredentialRef nextCredential() {
List<CozeCredentialPoolService.CozeCredential> pooledCredentials = cozeCredentialPoolService.listEnabled(MODULE_TYPE);
CozeCredentialPoolService.CozeCredential pooledCredential =
cozeCredentialPoolService.chooseRoundRobin(MODULE_TYPE, pooledCredentials, properties.getCozeCredentialStripeSize());
if (pooledCredential != null) {
return new CozeCredentialRef(pooledCredential.name(), pooledCredential.workflowId(), pooledCredential.token(),
pooledCredential.maxConcurrent());
}
List<CozeCredentialRef> credentials = configuredCredentials();
int configuredStripe = properties.getCozeCredentialStripeSize();
int stripeSize = configuredStripe <= 0 ? Math.max(1, credentials.size()) : configuredStripe;
long cursor = Math.max(0L, credentialCursor.getAndIncrement());
int index = (int) ((cursor / stripeSize) % credentials.size());
return credentials.get(index);
}
public CozeCredentialRef credentialByName(String name) {
if (name == null || name.isBlank()) {
return nextCredential();
}
String normalizedName = normalize(name);
for (CozeCredentialRef credential : configuredCredentials()) {
if (normalize(credential.name()).equals(normalizedName)) {
return credential;
}
}
return nextCredential();
}
public boolean hasConfiguredCredential() {
return !configuredCredentials().isEmpty();
}
public int configuredCredentialCount() {
return configuredCredentials().size();
}
private CozeCredentialRef resolveCredential(CozeCredentialRef credential) {
return credential == null ? nextCredential() : credential;
}
private List<CozeCredentialRef> configuredCredentials() {
List<CozeCredentialRef> credentials = new ArrayList<>();
for (CozeCredentialPoolService.CozeCredential credential : cozeCredentialPoolService.listEnabled(MODULE_TYPE)) {
credentials.add(new CozeCredentialRef(credential.name(), credential.workflowId(), credential.token(),
credential.maxConcurrent()));
}
if (!credentials.isEmpty()) {
return credentials;
}
if (properties.getCozeCredentials() != null) {
int index = 1;
for (SimilarAsinProperties.CozeCredential credential : properties.getCozeCredentials()) {
if (credential == null
|| normalize(credential.getWorkflowId()).isBlank()
|| normalize(credential.getToken()).isBlank()) {
continue;
}
String name = firstNonBlank(credential.getName(), "credential-" + index);
credentials.add(new CozeCredentialRef(name, credential.getWorkflowId(), credential.getToken(), Integer.MAX_VALUE));
index++;
}
}
if (credentials.isEmpty()
&& properties.getCozeWorkflowId() != null && !properties.getCozeWorkflowId().isBlank()
&& properties.getCozeToken() != null && !properties.getCozeToken().isBlank()) {
credentials.add(new CozeCredentialRef("default", properties.getCozeWorkflowId(), properties.getCozeToken(), Integer.MAX_VALUE));
}
return credentials;
}
private Map<String, Object> buildParameters(List<SimilarAsinResultRowDto> rows, String prompt, String apiKey) {
List<String> groupKeys = rows.stream().map(row -> nonBlank(row.getGroupKey(), rowKey(row))).toList();
List<String> rowIds = rows.stream().map(row -> nonBlank(row.getId(), "")).toList();
List<String> asins = rows.stream().map(row -> nonBlank(row.getAsin(), "")).toList();
List<String> countries = rows.stream().map(row -> nonBlank(row.getCountry(), "")).toList();
List<String> prices = rows.stream().map(row -> nonBlank(row.getPrice(), "")).toList();
List<String> titles = rows.stream().map(row -> nonBlank(row.getTitle(), row.getAsin())).toList();
List<String> urls = rows.stream().map(row -> nonBlank(row.getUrl(), "")).toList();
List<List<String>> urlLists = rows.stream().map(SimilarAsinResultRowDto::getUrls).toList();
List<String> asins = rows.stream().map(row -> safeText(row.getAsin())).toList();
List<String> titles = rows.stream().map(row -> safeText(firstNonBlank(row.getTitle(), row.getAsin()))).toList();
List<String> skus = rows.stream().map(row -> safeText(row.getSku())).toList();
List<String> urls = rows.stream().map(this::primaryImageUrl).toList();
List<List<String>> urlLists = rows.stream().map(this::imageUrls).toList();
Map<String, Object> parameters = new LinkedHashMap<>();
parameters.put("title_list", titles);
parameters.put("url_list", urls);
parameters.put("url_lists", urlLists);
parameters.put("items", buildItemObjects(rows, groupKeys, rowIds, asins, countries, prices, titles, urls, urlLists));
parameters.put("items", buildItemObjects(asins, titles, skus, urls, urlLists));
parameters.put("prompt", prompt == null ? "" : prompt);
if (apiKey != null && !apiKey.isBlank()) {
// legacy flag当线上 coze 工作流回退到老契约时,把环境变量
// AIIMAGE_SIMILAR_ASIN_COZE_INCLUDE_LEGACY_API_KEY=true 即可重新塞 api_key。
boolean includeLegacyApiKey = properties.isCozeIncludeLegacyApiKey();
if (includeLegacyApiKey && apiKey != null && !apiKey.isBlank()) {
parameters.put("api_key", apiKey.trim());
}
return parameters;
@@ -280,6 +370,10 @@ public class SimilarAsinCozeClient {
@SuppressWarnings("unchecked")
private Map<String, Object> maskCozeRequestBody(Map<String, Object> body) {
Map<String, Object> masked = new LinkedHashMap<>(body);
Object topLevelApiKey = masked.get("api_key");
if (topLevelApiKey instanceof String apiKeyText && !apiKeyText.isBlank()) {
masked.put("api_key", maskSecret(apiKeyText));
}
Object parametersObj = masked.get("parameters");
if (parametersObj instanceof Map<?, ?> parameters) {
Map<String, Object> maskedParameters = new LinkedHashMap<>((Map<String, Object>) parameters);
@@ -303,27 +397,32 @@ public class SimilarAsinCozeClient {
return normalized.substring(0, 6) + "***" + normalized.substring(normalized.length() - 4);
}
private List<Map<String, Object>> buildItemObjects(List<SimilarAsinResultRowDto> rows,
List<String> groupKeys,
List<String> rowIds,
List<String> asins,
List<String> countries,
List<String> prices,
private List<Map<String, Object>> buildItemObjects(List<String> asins,
List<String> titles,
List<String> skus,
List<String> urls,
List<List<String>> urlLists) {
List<Map<String, Object>> items = new ArrayList<>(rows.size());
for (int i = 0; i < rows.size(); i++) {
// legacy flag保留切回旧字段顺序 {asin, sku, url, target_urls, title} 的开关,
// 默认 false 使用当前顺序 {asin, url, target_urls, title, sku}。
boolean useLegacyOrder = properties.isCozeUseLegacyItemFieldOrder();
List<Map<String, Object>> items = new ArrayList<>(asins.size());
for (int i = 0; i < asins.size(); i++) {
String url = urls.get(i);
List<String> imageUrls = urlLists.get(i);
Map<String, Object> item = new LinkedHashMap<>();
item.put("group_key", groupKeys.get(i));
item.put("row_id", rowIds.get(i));
item.put("asin", asins.get(i));
item.put("country", countries.get(i));
item.put("price", prices.get(i));
item.put("title", titles.get(i));
item.put("url", urls.get(i));
item.put("urls", urlLists.get(i));
item.put("target_urls", urlLists.get(i));
if (useLegacyOrder) {
item.put("asin", asins.get(i));
item.put("sku", skus.get(i));
item.put("url", url);
item.put("target_urls", imageUrls);
item.put("title", titles.get(i));
} else {
item.put("asin", asins.get(i));
item.put("url", url);
item.put("target_urls", imageUrls);
item.put("title", titles.get(i));
item.put("sku", skus.get(i));
}
items.add(item);
}
return items;
@@ -346,6 +445,9 @@ public class SimilarAsinCozeClient {
text(firstNonNull(
firstNonNull(node.get("group_key"), node.get("groupKey")),
firstNonNull(itemNode.get("group_key"), itemNode.get("groupKey")))),
text(firstNonNull(
firstNonNull(node.get("row_token"), node.get("rowToken")),
firstNonNull(itemNode.get("row_token"), itemNode.get("rowToken")))),
text(firstNonNull(
firstNonNull(node.get("row_id"), firstNonNull(node.get("rowId"), node.get("id"))),
firstNonNull(itemNode.get("row_id"), firstNonNull(itemNode.get("rowId"), itemNode.get("id"))))),
@@ -353,6 +455,9 @@ public class SimilarAsinCozeClient {
text(firstNonNull(
firstNonNull(node.get("country"), node.get("site")),
firstNonNull(itemNode.get("country"), itemNode.get("site")))),
text(firstNonNull(
firstNonNull(node.get("sku"), firstNonNull(node.get("SKU"), firstNonNull(node.get("seller_sku"), node.get("sellerSku")))),
firstNonNull(itemNode.get("sku"), firstNonNull(itemNode.get("SKU"), firstNonNull(itemNode.get("seller_sku"), itemNode.get("sellerSku")))))),
text(firstNonNull(
firstNonNull(node.get("price"), node.get("价格")),
firstNonNull(itemNode.get("price"), itemNode.get("价格")))),
@@ -363,6 +468,15 @@ public class SimilarAsinCozeClient {
text(firstNonNull(
firstNonNull(node.get("similarity"), firstNonNull(node.get("similarity_rate"), node.get("相似度"))),
firstNonNull(itemNode.get("similarity"), firstNonNull(itemNode.get("similarity_rate"), itemNode.get("相似度"))))),
text(firstNonNull(
firstNonNull(node.get("is_conform"), firstNonNull(node.get("isConform"), firstNonNull(node.get("conform"), node.get("是否符合类目")))),
firstNonNull(itemNode.get("is_conform"), firstNonNull(itemNode.get("isConform"), firstNonNull(itemNode.get("conform"), itemNode.get("是否符合类目")))))),
text(firstNonNull(
firstNonNull(node.get("reason"), firstNonNull(node.get("原因"), node.get("不符合理由"))),
firstNonNull(itemNode.get("reason"), firstNonNull(itemNode.get("原因"), itemNode.get("不符合理由"))))),
text(firstNonNull(
firstNonNull(node.get("category"), firstNonNull(node.get("类目"), node.get("产品类目"))),
firstNonNull(itemNode.get("category"), firstNonNull(itemNode.get("类目"), itemNode.get("产品类目"))))),
text(firstNonNull(node.get("appearance"),
firstNonNull(node.get("appearance_risk"),
firstNonNull(itemNode.get("appearance"), itemNode.get("appearance_risk"))))),
@@ -372,6 +486,9 @@ public class SimilarAsinCozeClient {
text(firstNonNull(node.get("result"),
firstNonNull(node.get("conclusion"),
firstNonNull(itemNode.get("result"), itemNode.get("conclusion"))))),
text(firstNonNull(
firstNonNull(node.get("status"), firstNonNull(node.get("row_status"), node.get("rowStatus"))),
firstNonNull(itemNode.get("status"), firstNonNull(itemNode.get("row_status"), itemNode.get("rowStatus"))))),
text(firstNonNull(node.get("title_reason"),
firstNonNull(node.get("titleReason"),
firstNonNull(itemNode.get("title_reason"), itemNode.get("titleReason"))))),
@@ -381,7 +498,16 @@ public class SimilarAsinCozeClient {
text(firstNonNull(node.get("patent_reason"),
firstNonNull(firstNonNull(node.get("patentReason"), node.get("patent reason")),
firstNonNull(itemNode.get("patent_reason"),
firstNonNull(itemNode.get("patentReason"), itemNode.get("patent reason"))))))
firstNonNull(itemNode.get("patentReason"), itemNode.get("patent reason")))))),
text(firstNonNull(
firstNonNull(node.get("main_url"), firstNonNull(node.get("mainUrl"), firstNonNull(node.get("main_image_url"), firstNonNull(node.get("mainImageUrl"), firstNonNull(node.get("main_img"), node.get("mainImg")))))),
firstNonNull(itemNode.get("main_url"), firstNonNull(itemNode.get("mainUrl"), firstNonNull(itemNode.get("main_image_url"), firstNonNull(itemNode.get("mainImageUrl"), firstNonNull(itemNode.get("main_img"), itemNode.get("mainImg")))))))),
text(firstNonNull(
firstNonNull(node.get("puzzle_img1"), firstNonNull(node.get("puzzleImg1"), firstNonNull(node.get("puzzle_img_1"), node.get("puzzleImg_1")))),
firstNonNull(itemNode.get("puzzle_img1"), firstNonNull(itemNode.get("puzzleImg1"), firstNonNull(itemNode.get("puzzle_img_1"), itemNode.get("puzzleImg_1")))))),
text(firstNonNull(
firstNonNull(node.get("puzzle_img2"), firstNonNull(node.get("puzzleImg2"), firstNonNull(node.get("puzzle_img_2"), node.get("puzzleImg_2")))),
firstNonNull(itemNode.get("puzzle_img2"), firstNonNull(itemNode.get("puzzleImg2"), firstNonNull(itemNode.get("puzzle_img_2"), itemNode.get("puzzleImg_2"))))))
));
}
}
@@ -403,15 +529,14 @@ public class SimilarAsinCozeClient {
}
private List<SimilarAsinResultRowDto> mergeRows(List<SimilarAsinResultRowDto> rows, List<CozeResult> results) {
Map<String, CozeResult> resultByGroupKey = new LinkedHashMap<>();
Map<String, CozeResult> resultByRowToken = new LinkedHashMap<>();
Map<String, CozeResult> resultByCompositeKey = new LinkedHashMap<>();
Map<String, CozeResult> resultByAsinCountry = new LinkedHashMap<>();
Map<String, CozeResult> resultByAsin = new LinkedHashMap<>();
Map<String, CozeResult> resultByRowId = new LinkedHashMap<>();
for (CozeResult result : results) {
String groupKey = normalize(result.groupKey());
if (!groupKey.isBlank()) {
resultByGroupKey.putIfAbsent(groupKey, result);
String rowToken = normalize(result.rowToken());
if (!rowToken.isBlank()) {
resultByRowToken.putIfAbsent(rowToken, result);
}
String compositeKey = rowKey(result.rowId(), result.asin(), result.country());
if (!compositeKey.isBlank()) {
@@ -421,10 +546,6 @@ public class SimilarAsinCozeClient {
if (!asinCountryKey.isBlank()) {
resultByAsinCountry.putIfAbsent(asinCountryKey, result);
}
String asinKey = normalize(result.asin()).toUpperCase(Locale.ROOT);
if (!asinKey.isBlank()) {
resultByAsin.putIfAbsent(asinKey, result);
}
String rowIdKey = normalize(result.rowId());
if (!rowIdKey.isBlank()) {
resultByRowId.putIfAbsent(rowIdKey, result);
@@ -435,7 +556,7 @@ public class SimilarAsinCozeClient {
boolean allowIndexFallback = results.size() == rows.size() && results.stream().noneMatch(this::hasIdentity);
for (int i = 0; i < rows.size(); i++) {
SimilarAsinResultRowDto row = copy(rows.get(i));
CozeResult result = resultByGroupKey.get(normalize(row.getGroupKey()));
CozeResult result = resultByRowToken.get(normalize(row.getRowToken()));
if (result == null) {
result = resultByCompositeKey.get(rowKey(row.getId(), row.getAsin(), row.getCountry()));
}
@@ -445,9 +566,6 @@ public class SimilarAsinCozeClient {
if (result == null) {
result = resultByAsinCountry.get(asinCountryKey(row.getAsin(), row.getCountry()));
}
if (result == null) {
result = resultByAsin.get(normalize(row.getAsin()).toUpperCase(Locale.ROOT));
}
if (result == null) {
result = resultByRowId.get(normalize(row.getId()));
}
@@ -472,7 +590,8 @@ public class SimilarAsinCozeClient {
if (result == null) {
return false;
}
return !normalize(result.groupKey()).isBlank()
return !normalize(result.rowToken()).isBlank()
|| !normalize(result.groupKey()).isBlank()
|| !normalize(result.rowId()).isBlank()
|| !normalize(result.asin()).isBlank();
}
@@ -481,7 +600,7 @@ public class SimilarAsinCozeClient {
if (result == null || row == null) {
return false;
}
if (!normalize(result.groupKey()).isBlank() || !normalize(result.rowId()).isBlank()) {
if (!normalize(result.rowToken()).isBlank() || !normalize(result.rowId()).isBlank()) {
return false;
}
String resultAsin = normalize(result.asin()).toUpperCase(Locale.ROOT);
@@ -493,15 +612,65 @@ public class SimilarAsinCozeClient {
return;
}
row.setTitleRisk(result.title());
row.setSku(nonBlank(result.sku(), row.getSku()));
row.setPrice(nonBlank(result.price(), row.getPrice()));
row.setIsStock(result.isStock());
row.setSimilarity(result.similarity());
row.setIsConform(result.isConform());
row.setReason(result.reason());
row.setCategory(result.category());
row.setAppearanceRisk(result.appearance());
row.setPatentRisk(result.patent());
row.setConclusion(result.result());
row.setStatus(result.status());
row.setTitleReason(result.titleReason());
row.setAppearanceReason(result.appearanceReason());
row.setPatentReason(result.patentReason());
row.setMainUrl(result.mainUrl());
row.setPuzzleImg1(result.puzzleImg1());
row.setPuzzleImg2(result.puzzleImg2());
normalizeRequiredBusinessFields(row);
}
private void normalizeRequiredBusinessFields(SimilarAsinResultRowDto row) {
if (row == null || !hasBusinessCozeResult(row)) {
return;
}
boolean filledStock = false;
boolean filledSimilarity = false;
if (normalize(row.getIsStock()).isBlank()) {
row.setIsStock("\u672a\u77e5");
filledStock = true;
}
if (normalize(row.getSimilarity()).isBlank()) {
row.setSimilarity("0%");
filledSimilarity = true;
}
// \u5728 error \u5b57\u6bb5\u8ffd\u52a0 marker\uff0c\u5bfc\u51fa/BI \u4fa7\u53ef\u636e\u6b64\u533a\u5206"\u4e1a\u52a1\u771f\u5b9e\u7a7a"\u4e0e"\u4ee3\u7801\u515c\u5e95\u586b\u7684"\u3002
// \u4ec5\u5728\u539f\u503c\u4e3a\u7a7a\u65f6\u586b\u5165\u515c\u5e95\uff0c\u6240\u4ee5\u4e24\u4e2a marker \u5404\u81ea\u72ec\u7acb\u5224\u5b9a\uff0c\u4e0d\u4f1a\u91cd\u590d\u8ffd\u52a0\u3002
if (filledStock || filledSimilarity) {
StringBuilder marker = new StringBuilder();
if (filledStock) {
marker.append("|coze-default-stock");
}
if (filledSimilarity) {
marker.append("|coze-default-similarity");
}
String existingError = row.getError();
String markerText = marker.toString();
if (existingError == null || existingError.isBlank()) {
row.setError(markerText.startsWith("|") ? markerText.substring(1) : markerText);
} else if (!existingError.contains(markerText.trim())) {
row.setError(existingError + markerText);
}
}
}
private boolean hasBusinessCozeResult(SimilarAsinResultRowDto row) {
return !normalize(row.getIsConform()).isBlank()
|| !normalize(row.getReason()).isBlank()
|| !normalize(row.getCategory()).isBlank()
|| !normalize(row.getStatus()).isBlank();
}
private RestClient restClient() {
@@ -520,11 +689,16 @@ public class SimilarAsinCozeClient {
row.setId(source.getId());
row.setAsin(source.getAsin());
row.setCountry(source.getCountry());
row.setSku(source.getSku());
row.setPrice(source.getPrice());
row.setUrls(source.getUrls());
row.setTitle(source.getTitle());
row.setError(source.getError());
row.setDone(source.getDone());
row.setStatus(source.getStatus());
row.setIsConform(source.getIsConform());
row.setReason(source.getReason());
row.setCategory(source.getCategory());
row.setTitleRisk(source.getTitleRisk());
row.setAppearanceRisk(source.getAppearanceRisk());
row.setPatentRisk(source.getPatentRisk());
@@ -534,6 +708,9 @@ public class SimilarAsinCozeClient {
row.setTitleReason(source.getTitleReason());
row.setAppearanceReason(source.getAppearanceReason());
row.setPatentReason(source.getPatentReason());
row.setMainUrl(source.getMainUrl());
row.setPuzzleImg1(source.getPuzzleImg1());
row.setPuzzleImg2(source.getPuzzleImg2());
return row;
}
@@ -544,6 +721,12 @@ public class SimilarAsinCozeClient {
if (row.getError() == null || row.getError().isBlank()) {
row.setError(failureMessage);
}
if (row.getReason() == null || row.getReason().isBlank()) {
row.setReason(failureMessage);
}
if (row.getStatus() == null || row.getStatus().isBlank()) {
row.setStatus("FAILED");
}
if (row.getTitleRisk() == null || row.getTitleRisk().isBlank()) {
row.setTitleRisk(reviewMessage);
}
@@ -572,6 +755,11 @@ public class SimilarAsinCozeClient {
|| message.contains("Read timed out")
|| message.contains("Connection reset")
|| message.contains("I/O error on POST request")
|| message.contains("429")
|| message.toLowerCase(Locale.ROOT).contains("rate limit")
|| message.toLowerCase(Locale.ROOT).contains("retry later")
|| message.contains("\u9650\u6d41")
|| message.contains("\u7a0d\u540e\u91cd\u8bd5")
|| message.toLowerCase(Locale.ROOT).contains("timeout");
}
@@ -728,6 +916,17 @@ public class SimilarAsinCozeClient {
|| item.has("similarity")
|| item.has("similarity_rate")
|| item.has("相似度")
|| item.has("is_conform")
|| item.has("isConform")
|| item.has("conform")
|| item.has("是否符合类目")
|| item.has("reason")
|| item.has("原因")
|| item.has("不符合理由")
|| item.has("category")
|| item.has("类目")
|| item.has("产品类目")
|| item.has("status")
|| item.has("title_reason")
|| item.has("titleReason")
|| item.has("appearance_reason")
@@ -779,7 +978,9 @@ public class SimilarAsinCozeClient {
private String resolveFailureMessage(JsonNode root) {
JsonNode dataNode = root.path("data");
String message = text(firstNonNull(dataNode.get("error_message"), firstNonNull(dataNode.get("msg"), root.get("msg"))));
String message = firstNonBlank(
findTextByFieldName(dataNode, "error_message", "error", "msg"),
findTextByFieldName(root, "error_message", "error", "msg"));
return message == null ? "" : message;
}
@@ -927,6 +1128,65 @@ public class SimilarAsinCozeClient {
return preferred == null || preferred.isBlank() ? fallback : preferred.trim();
}
private String safeText(String value) {
return value == null ? "" : value.trim();
}
private List<String> safeUrls(List<String> urls) {
if (urls == null || urls.isEmpty()) {
return List.of();
}
return urls.stream()
.map(this::safeText)
.filter(value -> !value.isBlank())
.toList();
}
private void putIfPresent(Map<String, Object> item, String key, String value) {
String normalized = safeText(value);
if (!normalized.isBlank()) {
item.put(key, normalized);
}
}
private void putIfPresent(Map<String, Object> item, String key, List<String> values) {
if (values != null && !values.isEmpty()) {
item.put(key, values);
}
}
private String primaryImageUrl(SimilarAsinResultRowDto row) {
if (row == null) {
return "";
}
String url = safeText(row.getUrl());
if (!url.isBlank()) {
return url;
}
List<String> urls = safeUrls(row.getUrls());
return urls.isEmpty() ? "" : urls.get(0);
}
private List<String> imageUrls(SimilarAsinResultRowDto row) {
if (row == null) {
return List.of();
}
List<String> urls = safeUrls(row.getUrls());
if (!urls.isEmpty()) {
return urls;
}
String primaryUrl = primaryImageUrl(row);
return primaryUrl.isBlank() ? List.of() : List.of(primaryUrl);
}
private String cozeGroupKey(SimilarAsinResultRowDto row) {
if (row == null) {
return "";
}
String groupKey = safeText(row.getGroupKey());
return groupKey.isBlank() ? rowKey(row) : groupKey;
}
private String normalize(String value) {
return value == null ? "" : value.replace(String.valueOf((char) 0xFEFF), "").replace((char) 0x3000, ' ').trim();
}
@@ -978,19 +1238,28 @@ public class SimilarAsinCozeClient {
private record CozeResult(
String groupKey,
String rowToken,
String rowId,
String asin,
String country,
String sku,
String price,
String title,
String isStock,
String similarity,
String isConform,
String reason,
String category,
String appearance,
String patent,
String result,
String status,
String titleReason,
String appearanceReason,
String patentReason
String patentReason,
String mainUrl,
String puzzleImg1,
String puzzleImg2
) {
}
@@ -1005,7 +1274,8 @@ public class SimilarAsinCozeClient {
public record CozeSubmitResponse(
String executeId,
String immediateData,
String rawResponse
String rawResponse,
String credentialName
) {
}
@@ -1015,7 +1285,8 @@ public class SimilarAsinCozeClient {
String dataText,
String outputText,
String failureMessage,
String rawResponse
String rawResponse,
String credentialName
) {
public boolean hasPayload() {
return dataText != null && !dataText.isBlank() || outputText != null && !outputText.isBlank();
@@ -1027,7 +1298,7 @@ public class SimilarAsinCozeClient {
public boolean isFailed() {
String normalized = status == null ? "" : status.trim().toUpperCase(Locale.ROOT);
return normalized.contains("FAILED") || normalized.contains("ERROR") || normalized.contains("CANCEL");
return normalized.contains("FAIL") || normalized.contains("ERROR") || normalized.contains("CANCEL");
}
public boolean isFinished() {
@@ -1041,6 +1312,14 @@ public class SimilarAsinCozeClient {
}
}
public record CozeCredentialRef(
String name,
String workflowId,
String token,
int maxConcurrent
) {
}
private static final class PartialCozeResultException extends RuntimeException {
private final int resolvedCount;

View File

@@ -37,7 +37,10 @@ import java.util.List;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/similar-asin")
@Tag(name = "相似ASIN检测", description = "相似ASIN检测任务接口。前端上传 Excel 后由 Java 解析并创建任务Python 回传商品数据Java 负责攒批调用 Coze、补齐子行、生成最终 xlsx 并上传 OSS。")
@Tag(
name = "相似 ASIN 检测",
description = "前端上传 Excel 后由 Java 解析并创建任务Python 按分组抓取商品数据并回传Java 再按批调用 Coze 并生成最终 xlsx。"
)
public class SimilarAsinController {
private final SimilarAsinTaskService service;
@@ -69,13 +72,19 @@ public class SimilarAsinController {
}
@PostMapping("/parse")
@Operation(summary = "解析 Excel 并创建任务", description = "解析上传后的 Excel 文件,提取 id、ASIN、国家、URL、标题等字段。返回给前端的数据只包含整数 id 和 n_1 行n_2、n_3 等子行会保存在 OSS 解析载荷中,用于最终结果补齐。创建后的任务状态为 PENDING不会自动推送 Python。")
@Operation(
summary = "解析 Excel 并创建任务",
description = "解析上传后的 Excel 文件,提取 id、ASIN、国家、URL、标题、SKU 等字段。返回给前端的数据同时包含全量有效行 items以及按主数据块分组后的 groups便于 Python 按组抓取后再回传。创建后的任务状态为 PENDING不会自动推送 Python。"
)
public ApiResponse<SimilarAsinParseVo> parse(@Valid @RequestBody SimilarAsinParseRequest request) {
return ApiResponse.success(service.parseAndCreateTask(request));
}
@GetMapping("/tasks/{taskId}/parsed-payload")
@Operation(summary = "获取货源查询完整解析载荷", description = "给 Python 队列消费端使用。parse 接口只返回轻量预览,大文件完整行数据通过该接口按 taskId 拉取。")
@Operation(
summary = "获取货源查询完整解析载荷",
description = "给 Python 队列消费端使用。返回全量有效行 items/allItems 以及分组后的 groups便于 Python 按组抓取后回传。"
)
public ApiResponse<SimilarAsinParsedPayloadDto> parsedPayload(
@Parameter(description = "货源查询任务 ID", required = true, example = "7004")
@PathVariable Long taskId,
@@ -85,17 +94,17 @@ public class SimilarAsinController {
}
@GetMapping("/dashboard")
@Operation(summary = "查询相似ASIN检测总览", description = "查询当前用户的运行中、成功、失败和已结束任务数量。页面进入时请求一次即可,不需要持续轮询。")
@Operation(summary = "查询相似 ASIN 检测总览", description = "查询当前用户的运行中、成功、失败和已结束任务数量。")
public ApiResponse<SimilarAsinDashboardVo> dashboard(
@Parameter(description = "当前用户 ID,用于隔离不同用户的任务和历史记录。", required = true, example = "1")
@Parameter(description = "当前用户 ID", required = true, example = "1")
@RequestParam("user_id") Long userId) {
return ApiResponse.success(service.dashboard(userId));
}
@GetMapping("/history")
@Operation(summary = "查询相似ASIN检测历史", description = "查询当前用户最近的相似ASIN检测历史记录,包含源文件名、任务状态、行数、错误信息和最终 xlsx 下载地址")
@Operation(summary = "查询相似 ASIN 检测历史", description = "查询当前用户最近的相似 ASIN 检测历史记录。")
public ApiResponse<SimilarAsinHistoryVo> history(
@Parameter(description = "当前用户 ID,用于查询该用户自己的历史记录。", required = true, example = "1")
@Parameter(description = "当前用户 ID", required = true, example = "1")
@RequestParam("user_id") Long userId,
@Parameter(description = "history limit, default 50, max 100", example = "50")
@RequestParam(value = "limit", required = false, defaultValue = "50") Integer limit) {
@@ -103,26 +112,29 @@ public class SimilarAsinController {
}
@PostMapping("/tasks/progress/batch")
@Operation(summary = "批量查询任务进度", description = "前端对活跃任务调用该接口,建议 6 秒一次。接口只返回轻量任务状态,不返回明细结果")
@Operation(summary = "批量查询任务进度", description = "前端对活跃任务轮询该接口,返回轻量任务状态")
public ApiResponse<SimilarAsinTaskBatchVo> progress(@Valid @RequestBody SimilarAsinTaskBatchRequest request) {
return ApiResponse.success(service.progressBatch(request.getTaskIds()));
}
@PostMapping("/tasks/{taskId}/activate")
@Operation(summary = "激活任务", description = "前端手动推送 Python 队列成功后调用,将任务从 PENDING 改为 RUNNING,并记录后端内部活跃时间。后续活跃时间由 Python 回传结果接口自动刷新,不需要单独心跳接口")
@Operation(summary = "激活任务", description = "前端手动推送 Python 队列成功后调用,将任务从 PENDING 改为 RUNNING。")
public ApiResponse<Void> activate(
@Parameter(description = "相似ASIN检测任务 ID,即解析接口返回的 taskId。", required = true, example = "3938")
@Parameter(description = "相似 ASIN 检测任务 ID", required = true, example = "3938")
@PathVariable Long taskId,
@Parameter(description = "当前用户 ID,必须与创建任务的用户一致。", required = true, example = "1")
@Parameter(description = "当前用户 ID", required = true, example = "1")
@RequestParam("user_id") Long userId) {
service.activateTask(taskId, userId);
return ApiResponse.success(null);
}
@PostMapping("/tasks/{taskId}/result")
@Operation(summary = "提交 Python 回传结果", description = "Python 回传商品数据接口。items 可以是一条或多条Java 先原样保存回传数据,再内部攒够 10 条调用 Coze。done=true 表示 Python 已完成全部回传Java 会强制处理剩余不足 10 条的数据并生成最终 xlsx。")
@Operation(
summary = "提交 Python 回传结果",
description = "Python 请通过 groups[].items[] 回传分组抓取结果Java 先原样保存回传数据,再内部攒批调用 Coze。done=true 表示 Python 已完成全部回传Java 会强制处理剩余未满批的数据并生成最终 xlsx。"
)
public ApiResponse<Void> result(
@Parameter(description = "相似ASIN检测任务 ID任务必须处于 RUNNING 状态", required = true, example = "3938")
@Parameter(description = "相似 ASIN 检测任务 ID任务必须处于 RUNNING 状态", required = true, example = "3938")
@PathVariable Long taskId,
@Valid @RequestBody SimilarAsinSubmitResultRequest request,
jakarta.servlet.http.HttpServletResponse response) {
@@ -133,29 +145,29 @@ public class SimilarAsinController {
}
@DeleteMapping("/tasks/{taskId}")
@Operation(summary = "删除任务", description = "删除当前用户的一条相似ASIN检测任务同时清理任务结果、scope 状态和分片记录。")
@Operation(summary = "删除任务", description = "删除当前用户的一条相似 ASIN 检测任务同时清理任务结果、scope 状态和分片记录。")
public ApiResponse<Void> deleteTask(
@Parameter(description = "相似ASIN检测任务 ID", required = true, example = "3938")
@Parameter(description = "相似 ASIN 检测任务 ID", required = true, example = "3938")
@PathVariable Long taskId,
@Parameter(description = "当前用户 ID,必须与创建任务的用户一致。", required = true, example = "1")
@Parameter(description = "当前用户 ID", required = true, example = "1")
@RequestParam("user_id") Long userId) {
service.deleteTask(taskId, userId);
return ApiResponse.success(null);
}
@DeleteMapping("/history/{resultId}")
@Operation(summary = "删除历史记录", description = "删除当前用户的一条相似ASIN检测历史记录。只删除 biz_file_result 记录,不主动删除任务主记录。")
@Operation(summary = "删除历史记录", description = "删除当前用户的一条相似 ASIN 检测历史记录。")
public ApiResponse<Void> deleteHistory(
@Parameter(description = "历史结果记录 ID,即 history 接口返回的 resultId。", required = true, example = "1001")
@Parameter(description = "历史结果记录 ID", required = true, example = "1001")
@PathVariable Long resultId,
@Parameter(description = "当前用户 ID", required = true, example = "1")
@Parameter(description = "当前用户 ID", required = true, example = "1")
@RequestParam("user_id") Long userId) {
service.deleteHistory(resultId, userId);
return ApiResponse.success(null);
}
@GetMapping("/results/{resultId}/download")
@Operation(summary = "下载相似ASIN检测结果文件")
@Operation(summary = "下载相似 ASIN 检测结果文件")
public void downloadResult(
@Parameter(description = "结果记录 ID", required = true, example = "1001")
@PathVariable Long resultId,

View File

@@ -9,7 +9,7 @@ import java.util.ArrayList;
import java.util.List;
@Data
@Schema(description = "相似ASIN检测解析载荷")
@Schema(description = "相似 ASIN 解析载荷")
public class SimilarAsinParsedPayloadDto {
@Schema(description = "AI 提示词")
private String aiPrompt;
@@ -23,12 +23,12 @@ public class SimilarAsinParsedPayloadDto {
@Schema(description = "Excel 原始表头列表")
private List<String> headers = new ArrayList<>();
@Schema(description = "兼容旧链路的平铺有效行,现为全部有效行")
@Schema(description = "平铺的全量有效行")
private List<SimilarAsinParsedRowVo> items = new ArrayList<>();
@Schema(description = "相邻主 ID 块分组后的完整数据")
@Schema(description = "主数据块分组后的完整数据,每个 group.items 都是需要 Python 抓取的全量子行")
private List<SimilarAsinParsedGroupVo> groups = new ArrayList<>();
@Schema(description = "完整有效行")
@Schema(description = "全量有效行,用于兼容旧链路与结果文件组装")
private List<SimilarAsinParsedRowVo> allItems = new ArrayList<>();
}

View File

@@ -39,6 +39,10 @@ public class SimilarAsinResultRowDto {
@Schema(description = "站点或国家。来自 Excel 的国家列,例如英国、德国、法国。", example = "英国")
private String country;
@JsonAlias({"sku", "SKU", "seller_sku", "sellerSku", "msku", "货号"})
@Schema(description = "商品 SKU。来自 Excel 解析或 Python 回传。", example = "SKU-001")
private String sku;
@JsonAlias({"price", "价格"})
@Schema(description = "商品价格。来自 Excel 解析或 Python 回传。", example = "12.29")
private String price;
@@ -61,12 +65,28 @@ public class SimilarAsinResultRowDto {
@Schema(description = "Coze 返回的相似度结果。", example = "80%", accessMode = Schema.AccessMode.READ_ONLY)
private String similarity;
@JsonAlias({"is_conform", "isConform", "conform", "是否符合类目"})
@Schema(description = "Coze 返回的是否符合类目结果。", example = "符合", accessMode = Schema.AccessMode.READ_ONLY)
private String isConform;
@JsonAlias({"reason", "原因", "不符合理由"})
@Schema(description = "Coze 返回的不符合理由。", example = "类目不匹配", accessMode = Schema.AccessMode.READ_ONLY)
private String reason;
@JsonAlias({"category", "类目", "产品类目"})
@Schema(description = "Coze 返回的产品类目。", example = "女装", accessMode = Schema.AccessMode.READ_ONLY)
private String category;
@Schema(description = "单行错误信息。通常用于记录 Python 单行处理异常Coze 失败时后端会尽量保留原始行,不强行写入风险结果。", example = "图片地址为空")
private String error;
@Schema(description = "单行完成标记。当前主要使用请求体顶层 done 控制任务收尾,该字段仅作兼容。", example = "true")
private Boolean done;
@JsonAlias({"row_status", "rowStatus", "Status"})
@Schema(description = "Coze row status", example = "success", accessMode = Schema.AccessMode.READ_ONLY)
private String status;
@Schema(description = "兼容旧版 Coze 返回中的标题维度结果字段;当前相似 ASIN 结果文件不再输出该列。", example = "标题未发现明显商标侵权风险。", accessMode = Schema.AccessMode.READ_ONLY)
private String titleRisk;
@@ -92,6 +112,18 @@ public class SimilarAsinResultRowDto {
@Schema(description = "Coze patent reason", accessMode = Schema.AccessMode.READ_ONLY)
private String patentReason;
@JsonAlias({"main_url", "mainUrl", "main_image_url", "mainImageUrl", "main_img", "mainImg", "主图URL", "主图链接"})
@Schema(description = "Coze 回包中的亚马逊主图 URL最终 xlsx 中以嵌入图片形式呈现", example = "https://m.media-amazon.com/images/I/xxx.jpg", accessMode = Schema.AccessMode.READ_ONLY)
private String mainUrl;
@JsonAlias({"puzzle_img1", "puzzleImg1", "puzzle_img_1", "puzzleImg_1", "拼图1"})
@Schema(description = "Coze 回包中阿里巴巴搜图候选 1 的 URL最终 xlsx 中以嵌入图片形式呈现", example = "https://cbu01.alicdn.com/img/ibank/xxx.jpg", accessMode = Schema.AccessMode.READ_ONLY)
private String puzzleImg1;
@JsonAlias({"puzzle_img2", "puzzleImg2", "puzzle_img_2", "puzzleImg_2", "拼图2"})
@Schema(description = "Coze 回包中阿里巴巴搜图候选 2 的 URL最终 xlsx 中以嵌入图片形式呈现", example = "https://cbu01.alicdn.com/img/ibank/yyy.jpg", accessMode = Schema.AccessMode.READ_ONLY)
private String puzzleImg2;
public String getUrl() {
if (url != null && !url.isBlank()) {
return url;

View File

@@ -27,6 +27,4 @@ public class SimilarAsinSubmitResultRequest {
@Schema(description = "本次回传的分组结果列表,推荐优先使用")
private List<SimilarAsinResultGroupDto> groups = new ArrayList<>();
@Schema(description = "兼容旧链路的平铺结果列表")
private List<SimilarAsinResultRowDto> items = new ArrayList<>();
}

View File

@@ -15,5 +15,6 @@ public class SimilarAsinFilterConditionEntity {
private Long id;
private Long userId;
private String conditionText;
private String conditionHash;
private LocalDateTime createdAt;
}

View File

@@ -34,4 +34,8 @@ public class SimilarAsinHistoryItemVo {
private Integer rowCount;
@Schema(description = "历史记录创建时间ISO 本地时间字符串。", example = "2026-04-26T10:30:00")
private String createdAt;
@Schema(description = "任务开始时间ISO 本地时间字符串。当前等价于 biz_file_task.created_at用户点解析创建任务时刻", example = "2026-04-26T10:00:00")
private String startedAt;
@Schema(description = "任务结束时间ISO 本地时间字符串。SUCCESS/FAILED 时回填,未结束为空。", example = "2026-04-26T10:10:00")
private String finishedAt;
}

View File

@@ -39,6 +39,9 @@ public class SimilarAsinParsedRowVo {
@Schema(description = "商品价格。", example = "12.29")
private String price;
@Schema(description = "商品 SKU。", example = "SKU-001")
private String sku;
@Schema(description = "商品图片 URL 或商品 URL供 Coze 检测使用。", example = "https://webstatic.aiproxy.vip/output/demo.jpg")
private String url;

View File

@@ -0,0 +1,464 @@
package com.nanri.aiimage.modules.similarasin.util;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import okhttp3.Dns;
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.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import org.springframework.stereotype.Component;
import javax.imageio.IIOImage;
import javax.imageio.ImageIO;
import javax.imageio.ImageReader;
import javax.imageio.ImageWriteParam;
import javax.imageio.ImageWriter;
import javax.imageio.stream.ImageInputStream;
import javax.imageio.stream.ImageOutputStream;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetAddress;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.UnknownHostException;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicInteger;
/**
* similar-asin 结果 xlsx 的图片嵌入器下载、缩略图压缩、任务内缓存、POI 嵌入与单图失败兜底集中在一处。
* 按 ralplan 共识 .omc/plans/similar-asin-coze-image-embed.md T4 / T4.5 / T5 实现。
*/
@Component
@Slf4j
public class SimilarAsinImageEmbedder {
static final int DOWNLOAD_TIMEOUT_SECONDS = 5;
static final int DOWNLOAD_MAX_RETRY = 1;
static final int DOWNLOAD_POOL_SIZE = 8;
public static final float IMAGE_ROW_HEIGHT_POINTS = 150f;
public static final int IMAGE_COL_WIDTH_CHARS = 30;
static final int TARGET_LONG_EDGE_PX = 300;
static final float JPEG_QUALITY = 0.85f;
static final int MAX_THUMB_SIZE_BYTES = 40 * 1024;
static final int MAX_DOWNLOAD_BYTES = 5 * 1024 * 1024;
static final int MAX_DECODE_PIXELS = 6000 * 6000;
private static final String UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
+ "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
private final OkHttpClient httpClient = new OkHttpClient.Builder()
.connectTimeout(Duration.ofSeconds(DOWNLOAD_TIMEOUT_SECONDS))
.readTimeout(Duration.ofSeconds(DOWNLOAD_TIMEOUT_SECONDS))
.writeTimeout(Duration.ofSeconds(DOWNLOAD_TIMEOUT_SECONDS))
.callTimeout(Duration.ofSeconds(DOWNLOAD_TIMEOUT_SECONDS * 2L))
.retryOnConnectionFailure(false)
.followRedirects(false)
.followSslRedirects(false)
.dns(new SafeDns(Dns.SYSTEM))
.build();
private final ExecutorService downloadPool = Executors.newFixedThreadPool(
DOWNLOAD_POOL_SIZE, namedFactory("similar-asin-image-dl"));
@PreDestroy
public void shutdown() {
downloadPool.shutdownNow();
}
/**
* 嵌入单元格:下载 → resize → POI addPicture → createPicture全链路任一失败即 URL 文本兜底。
* taskImageCache 由调用方在 writeResultWorkbook 入口创建(任务级 ConcurrentHashMap方法返回随栈帧释放。
*/
public void embed(Sheet sheet,
Drawing<?> patriarch,
SXSSFWorkbook wb,
int rowIdx,
int colIdx,
String url,
Row row,
Map<String, byte[]> taskImageCache) {
if (url == null || url.isBlank()) {
return;
}
String trimmedUrl = url.trim();
byte[] thumb;
try {
thumb = taskImageCache.get(trimmedUrl);
boolean cacheHit = thumb != null;
if (!cacheHit) {
byte[] raw = downloadWithRetry(trimmedUrl);
thumb = resizeImage(trimmedUrl, raw);
taskImageCache.put(trimmedUrl, thumb);
log.info("[similar-asin][image] cache-miss url={} bytes={}", trimmedUrl, thumb.length);
} else {
log.debug("[similar-asin][image] cache-hit url={} bytes={}", trimmedUrl, thumb.length);
}
} catch (UnsupportedUrlException ex) {
log.warn("[similar-asin][image] unsupported-url url={} reason={}", trimmedUrl, ex.getMessage());
row.createCell(colIdx).setCellValue(trimmedUrl);
return;
} catch (DownloadOversizeException ex) {
log.warn("[similar-asin][image] download-oversize url={} bytes={}", trimmedUrl, ex.size());
row.createCell(colIdx).setCellValue(trimmedUrl);
return;
} catch (ResizeOversizeException ex) {
log.warn("[similar-asin][image] resize-oversize url={} bytes={}", ex.url(), ex.size());
row.createCell(colIdx).setCellValue(trimmedUrl);
return;
} catch (ResizeException ex) {
log.warn("[similar-asin][image] resize-fail url={} err={}", trimmedUrl, ex.getMessage());
row.createCell(colIdx).setCellValue(trimmedUrl);
return;
} catch (IOException | TimeoutException ex) {
log.warn("[similar-asin][image] download-fail url={} err={}", trimmedUrl, ex.getMessage());
row.createCell(colIdx).setCellValue(trimmedUrl);
return;
} catch (RuntimeException ex) {
log.warn("[similar-asin][image] embed-fail url={} err={}", trimmedUrl, ex.getMessage());
row.createCell(colIdx).setCellValue(trimmedUrl);
return;
}
try {
int picIdx = wb.addPicture(thumb, Workbook.PICTURE_TYPE_JPEG);
ClientAnchor anchor = wb.getCreationHelper().createClientAnchor();
anchor.setCol1(colIdx);
anchor.setRow1(rowIdx);
anchor.setCol2(colIdx + 1);
anchor.setRow2(rowIdx + 1);
anchor.setAnchorType(ClientAnchor.AnchorType.MOVE_AND_RESIZE);
patriarch.createPicture(anchor, picIdx);
} catch (RuntimeException ex) {
log.warn("[similar-asin][image] embed-fail url={} err={}", trimmedUrl, ex.getMessage());
row.createCell(colIdx).setCellValue(trimmedUrl);
}
}
private byte[] downloadWithRetry(String url) throws IOException, TimeoutException {
validateHttpsUrl(url);
IOException last = null;
for (int attempt = 0; attempt <= DOWNLOAD_MAX_RETRY; attempt++) {
Future<byte[]> future = downloadPool.submit(() -> doFetch(url));
try {
return future.get(DOWNLOAD_TIMEOUT_SECONDS * 2L, TimeUnit.SECONDS);
} catch (java.util.concurrent.ExecutionException ee) {
Throwable cause = ee.getCause();
if (cause instanceof DownloadOversizeException doe) {
throw doe;
}
if (cause instanceof IOException io) {
last = io;
continue;
}
if (cause instanceof RuntimeException re) {
throw re;
}
throw new IOException("image download failed: " + cause.getMessage(), cause);
} catch (java.util.concurrent.TimeoutException te) {
future.cancel(true);
throw te;
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new IOException("image download interrupted", ie);
}
}
throw last != null ? last : new IOException("image download failed without cause");
}
static void validateHttpsUrl(String url) {
URI uri;
try {
uri = new URI(url);
} catch (URISyntaxException ex) {
throw new UnsupportedUrlException("invalid url syntax: " + url);
}
String scheme = uri.getScheme();
if (scheme == null || !scheme.toLowerCase(Locale.ROOT).equals("https")) {
throw new UnsupportedUrlException("only https is allowed, got=" + scheme + " url=" + url);
}
String host = uri.getHost();
if (host == null || host.isBlank()) {
throw new UnsupportedUrlException("missing host: " + url);
}
String lowerHost = host.toLowerCase(Locale.ROOT);
if (lowerHost.equals("localhost") || lowerHost.endsWith(".localhost")) {
throw new UnsupportedUrlException("localhost not allowed url=" + url);
}
if (lowerHost.startsWith("127.") || lowerHost.startsWith("10.")
|| lowerHost.startsWith("192.168.")
|| lowerHost.startsWith("169.254.")
|| lowerHost.equals("0.0.0.0")
|| lowerHost.equals("::1")
|| lowerHost.startsWith("[::1")
|| lowerHost.startsWith("[fc")
|| lowerHost.startsWith("[fd")) {
throw new UnsupportedUrlException("private/loopback host not allowed url=" + url);
}
if (lowerHost.startsWith("172.")) {
String[] parts = lowerHost.split("\\.");
if (parts.length >= 2) {
try {
int second = Integer.parseInt(parts[1]);
if (second >= 16 && second <= 31) {
throw new UnsupportedUrlException("private host not allowed url=" + url);
}
} catch (NumberFormatException ignored) {
// 非纯数字,跳过 172.16/12 私网判断
}
}
}
}
private byte[] doFetch(String url) throws IOException {
Request req = new Request.Builder()
.url(url)
.header("User-Agent", UA)
.get()
.build();
try (Response resp = httpClient.newCall(req).execute()) {
if (!resp.isSuccessful()) {
throw new IOException("http " + resp.code() + " for " + url);
}
ResponseBody body = resp.body();
if (body == null) {
throw new IOException("empty body for " + url);
}
long advertised = body.contentLength();
if (advertised > MAX_DOWNLOAD_BYTES) {
throw new DownloadOversizeException(url, advertised);
}
try (InputStream in = body.byteStream()) {
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] buf = new byte[8192];
int total = 0;
int n;
while ((n = in.read(buf)) != -1) {
total += n;
if (total > MAX_DOWNLOAD_BYTES) {
throw new DownloadOversizeException(url, total);
}
out.write(buf, 0, n);
}
return out.toByteArray();
}
}
}
/**
* 长边 300px 等比缩放 + JPEG q=0.85 输出ImageWriter / ImageOutputStream 双层 try-finally 释放 native 资源。
* 出口校验字节数 ≤ MAX_THUMB_SIZE_BYTES超限抛 ResizeOversizeException 触发文本兜底。
*/
byte[] resizeImage(String sourceUrl, byte[] raw) throws IOException {
guardImageDimensions(sourceUrl, raw);
BufferedImage src = ImageIO.read(new ByteArrayInputStream(raw));
if (src == null) {
throw new IOException("unsupported image format url=" + sourceUrl);
}
int srcW = src.getWidth();
int srcH = src.getHeight();
double ratio = (double) Math.max(srcW, srcH) / TARGET_LONG_EDGE_PX;
int dstW = ratio > 1 ? Math.max(1, (int) Math.round(srcW / ratio)) : srcW;
int dstH = ratio > 1 ? Math.max(1, (int) Math.round(srcH / ratio)) : srcH;
BufferedImage dst = new BufferedImage(dstW, dstH, BufferedImage.TYPE_INT_RGB);
Graphics2D g = dst.createGraphics();
try {
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
g.drawImage(src, 0, 0, dstW, dstH, null);
} finally {
g.dispose();
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
Iterator<ImageWriter> writers = ImageIO.getImageWritersByFormatName("jpg");
if (!writers.hasNext()) {
throw new IOException("no JPEG writer available");
}
ImageWriter writer = writers.next();
try {
ImageWriteParam param = writer.getDefaultWriteParam();
param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
param.setCompressionQuality(JPEG_QUALITY);
ImageOutputStream ios = ImageIO.createImageOutputStream(baos);
try {
writer.setOutput(ios);
writer.write(null, new IIOImage(dst, null, null), param);
} finally {
ios.close();
}
} finally {
writer.dispose();
}
if (baos.size() > MAX_THUMB_SIZE_BYTES) {
throw new ResizeOversizeException(sourceUrl, baos.size());
}
return baos.toByteArray();
}
/** 在解码整张位图前用 ImageReader 仅读取头部尺寸,避免“图像炸弹”导致 heap OOM。 */
private static void guardImageDimensions(String sourceUrl, byte[] raw) throws IOException {
try (ImageInputStream iis = ImageIO.createImageInputStream(new ByteArrayInputStream(raw))) {
if (iis == null) {
throw new IOException("unable to create ImageInputStream url=" + sourceUrl);
}
Iterator<ImageReader> readers = ImageIO.getImageReaders(iis);
if (!readers.hasNext()) {
throw new IOException("unsupported image format url=" + sourceUrl);
}
ImageReader reader = readers.next();
try {
reader.setInput(iis, true, true);
long pixels = (long) reader.getWidth(0) * (long) reader.getHeight(0);
if (pixels > MAX_DECODE_PIXELS) {
throw new ResizeException("image too large url=" + sourceUrl + " pixels=" + pixels);
}
} finally {
reader.dispose();
}
}
}
private static ThreadFactory namedFactory(String prefix) {
AtomicInteger counter = new AtomicInteger();
return r -> {
Thread t = new Thread(r, prefix + "-" + counter.incrementAndGet());
t.setDaemon(true);
return t;
};
}
/**
* 在 OkHttp 解析 host 后立刻按 IP 复检,关闭 DNS rebinding 通道。
* 校验项loopback / linkLocal / siteLocal / anyLocal / multicast / IPv4-mapped IPv6 私网 / CGNAT 100.64/10。
*/
static class SafeDns implements Dns {
private final Dns delegate;
SafeDns(Dns delegate) {
this.delegate = delegate;
}
@Override
public List<InetAddress> lookup(String hostname) throws UnknownHostException {
List<InetAddress> resolved = delegate.lookup(hostname);
List<InetAddress> safe = new ArrayList<>(resolved.size());
for (InetAddress addr : resolved) {
if (isPrivateIp(addr)) {
throw new UnknownHostException(
"blocked private/loopback ip host=" + hostname + " ip=" + addr.getHostAddress());
}
safe.add(addr);
}
if (safe.isEmpty()) {
throw new UnknownHostException("no public ip for host=" + hostname);
}
return safe;
}
}
static boolean isPrivateIp(InetAddress addr) {
if (addr.isLoopbackAddress() || addr.isLinkLocalAddress() || addr.isSiteLocalAddress()
|| addr.isAnyLocalAddress() || addr.isMulticastAddress()) {
return true;
}
byte[] raw = addr.getAddress();
if (raw.length == 4) {
int b0 = raw[0] & 0xff;
int b1 = raw[1] & 0xff;
int b2 = raw[2] & 0xff;
int b3 = raw[3] & 0xff;
// CGNAT 100.64.0.0/10
if (b0 == 100 && b1 >= 64 && b1 <= 127) {
return true;
}
// 受限广播 255.255.255.255
if (b0 == 255 && b1 == 255 && b2 == 255 && b3 == 255) {
return true;
}
} else if (raw.length == 16) {
// RFC 4193 IPv6 ULA fc00::/7高 7 位为 1111110与 validateHttpsUrl 的 `[fc`/`[fd` 字面量拦截对齐
if ((raw[0] & 0xfe) == 0xfc) {
return true;
}
}
return false;
}
/** URL 协议/host 不在白名单时抛出,触发文本兜底。 */
public static class UnsupportedUrlException extends RuntimeException {
public UnsupportedUrlException(String message) {
super(message);
}
}
/** 下载链路体积超限保护:上限 MAX_DOWNLOAD_BYTES5MB覆盖 Content-Length 与流式累加两条路径。 */
public static class DownloadOversizeException extends IOException {
private final String url;
private final long size;
public DownloadOversizeException(String url, long size) {
super("download oversize url=" + url + " size=" + size);
this.url = url;
this.size = size;
}
public String url() {
return url;
}
public long size() {
return size;
}
}
/** resize 链路非 oversize 的失败(解码异常、像素炸弹等)。 */
public static class ResizeException extends RuntimeException {
public ResizeException(String message) {
super(message);
}
}
/**
* resize 出口字节硬上限保护worst case 6000 行 × 3 列 × 40KB × 2 副本 = 1440MB
* 因此线上限制建议结合 `taskImageCache` 命中率与单任务行数评估,超出预算需缩小 MAX_THUMB_SIZE_BYTES 或限制行数。
*/
public static class ResizeOversizeException extends ResizeException {
private final String url;
private final int size;
public ResizeOversizeException(String url, int size) {
super("resize oversize url=" + url + " size=" + size);
this.url = url;
this.size = size;
}
public String url() {
return url;
}
public int size() {
return size;
}
}
}

View File

@@ -4,6 +4,8 @@ import com.nanri.aiimage.modules.task.model.dto.TaskFileJobDispatchEvent;
import com.nanri.aiimage.modules.task.model.dto.TaskFileJobMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.task.TaskExecutor;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
@@ -15,6 +17,8 @@ public class TaskFileJobDispatchCoordinator {
private final TaskFileJobPublisher taskFileJobPublisher;
private final TaskFileJobLocalDispatcher taskFileJobLocalDispatcher;
@Qualifier("taskFileJobDispatchExecutor")
private final TaskExecutor taskFileJobDispatchExecutor;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
public void onTaskFileJobDispatch(TaskFileJobDispatchEvent event) {
@@ -29,6 +33,16 @@ public class TaskFileJobDispatchCoordinator {
message.setScopeKey(event.scopeKey());
message.setJobType(event.jobType());
message.setCreatedAt(event.createdAt());
try {
taskFileJobDispatchExecutor.execute(() -> dispatchAfterCommit(event, message));
} catch (RuntimeException ex) {
log.warn("[task-file-job] dispatch executor rejected after commit jobId={} taskId={} moduleType={} msg={}",
event.jobId(), event.taskId(), event.moduleType(), ex.getMessage(), ex);
dispatchAfterCommit(event, message);
}
}
private void dispatchAfterCommit(TaskFileJobDispatchEvent event, TaskFileJobMessage message) {
boolean published = taskFileJobPublisher.publish(message);
if (published) {
return;

View File

@@ -8,12 +8,17 @@ import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
@Slf4j
@Service
@RequiredArgsConstructor
public class TaskFileJobPublisher {
private final ObjectProvider<RocketMQTemplate> rocketMQTemplateProvider;
private final AtomicInteger consecutiveFailures = new AtomicInteger();
private final AtomicLong circuitOpenUntilMillis = new AtomicLong();
@Value("${aiimage.result-file-job.mq-enabled:false}")
private boolean mqEnabled;
@@ -21,6 +26,15 @@ public class TaskFileJobPublisher {
@Value("${aiimage.result-file-job.topic:aiimage-result-file-job}")
private String topic;
@Value("${aiimage.result-file-job.mq-send-timeout-ms:1000}")
private long mqSendTimeoutMillis;
@Value("${aiimage.result-file-job.mq-failure-threshold:3}")
private int mqFailureThreshold;
@Value("${aiimage.result-file-job.mq-circuit-open-ms:60000}")
private long mqCircuitOpenMillis;
public boolean publish(TaskFileJobMessage message) {
if (message == null || message.getJobId() == null) {
return false;
@@ -30,6 +44,13 @@ public class TaskFileJobPublisher {
message.getJobId(), message.getTaskId(), message.getModuleType());
return false;
}
long now = System.currentTimeMillis();
long openUntil = circuitOpenUntilMillis.get();
if (openUntil > now) {
log.info("[task-file-job] RocketMQ publish circuit open, fallback to local dispatcher/scheduled worker jobId={} taskId={} moduleType={} remainingMs={}",
message.getJobId(), message.getTaskId(), message.getModuleType(), openUntil - now);
return false;
}
RocketMQTemplate template = rocketMQTemplateProvider.getIfAvailable();
if (template == null) {
log.warn("[task-file-job] RocketMQTemplate unavailable, jobId={} taskId={} moduleType={}",
@@ -37,13 +58,25 @@ public class TaskFileJobPublisher {
return false;
}
try {
template.convertAndSend(topic, message);
template.syncSend(topic, message, Math.max(100L, mqSendTimeoutMillis));
consecutiveFailures.set(0);
circuitOpenUntilMillis.set(0L);
log.info("[task-file-job] mq published jobId={} taskId={} moduleType={} topic={}",
message.getJobId(), message.getTaskId(), message.getModuleType(), topic);
return true;
} catch (Exception ex) {
log.warn("[task-file-job] publish RocketMQ failed, fallback to local dispatcher/scheduled worker jobId={} taskId={} moduleType={} msg={}",
message.getJobId(), message.getTaskId(), message.getModuleType(), ex.getMessage());
int failures = consecutiveFailures.incrementAndGet();
int threshold = Math.max(1, mqFailureThreshold);
if (failures >= threshold) {
long nextOpenUntil = System.currentTimeMillis() + Math.max(1000L, mqCircuitOpenMillis);
circuitOpenUntilMillis.set(nextOpenUntil);
log.warn("[task-file-job] publish RocketMQ failed, circuit opened and fallback to local dispatcher/scheduled worker jobId={} taskId={} moduleType={} failures={} openMs={} msg={}",
message.getJobId(), message.getTaskId(), message.getModuleType(), failures,
Math.max(1000L, mqCircuitOpenMillis), ex.getMessage());
} else {
log.warn("[task-file-job] publish RocketMQ failed, fallback to local dispatcher/scheduled worker jobId={} taskId={} moduleType={} failures={}/{} msg={}",
message.getJobId(), message.getTaskId(), message.getModuleType(), failures, threshold, ex.getMessage());
}
return false;
}
}

View File

@@ -12,6 +12,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -76,6 +77,41 @@ public class TaskFileJobService {
.last("limit " + Math.max(1, Math.min(limit, 100))));
}
public List<TaskFileJobEntity> listRunnableJobsForOwner(int limit, String owner) {
String normalizedOwner = owner == null ? "" : owner.trim();
if (normalizedOwner.isBlank()) {
return listRunnableJobs(limit);
}
String ownerMarker = ":owner:" + normalizedOwner;
int safeLimit = Math.max(1, Math.min(limit, 100));
List<TaskFileJobEntity> ownerJobs = taskFileJobMapper.selectList(new LambdaQueryWrapper<TaskFileJobEntity>()
.in(TaskFileJobEntity::getStatus, List.of("PENDING", "FAILED"))
.lt(TaskFileJobEntity::getRetryCount, 5)
.in(TaskFileJobEntity::getModuleType, List.of("APPEARANCE_PATENT", "SIMILAR_ASIN"))
.like(TaskFileJobEntity::getScopeKey, ownerMarker)
.orderByAsc(TaskFileJobEntity::getUpdatedAt)
.last("limit " + safeLimit));
if (ownerJobs.size() >= safeLimit) {
return ownerJobs;
}
List<TaskFileJobEntity> jobs = new ArrayList<>(ownerJobs);
LambdaQueryWrapper<TaskFileJobEntity> genericWrapper = new LambdaQueryWrapper<TaskFileJobEntity>()
.in(TaskFileJobEntity::getStatus, List.of("PENDING", "FAILED"))
.lt(TaskFileJobEntity::getRetryCount, 5)
.and(wrapper -> wrapper
.notIn(TaskFileJobEntity::getModuleType, List.of("APPEARANCE_PATENT", "SIMILAR_ASIN"))
.or()
.isNull(TaskFileJobEntity::getScopeKey)
.or()
.notLike(TaskFileJobEntity::getScopeKey, ":owner:"))
.orderByAsc(TaskFileJobEntity::getUpdatedAt)
.last("limit " + (safeLimit - jobs.size()));
jobs.addAll(taskFileJobMapper.selectList(genericWrapper));
return jobs;
}
public List<TaskFileJobEntity> listJobs(String status, String moduleType, Long taskId, int limit) {
LambdaQueryWrapper<TaskFileJobEntity> wrapper = new LambdaQueryWrapper<TaskFileJobEntity>()
.orderByDesc(TaskFileJobEntity::getUpdatedAt)
@@ -130,7 +166,8 @@ public class TaskFileJobService {
.eq(TaskFileJobEntity::getStatus, "RUNNING")
.set(TaskFileJobEntity::getStatus, "FAILED")
.set(TaskFileJobEntity::getErrorMessage, "文件生成任务运行超时")
.set(TaskFileJobEntity::getUpdatedAt, LocalDateTime.now()));
.set(TaskFileJobEntity::getUpdatedAt, LocalDateTime.now())
.set(TaskFileJobEntity::getFinishedAt, LocalDateTime.now()));
continue;
}
int updated = taskFileJobMapper.update(null, new LambdaUpdateWrapper<TaskFileJobEntity>()
@@ -168,14 +205,30 @@ public class TaskFileJobService {
.set(TaskFileJobEntity::getUpdatedAt, LocalDateTime.now()));
}
public boolean deferRunning(Long jobId, String message) {
if (jobId == null || jobId <= 0) {
return false;
}
return taskFileJobMapper.update(null, new LambdaUpdateWrapper<TaskFileJobEntity>()
.eq(TaskFileJobEntity::getId, jobId)
.eq(TaskFileJobEntity::getStatus, "RUNNING")
.set(TaskFileJobEntity::getStatus, "PENDING")
.set(TaskFileJobEntity::getErrorMessage, message)
.set(TaskFileJobEntity::getUpdatedAt, LocalDateTime.now())
.set(TaskFileJobEntity::getFinishedAt, null)) > 0;
}
@Transactional
public boolean requeue(Long jobId, String message) {
if (jobId == null || jobId <= 0) {
return false;
}
// 仅在状态从非 PENDING 切换到 PENDING 时更新并发布事件,
// 避免对已在排队中的 job 重复触发 dispatch同一 jobId 在多个 dispatch 线程并发处理)。
int updated = taskFileJobMapper.update(null, new LambdaUpdateWrapper<TaskFileJobEntity>()
.eq(TaskFileJobEntity::getId, jobId)
.ne(TaskFileJobEntity::getStatus, "SUCCESS")
.ne(TaskFileJobEntity::getStatus, "PENDING")
.set(TaskFileJobEntity::getStatus, "PENDING")
.set(TaskFileJobEntity::getErrorMessage, message)
.set(TaskFileJobEntity::getUpdatedAt, LocalDateTime.now())
@@ -198,14 +251,38 @@ public class TaskFileJobService {
.set(TaskFileJobEntity::getFinishedAt, LocalDateTime.now()));
}
public TaskFileJobEntity findById(Long jobId) {
if (jobId == null || jobId <= 0) {
return null;
}
return taskFileJobMapper.selectById(jobId);
}
public void markFailed(TaskFileJobEntity job, String message) {
int retryCount = job.getRetryCount() == null ? 0 : job.getRetryCount() + 1;
taskFileJobMapper.update(null, new LambdaUpdateWrapper<TaskFileJobEntity>()
.eq(TaskFileJobEntity::getId, job.getId())
.ne(TaskFileJobEntity::getStatus, "SUCCESS")
.set(TaskFileJobEntity::getStatus, "FAILED")
.set(TaskFileJobEntity::getRetryCount, retryCount)
.set(TaskFileJobEntity::getErrorMessage, message == null ? "结果文件生成失败" : message)
.set(TaskFileJobEntity::getUpdatedAt, LocalDateTime.now()));
.set(TaskFileJobEntity::getUpdatedAt, LocalDateTime.now())
.set(TaskFileJobEntity::getFinishedAt, retryCount >= 5 ? LocalDateTime.now() : null));
}
/**
* 终态失败:把 retryCount 直接拉满到上限,让 listRunnableJobs 不再扫到该 job。
* 适用于 task/result 已经不存在等无法通过重试恢复的场景。
*/
public void markFailedPermanent(TaskFileJobEntity job, String message) {
taskFileJobMapper.update(null, new LambdaUpdateWrapper<TaskFileJobEntity>()
.eq(TaskFileJobEntity::getId, job.getId())
.ne(TaskFileJobEntity::getStatus, "SUCCESS")
.set(TaskFileJobEntity::getStatus, "FAILED")
.set(TaskFileJobEntity::getRetryCount, 5)
.set(TaskFileJobEntity::getErrorMessage, message == null ? "结果文件生成失败" : message)
.set(TaskFileJobEntity::getUpdatedAt, LocalDateTime.now())
.set(TaskFileJobEntity::getFinishedAt, LocalDateTime.now()));
}
public TaskFileJobEntity findAssembleJob(Long taskId, String moduleType, Long resultId) {

View File

@@ -1,5 +1,6 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
import com.nanri.aiimage.config.InstanceMetadata;
import com.nanri.aiimage.modules.brand.service.BrandTaskService;
@@ -62,7 +63,7 @@ public class TaskResultFileJobWorker {
if (!localWorkerEnabled) {
return;
}
List<TaskFileJobEntity> jobs = taskFileJobService.listRunnableJobs(batchSize);
List<TaskFileJobEntity> jobs = taskFileJobService.listRunnableJobsForOwner(batchSize, currentInstanceId());
if (jobs == null || jobs.isEmpty()) {
return;
}
@@ -89,7 +90,7 @@ public class TaskResultFileJobWorker {
public void process(TaskFileJobEntity job) {
if (isOwnerScopedCozeJob(job) && !isOwnedByCurrentInstance(job)) {
log.info("[task-file-job] skip owner-scoped job because owner is another instance jobId={} taskId={} moduleType={} owner={} current={}",
log.debug("[task-file-job] skip owner-scoped job because owner is another instance jobId={} taskId={} moduleType={} owner={} current={}",
job.getId(), job.getTaskId(), job.getModuleType(), ownerFromScopeKey(job.getScopeKey()), currentInstanceId());
return;
}
@@ -111,6 +112,12 @@ public class TaskResultFileJobWorker {
private void processInternal(TaskFileJobEntity job) {
long startedAt = System.currentTimeMillis();
try {
TaskFileJobEntity latest = taskFileJobService.findById(job.getId());
if (latest != null && "SUCCESS".equals(latest.getStatus())) {
log.info("[task-file-job] process skipped because job already succeeded jobId={} taskId={} moduleType={} resultId={}",
job.getId(), job.getTaskId(), job.getModuleType(), job.getResultId());
return;
}
TaskDistributedLockService.LockHandle lockHandle =
taskDistributedLockService.acquire(job.getModuleType(), job.getTaskId(), TaskDistributedLockService.DEFAULT_WAIT_MILLIS);
if (lockHandle == null) {
@@ -120,9 +127,22 @@ public class TaskResultFileJobWorker {
return;
}
try (lockHandle) {
latest = taskFileJobService.findById(job.getId());
if (latest != null && "SUCCESS".equals(latest.getStatus())) {
log.info("[task-file-job] process skipped after lock because job already succeeded jobId={} taskId={} moduleType={} resultId={}",
job.getId(), job.getTaskId(), job.getModuleType(), job.getResultId());
return;
}
boolean completed = dispatch(job);
if (!completed) {
taskFileJobService.touchRunning(job.getId());
if (isOwnerScopedCozeJob(job)) {
taskFileJobService.touchRunning(job.getId());
log.info("[task-file-job] process waiting for async coze result jobId={} taskId={} moduleType={} resultId={} elapsedMs={}",
job.getId(), job.getTaskId(), job.getModuleType(), job.getResultId(),
System.currentTimeMillis() - startedAt);
return;
}
taskFileJobService.deferRunning(job.getId(), "Waiting for Coze/file assembly to continue");
log.info("[task-file-job] process deferred jobId={} taskId={} moduleType={} resultId={} elapsedMs={}",
job.getId(), job.getTaskId(), job.getModuleType(), job.getResultId(),
System.currentTimeMillis() - startedAt);
@@ -136,13 +156,40 @@ public class TaskResultFileJobWorker {
System.currentTimeMillis() - startedAt, resultFileUrl);
}
} catch (Exception ex) {
TaskFileJobEntity latest = taskFileJobService.findById(job.getId());
if (latest != null && "SUCCESS".equals(latest.getStatus())) {
log.info("[task-file-job] process failure ignored because job already succeeded jobId={} taskId={} moduleType={} resultId={} msg={}",
job.getId(), job.getTaskId(), job.getModuleType(), job.getResultId(), ex.getMessage());
return;
}
String message = ex.getMessage() == null ? "结果文件生成失败" : ex.getMessage();
// 孤儿 jobfileTask/fileResult 已被删除却残留 task_file_job 行,重试 5 次也救不回来,
// 直接拉满 retryCount 让 worker 跳过,避免每 15 秒刷一次 task not found 警告日志。
if (isOrphanJobFailure(ex, message)) {
log.warn("[task-file-job] process aborted because owning task/result no longer exists jobId={} taskId={} moduleType={} resultId={} msg={}",
job.getId(), job.getTaskId(), job.getModuleType(), job.getResultId(), message);
taskFileJobService.markFailedPermanent(job, message);
return;
}
log.warn("[task-file-job] process failed jobId={} taskId={} moduleType={} resultId={} msg={}",
job.getId(), job.getTaskId(), job.getModuleType(), job.getResultId(), message);
taskFileJobService.markFailed(job, message);
}
}
private boolean isOrphanJobFailure(Exception ex, String message) {
if (!(ex instanceof BusinessException)) {
return false;
}
if (message == null) {
return false;
}
return message.contains("task not found")
|| message.contains("result record not found")
|| message.contains("任务不存在")
|| message.contains("记录不存在");
}
private String resolveResultFileUrl(TaskFileJobEntity job) {
if ("BRAND".equals(job.getModuleType())) {
return brandTaskService.resolveResultObjectKey(job.getTaskId());

View File

@@ -1,6 +1,7 @@
package com.nanri.aiimage.modules.task.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.config.InstanceMetadata;
import com.nanri.aiimage.config.StorageProperties;
import com.nanri.aiimage.config.TransientStorageProperties;
import com.nanri.aiimage.modules.file.service.object.RustfsObjectStorageService;
@@ -28,6 +29,7 @@ import java.util.zip.GZIPOutputStream;
public class TransientPayloadStorageService {
private static final String LOCAL_POINTER_PREFIX = "local:";
private static final String LOCAL_INSTANCE_TAG = "i/";
private static final String RUSTFS_POINTER_PREFIX = "rustfs:";
private static final String OSS_POINTER_PREFIX = "oss:";
private static final String LOCAL_PAYLOAD_DIR = "transient-payload";
@@ -37,6 +39,7 @@ public class TransientPayloadStorageService {
private final RustfsObjectStorageService rustfsObjectStorageService;
private final OssStorageService ossStorageService;
private final ObjectMapper objectMapper;
private final InstanceMetadata instanceMetadata;
public boolean isWriteEnabled() {
return properties.isEnabled()
@@ -94,7 +97,15 @@ public class TransientPayloadStorageService {
}
try {
if (pointer.startsWith(LOCAL_POINTER_PREFIX)) {
return decodeStoredPayload(readLocalPayload(pointer.substring(LOCAL_POINTER_PREFIX.length())));
String localKey = pointer.substring(LOCAL_POINTER_PREFIX.length());
String ownerInstance = extractLocalInstanceId(localKey);
if (ownerInstance != null && !ownerInstance.equals(instanceMetadata.getInstanceId())) {
// 跨实例消费 local 指针时立即报错,避免悄悄读出空内容造成下游错位。
throw new IllegalStateException(
"transient payload only exists on instance=" + ownerInstance
+ " current=" + instanceMetadata.getInstanceId());
}
return decodeStoredPayload(readLocalPayload(stripLocalInstanceId(localKey)));
}
if (pointer.startsWith(RUSTFS_POINTER_PREFIX)) {
return decodeStoredPayload(rustfsObjectStorageService.readObjectAsString(pointer.substring(RUSTFS_POINTER_PREFIX.length())));
@@ -114,7 +125,15 @@ public class TransientPayloadStorageService {
return;
}
if (pointer.startsWith(LOCAL_POINTER_PREFIX)) {
deleteLocalPayload(pointer.substring(LOCAL_POINTER_PREFIX.length()));
String localKey = pointer.substring(LOCAL_POINTER_PREFIX.length());
String ownerInstance = extractLocalInstanceId(localKey);
if (ownerInstance != null && !ownerInstance.equals(instanceMetadata.getInstanceId())) {
// 跨实例不能直接删另一台机器上的本地文件,留给该实例的清理任务处理。
log.info("[transient-payload] skip cross-instance local delete pointer={} owner={} current={}",
pointer, ownerInstance, instanceMetadata.getInstanceId());
return;
}
deleteLocalPayload(stripLocalInstanceId(localKey));
return;
}
if (pointer.startsWith(RUSTFS_POINTER_PREFIX)) {
@@ -191,9 +210,9 @@ public class TransientPayloadStorageService {
try {
pointer = RUSTFS_POINTER_PREFIX + rustfsObjectStorageService.uploadText(objectKey, storedContent, verifyAfterUpload);
} catch (Exception ex) {
log.warn("[transient-payload] rustfs upload failed objectKey={} err={}",
objectKey, ex.getMessage());
throw ex;
// 升级为 ERRORrustfs 失败后只能落到本地,多实例下其他节点读不到,必须能告警。
log.error("[transient-payload] rustfs upload failed, fallback to local store instanceId={} objectKey={} err={}",
instanceMetadata.getInstanceId(), objectKey, ex.getMessage());
}
}
if (pointer == null) {
@@ -226,7 +245,9 @@ public class TransientPayloadStorageService {
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE);
return LOCAL_POINTER_PREFIX + objectKey;
// 在 local 指针上携带 instanceId 段,跨实例 resolve 时能直接判定来源、避免错读。
String instanceId = sanitizeInstanceId(instanceMetadata.getInstanceId());
return LOCAL_POINTER_PREFIX + LOCAL_INSTANCE_TAG + instanceId + "/" + objectKey;
} catch (Exception ex) {
log.warn("[transient-payload] local store failed objectKey={} err={}", objectKey, ex.getMessage());
return null;
@@ -242,6 +263,47 @@ public class TransientPayloadStorageService {
}
}
/**
* 从 local 指针中解析出归属实例 ID。指针形式
* - 新格式:{@code i/<instanceId>/<objectKey>}
* - 老格式:{@code <objectKey>}(无 instanceId 段,返回 null 表示未携带,向后兼容旧任务)。
*/
private String extractLocalInstanceId(String localKey) {
if (localKey == null || !localKey.startsWith(LOCAL_INSTANCE_TAG)) {
return null;
}
String remainder = localKey.substring(LOCAL_INSTANCE_TAG.length());
int slash = remainder.indexOf('/');
if (slash <= 0) {
return null;
}
return remainder.substring(0, slash);
}
/**
* 去掉 local 指针中的 instanceId 段,得到真正的 objectKey。
*/
private String stripLocalInstanceId(String localKey) {
if (localKey == null || !localKey.startsWith(LOCAL_INSTANCE_TAG)) {
return localKey;
}
String remainder = localKey.substring(LOCAL_INSTANCE_TAG.length());
int slash = remainder.indexOf('/');
if (slash < 0) {
return remainder;
}
return remainder.substring(slash + 1);
}
private String sanitizeInstanceId(String value) {
String normalized = value == null ? "" : value.trim();
if (normalized.isBlank()) {
return "unknown";
}
// 仅允许字母、数字、下划线、横线和点,避免出现路径分隔符破坏 objectKey 解析。
return normalized.replaceAll("[^A-Za-z0-9_.\\-]", "_");
}
private String encodeStoredPayload(String content) {
try {
byte[] raw = Objects.requireNonNullElse(content, "").getBytes(StandardCharsets.UTF_8);

View File

@@ -1,6 +1,11 @@
# 这个文件只是给你对照看的“环境变量配置示例”,不是 Spring Boot 自动加载的配置文件。
# 你现在最简单的做法:把下面这些键值复制到 IDEA 的 Run/Debug Configuration -> Environment variables。
# This file is only an environment-variable reference for local IDE startup.
# Recommended local startup:
# 1. Set SPRING_PROFILES_ACTIVE=local
# 2. Copy the needed keys below into IDEA Run/Debug Configuration -> Environment variables
# 3. Set AIIMAGE_INSTANCE_ID to a stable local value such as local-121
SPRING_PROFILES_ACTIVE=local
AIIMAGE_INSTANCE_ID=local-121
AIIMAGE_SERVER_PORT=18080
AIIMAGE_DB_URL=jdbc:mysql://127.0.0.1:3306/aiimage?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&useSSL=false
@@ -30,17 +35,17 @@ AIIMAGE_APPEARANCE_PATENT_COZE_BASE_URL=https://api.coze.cn
AIIMAGE_APPEARANCE_PATENT_COZE_WORKFLOW_PATH=/v1/workflow/run
AIIMAGE_APPEARANCE_PATENT_COZE_WORKFLOW_ID=7632683471312355338
AIIMAGE_APPEARANCE_PATENT_COZE_TOKEN=
AIIMAGE_APPEARANCE_PATENT_COZE_BATCH_SIZE=10
AIIMAGE_APPEARANCE_PATENT_COZE_BATCH_SIZE=50
AIIMAGE_APPEARANCE_PATENT_COZE_READ_TIMEOUT_MILLIS=60000
AIIMAGE_APPEARANCE_PATENT_STALE_TIMEOUT_MINUTES=20
AIIMAGE_APPEARANCE_PATENT_STALE_TIMEOUT_MINUTES=30
AIIMAGE_SIMILAR_ASIN_COZE_BASE_URL=https://api.coze.cn
AIIMAGE_SIMILAR_ASIN_COZE_WORKFLOW_PATH=/v1/workflow/run
AIIMAGE_SIMILAR_ASIN_COZE_WORKFLOW_ID=7632683471312355338
AIIMAGE_SIMILAR_ASIN_COZE_WORKFLOW_ID=7635328462404583478
AIIMAGE_SIMILAR_ASIN_COZE_TOKEN=
AIIMAGE_SIMILAR_ASIN_COZE_BATCH_SIZE=10
AIIMAGE_SIMILAR_ASIN_COZE_BATCH_SIZE=50
AIIMAGE_SIMILAR_ASIN_COZE_READ_TIMEOUT_MILLIS=60000
AIIMAGE_SIMILAR_ASIN_STALE_TIMEOUT_MINUTES=20
AIIMAGE_SIMILAR_ASIN_STALE_TIMEOUT_MINUTES=30
AIIMAGE_ROCKETMQ_NAME_SERVER=121.196.149.225:9876
AIIMAGE_RESULT_FILE_JOB_MQ_ENABLED=true

View File

@@ -0,0 +1,19 @@
# Common production override for all servers.
# Activate with: SPRING_PROFILES_ACTIVE=server
# Always pass AIIMAGE_INSTANCE_ID and Redis connection parameters from
# the startup command or panel environment variables.
spring:
data:
redis:
username: ${AIIMAGE_REDIS_USERNAME:}
host: ${AIIMAGE_REDIS_HOST}
port: ${AIIMAGE_REDIS_PORT}
password: ${AIIMAGE_REDIS_PASSWORD}
database: ${AIIMAGE_REDIS_DATABASE:0}
timeout: ${AIIMAGE_REDIS_TIMEOUT:5s}
aiimage:
instance-id: ${AIIMAGE_INSTANCE_ID:}
result-file-job:
mq-enabled: ${AIIMAGE_RESULT_FILE_JOB_MQ_ENABLED:true}

View File

@@ -4,8 +4,6 @@ server:
spring:
application:
name: aiimage-backend
config:
import: optional:classpath:application-local.yml
servlet:
multipart:
max-file-size: 200MB
@@ -14,9 +12,9 @@ spring:
time-zone: Asia/Shanghai
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${AIIMAGE_DB_URL:jdbc:mysql://127.0.0.1:3306/aiimage?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true}
url: ${AIIMAGE_DB_URL:jdbc:mysql://47.110.241.161:3306/aiimage?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true}
username: ${AIIMAGE_DB_USERNAME:root}
password: ${AIIMAGE_DB_PASSWORD:change-me}
password: ${AIIMAGE_DB_PASSWORD:WTFrb5y6hNLz6hNy}
hikari:
maximum-pool-size: ${AIIMAGE_DB_POOL_MAX_SIZE:30}
minimum-idle: ${AIIMAGE_DB_POOL_MIN_IDLE:5}
@@ -25,12 +23,13 @@ spring:
idle-timeout: ${AIIMAGE_DB_POOL_IDLE_TIMEOUT_MS:600000}
max-lifetime: ${AIIMAGE_DB_POOL_MAX_LIFETIME_MS:1500000}
keepalive-time: ${AIIMAGE_DB_POOL_KEEPALIVE_TIME_MS:120000}
leak-detection-threshold: ${AIIMAGE_DB_POOL_LEAK_DETECT_MS:0}
leak-detection-threshold: ${AIIMAGE_DB_POOL_LEAK_DETECT_MS:15000}
data:
redis:
host: ${AIIMAGE_REDIS_HOST:127.0.0.1}
port: ${AIIMAGE_REDIS_PORT:6379}
password: ${AIIMAGE_REDIS_PASSWORD:}
username: ${AIIMAGE_REDIS_USERNAME:}
host: ${AIIMAGE_REDIS_HOST:47.111.163.154}
port: ${AIIMAGE_REDIS_PORT:16379}
password: ${AIIMAGE_REDIS_PASSWORD:B6COTcY094TYe545}
database: ${AIIMAGE_REDIS_DATABASE:0}
timeout: ${AIIMAGE_REDIS_TIMEOUT:5s}
@@ -72,19 +71,22 @@ knife4j:
language: zh_cn
aiimage:
instance-id: ${AIIMAGE_INSTANCE_ID:}
# Set a stable server-level ID in 1Panel/Docker with AIIMAGE_INSTANCE_ID.
# For IDEA/local startup, set -Daiimage.instance-id=<stable-id> or configure the AIIMAGE_INSTANCE_ID env var.
# Avoid relying on container hostname/container ID, otherwise task owner continuity may break after restart/redeploy.
instance-id: ${AIIMAGE_INSTANCE_ID:local-dev}
oss:
region: ${AIIMAGE_OSS_REGION:cn-hangzhou}
endpoint: ${AIIMAGE_OSS_ENDPOINT:oss-cn-hangzhou.aliyuncs.com}
bucket: ${AIIMAGE_OSS_BUCKET:change-me}
access-key-id: ${AIIMAGE_OSS_ACCESS_KEY_ID:change-me}
access-key-secret: ${AIIMAGE_OSS_ACCESS_KEY_SECRET:change-me}
bucket: ${AIIMAGE_OSS_BUCKET:nanri-ai-images}
access-key-id: ${AIIMAGE_OSS_ACCESS_KEY_ID:LTAI5tNpyvzMNz9f2dHarsm8}
access-key-secret: ${AIIMAGE_OSS_ACCESS_KEY_SECRET:bQSZnFH455i8tzyOgeahJmUzwmhynz}
transient-storage:
enabled: ${AIIMAGE_TRANSIENT_STORAGE_ENABLED:false}
endpoint: ${AIIMAGE_TRANSIENT_STORAGE_ENDPOINT:}
bucket: ${AIIMAGE_TRANSIENT_STORAGE_BUCKET:}
access-key-id: ${AIIMAGE_TRANSIENT_STORAGE_ACCESS_KEY_ID:}
access-key-secret: ${AIIMAGE_TRANSIENT_STORAGE_ACCESS_KEY_SECRET:}
enabled: ${AIIMAGE_TRANSIENT_STORAGE_ENABLED:true}
endpoint: ${AIIMAGE_TRANSIENT_STORAGE_ENDPOINT:http://121.196.149.225:9000}
bucket: ${AIIMAGE_TRANSIENT_STORAGE_BUCKET:json-server}
access-key-id: ${AIIMAGE_TRANSIENT_STORAGE_ACCESS_KEY_ID:YPkyFAymauf21pHMoK0V}
access-key-secret: ${AIIMAGE_TRANSIENT_STORAGE_ACCESS_KEY_SECRET:XcPzJqT8jzAHDEQNCovhkPmlwWcphBwLdA36BTzL}
region: ${AIIMAGE_TRANSIENT_STORAGE_REGION:us-east-1}
connect-timeout-seconds: ${AIIMAGE_TRANSIENT_STORAGE_CONNECT_TIMEOUT_SECONDS:10}
read-timeout-seconds: ${AIIMAGE_TRANSIENT_STORAGE_READ_TIMEOUT_SECONDS:60}
@@ -131,9 +133,12 @@ aiimage:
scope-payload-recovery-max-files: ${AIIMAGE_TASK_SCOPE_PAYLOAD_RECOVERY_MAX_FILES:200}
scope-payload-cleanup-cron: ${AIIMAGE_TASK_SCOPE_PAYLOAD_CLEANUP_CRON:15 */30 * * * *}
result-file-job:
mq-enabled: ${AIIMAGE_RESULT_FILE_JOB_MQ_ENABLED:false}
mq-enabled: ${AIIMAGE_RESULT_FILE_JOB_MQ_ENABLED:true}
topic: ${AIIMAGE_RESULT_FILE_JOB_TOPIC:aiimage-result-file-job}
consumer-group: ${AIIMAGE_RESULT_FILE_JOB_CONSUMER_GROUP:aiimage-result-file-job-consumer}
mq-send-timeout-ms: ${AIIMAGE_RESULT_FILE_JOB_MQ_SEND_TIMEOUT_MS:1000}
mq-failure-threshold: ${AIIMAGE_RESULT_FILE_JOB_MQ_FAILURE_THRESHOLD:3}
mq-circuit-open-ms: ${AIIMAGE_RESULT_FILE_JOB_MQ_CIRCUIT_OPEN_MS:60000}
local-dispatch-enabled: ${AIIMAGE_RESULT_FILE_JOB_LOCAL_DISPATCH_ENABLED:true}
local-dispatch-pool-size: ${AIIMAGE_RESULT_FILE_JOB_LOCAL_DISPATCH_POOL_SIZE:2}
local-dispatch-queue-capacity: ${AIIMAGE_RESULT_FILE_JOB_LOCAL_DISPATCH_QUEUE_CAPACITY:200}
@@ -142,30 +147,35 @@ aiimage:
stuck-scan-delay-ms: ${AIIMAGE_RESULT_FILE_JOB_STUCK_SCAN_DELAY_MS:60000}
stuck-timeout-minutes: ${AIIMAGE_RESULT_FILE_JOB_STUCK_TIMEOUT_MINUTES:30}
batch-size: ${AIIMAGE_RESULT_FILE_JOB_BATCH_SIZE:20}
coze-task:
max-concurrent: ${AIIMAGE_COZE_TASK_MAX_CONCURRENT:12}
appearance-patent:
coze-base-url: ${AIIMAGE_APPEARANCE_PATENT_COZE_BASE_URL:https://api.coze.cn}
coze-workflow-path: ${AIIMAGE_APPEARANCE_PATENT_COZE_WORKFLOW_PATH:/v1/workflow/run}
coze-workflow-id: ${AIIMAGE_APPEARANCE_PATENT_COZE_WORKFLOW_ID:7632683471312355338}
coze-token: ${AIIMAGE_APPEARANCE_PATENT_COZE_TOKEN:Bearer sat_gsInckdsN0qqhnZSK7K4Zevw6neJCHmYHyIbm85oGgJIGEmSdkH5OFctSKLsAHvT}
coze-batch-size: ${AIIMAGE_APPEARANCE_PATENT_COZE_BATCH_SIZE:10}
coze-workflow-id: ${AIIMAGE_APPEARANCE_PATENT_COZE_WORKFLOW_ID:7639685157562089513}
coze-token: ${AIIMAGE_APPEARANCE_PATENT_COZE_TOKEN:}
coze-batch-size: ${AIIMAGE_APPEARANCE_PATENT_COZE_BATCH_SIZE:20}
coze-connect-timeout-millis: ${AIIMAGE_APPEARANCE_PATENT_COZE_CONNECT_TIMEOUT_MILLIS:10000}
coze-read-timeout-millis: ${AIIMAGE_APPEARANCE_PATENT_COZE_READ_TIMEOUT_MILLIS:60000}
coze-poll-interval-millis: ${AIIMAGE_APPEARANCE_PATENT_COZE_POLL_INTERVAL_MILLIS:5000}
coze-poll-interval-millis: ${AIIMAGE_APPEARANCE_PATENT_COZE_POLL_INTERVAL_MILLIS:30000}
coze-poll-timeout-millis: ${AIIMAGE_APPEARANCE_PATENT_COZE_POLL_TIMEOUT_MILLIS:600000}
stale-timeout-minutes: ${AIIMAGE_APPEARANCE_PATENT_STALE_TIMEOUT_MINUTES:20}
stale-timeout-minutes: ${AIIMAGE_APPEARANCE_PATENT_STALE_TIMEOUT_MINUTES:30}
stale-finalize-cron: ${AIIMAGE_APPEARANCE_PATENT_STALE_FINALIZE_CRON:0 */2 * * * *}
similar-asin:
coze-base-url: ${AIIMAGE_SIMILAR_ASIN_COZE_BASE_URL:https://api.coze.cn}
coze-workflow-path: ${AIIMAGE_SIMILAR_ASIN_COZE_WORKFLOW_PATH:/v1/workflow/run}
coze-workflow-id: ${AIIMAGE_SIMILAR_ASIN_COZE_WORKFLOW_ID:7632683471312355338}
coze-token: ${AIIMAGE_SIMILAR_ASIN_COZE_TOKEN:Bearer sat_gsInckdsN0qqhnZSK7K4Zevw6neJCHmYHyIbm85oGgJIGEmSdkH5OFctSKLsAHvT}
coze-workflow-id: ${AIIMAGE_SIMILAR_ASIN_COZE_WORKFLOW_ID:7639708860686024756}
coze-token: ${AIIMAGE_SIMILAR_ASIN_COZE_TOKEN:}
coze-batch-size: ${AIIMAGE_SIMILAR_ASIN_COZE_BATCH_SIZE:10}
coze-credential-stripe-size: ${AIIMAGE_SIMILAR_ASIN_COZE_CREDENTIAL_STRIPE_SIZE:0}
coze-connect-timeout-millis: ${AIIMAGE_SIMILAR_ASIN_COZE_CONNECT_TIMEOUT_MILLIS:10000}
coze-read-timeout-millis: ${AIIMAGE_SIMILAR_ASIN_COZE_READ_TIMEOUT_MILLIS:60000}
coze-poll-interval-millis: ${AIIMAGE_SIMILAR_ASIN_COZE_POLL_INTERVAL_MILLIS:5000}
coze-poll-timeout-millis: ${AIIMAGE_SIMILAR_ASIN_COZE_POLL_TIMEOUT_MILLIS:600000}
stale-timeout-minutes: ${AIIMAGE_SIMILAR_ASIN_STALE_TIMEOUT_MINUTES:20}
coze-poll-interval-millis: ${AIIMAGE_SIMILAR_ASIN_COZE_POLL_INTERVAL_MILLIS:30000}
coze-poll-timeout-millis: ${AIIMAGE_SIMILAR_ASIN_COZE_POLL_TIMEOUT_MILLIS:1800000}
stale-timeout-minutes: ${AIIMAGE_SIMILAR_ASIN_STALE_TIMEOUT_MINUTES:30}
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}
security:
shop-credential-key: ${AIIMAGE_SHOP_CREDENTIAL_KEY:change-me-shop-credential-key}
internal-token: ${AIIMAGE_INTERNAL_TOKEN:}

View File

@@ -0,0 +1,68 @@
SET @db_name = DATABASE();
SET @idx_exists := (
SELECT COUNT(*)
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = @db_name
AND TABLE_NAME = 'biz_similar_asin_filter_condition'
AND INDEX_NAME = 'uk_similar_asin_filter_condition_user_text'
);
SET @sql := IF(@idx_exists > 0,
'ALTER TABLE biz_similar_asin_filter_condition DROP INDEX uk_similar_asin_filter_condition_user_text',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @condition_text_type := (
SELECT DATA_TYPE
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db_name
AND TABLE_NAME = 'biz_similar_asin_filter_condition'
AND COLUMN_NAME = 'condition_text'
);
SET @sql := IF(@condition_text_type <> 'mediumtext',
'ALTER TABLE biz_similar_asin_filter_condition MODIFY condition_text MEDIUMTEXT NOT NULL',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @condition_hash_exists := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db_name
AND TABLE_NAME = 'biz_similar_asin_filter_condition'
AND COLUMN_NAME = 'condition_hash'
);
SET @sql := IF(@condition_hash_exists = 0,
'ALTER TABLE biz_similar_asin_filter_condition ADD COLUMN condition_hash CHAR(64) NULL AFTER condition_text',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
UPDATE biz_similar_asin_filter_condition
SET condition_hash = SHA2(condition_text, 256)
WHERE condition_hash IS NULL OR condition_hash = '';
ALTER TABLE biz_similar_asin_filter_condition
MODIFY condition_hash CHAR(64) NOT NULL;
SET @idx_exists := (
SELECT COUNT(*)
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = @db_name
AND TABLE_NAME = 'biz_similar_asin_filter_condition'
AND INDEX_NAME = 'uk_similar_asin_filter_condition_user_hash'
);
SET @sql := IF(@idx_exists = 0,
'ALTER TABLE biz_similar_asin_filter_condition ADD UNIQUE KEY uk_similar_asin_filter_condition_user_hash (user_id, condition_hash)',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@@ -0,0 +1,12 @@
INSERT INTO `columns` (`name`, `column_key`, `menu_type`, `route_path`, `sort_order`)
SELECT '商品类目', 'admin_product_categories', 'admin', 'product-categories', 66
WHERE NOT EXISTS (
SELECT 1 FROM `columns` WHERE `column_key` = 'admin_product_categories'
);
UPDATE `columns`
SET `name` = '商品类目',
`menu_type` = 'admin',
`route_path` = 'product-categories',
`sort_order` = 66
WHERE `column_key` = 'admin_product_categories';

View File

@@ -0,0 +1,91 @@
CREATE TABLE IF NOT EXISTS biz_product_category (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
parent_id BIGINT NULL COMMENT '父级类目ID',
name VARCHAR(128) NOT NULL COMMENT '类目名称',
category_key VARCHAR(128) NOT NULL COMMENT '类目标识',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序',
description VARCHAR(512) NOT NULL DEFAULT '' COMMENT '备注',
is_builtin TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否内置',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
UNIQUE KEY uk_product_category_key (category_key),
KEY idx_product_category_parent_sort (parent_id, sort_order, id)
) COMMENT='商品类目树';
INSERT INTO biz_product_category (parent_id, name, category_key, sort_order, is_builtin)
SELECT NULL, '护肤品', 'skincare', 10, 1
WHERE NOT EXISTS (SELECT 1 FROM biz_product_category WHERE category_key = 'skincare');
INSERT INTO biz_product_category (parent_id, name, category_key, sort_order, is_builtin)
SELECT NULL, '彩妆', 'makeup', 20, 1
WHERE NOT EXISTS (SELECT 1 FROM biz_product_category WHERE category_key = 'makeup');
INSERT INTO biz_product_category (parent_id, name, category_key, sort_order, is_builtin)
SELECT NULL, '洗护', 'haircare', 30, 1
WHERE NOT EXISTS (SELECT 1 FROM biz_product_category WHERE category_key = 'haircare');
INSERT INTO biz_product_category (parent_id, name, category_key, sort_order, is_builtin)
SELECT NULL, '功效类', 'functional', 40, 1
WHERE NOT EXISTS (SELECT 1 FROM biz_product_category WHERE category_key = 'functional');
INSERT INTO biz_product_category (parent_id, name, category_key, sort_order, is_builtin)
SELECT NULL, '香氛', 'fragrance', 50, 1
WHERE NOT EXISTS (SELECT 1 FROM biz_product_category WHERE category_key = 'fragrance');
INSERT INTO biz_product_category (parent_id, name, category_key, sort_order, is_builtin)
SELECT p.id, '面霜', 'skincare_cream', 10, 1 FROM biz_product_category p
WHERE p.category_key = 'skincare' AND NOT EXISTS (SELECT 1 FROM biz_product_category WHERE category_key = 'skincare_cream');
INSERT INTO biz_product_category (parent_id, name, category_key, sort_order, is_builtin)
SELECT p.id, '精华', 'skincare_essence', 20, 1 FROM biz_product_category p
WHERE p.category_key = 'skincare' AND NOT EXISTS (SELECT 1 FROM biz_product_category WHERE category_key = 'skincare_essence');
INSERT INTO biz_product_category (parent_id, name, category_key, sort_order, is_builtin)
SELECT p.id, '面膜', 'skincare_mask', 30, 1 FROM biz_product_category p
WHERE p.category_key = 'skincare' AND NOT EXISTS (SELECT 1 FROM biz_product_category WHERE category_key = 'skincare_mask');
INSERT INTO biz_product_category (parent_id, name, category_key, sort_order, is_builtin)
SELECT p.id, '爽肤水', 'skincare_toner', 40, 1 FROM biz_product_category p
WHERE p.category_key = 'skincare' AND NOT EXISTS (SELECT 1 FROM biz_product_category WHERE category_key = 'skincare_toner');
INSERT INTO biz_product_category (parent_id, name, category_key, sort_order, is_builtin)
SELECT p.id, '身体乳', 'skincare_body_lotion', 50, 1 FROM biz_product_category p
WHERE p.category_key = 'skincare' AND NOT EXISTS (SELECT 1 FROM biz_product_category WHERE category_key = 'skincare_body_lotion');
INSERT INTO biz_product_category (parent_id, name, category_key, sort_order, is_builtin)
SELECT p.id, '口红', 'makeup_lipstick', 10, 1 FROM biz_product_category p
WHERE p.category_key = 'makeup' AND NOT EXISTS (SELECT 1 FROM biz_product_category WHERE category_key = 'makeup_lipstick');
INSERT INTO biz_product_category (parent_id, name, category_key, sort_order, is_builtin)
SELECT p.id, '粉底', 'makeup_foundation', 20, 1 FROM biz_product_category p
WHERE p.category_key = 'makeup' AND NOT EXISTS (SELECT 1 FROM biz_product_category WHERE category_key = 'makeup_foundation');
INSERT INTO biz_product_category (parent_id, name, category_key, sort_order, is_builtin)
SELECT p.id, '眼影', 'makeup_eyeshadow', 30, 1 FROM biz_product_category p
WHERE p.category_key = 'makeup' AND NOT EXISTS (SELECT 1 FROM biz_product_category WHERE category_key = 'makeup_eyeshadow');
INSERT INTO biz_product_category (parent_id, name, category_key, sort_order, is_builtin)
SELECT p.id, '睫毛', 'makeup_mascara', 40, 1 FROM biz_product_category p
WHERE p.category_key = 'makeup' AND NOT EXISTS (SELECT 1 FROM biz_product_category WHERE category_key = 'makeup_mascara');
INSERT INTO biz_product_category (parent_id, name, category_key, sort_order, is_builtin)
SELECT p.id, '洗发水', 'haircare_shampoo', 10, 1 FROM biz_product_category p
WHERE p.category_key = 'haircare' AND NOT EXISTS (SELECT 1 FROM biz_product_category WHERE category_key = 'haircare_shampoo');
INSERT INTO biz_product_category (parent_id, name, category_key, sort_order, is_builtin)
SELECT p.id, '护发素', 'haircare_conditioner', 20, 1 FROM biz_product_category p
WHERE p.category_key = 'haircare' AND NOT EXISTS (SELECT 1 FROM biz_product_category WHERE category_key = 'haircare_conditioner');
INSERT INTO biz_product_category (parent_id, name, category_key, sort_order, is_builtin)
SELECT p.id, '染发剂', 'haircare_hair_dye', 30, 1 FROM biz_product_category p
WHERE p.category_key = 'haircare' AND NOT EXISTS (SELECT 1 FROM biz_product_category WHERE category_key = 'haircare_hair_dye');
INSERT INTO biz_product_category (parent_id, name, category_key, sort_order, is_builtin)
SELECT p.id, '烫发膏', 'haircare_perm_cream', 40, 1 FROM biz_product_category p
WHERE p.category_key = 'haircare' AND NOT EXISTS (SELECT 1 FROM biz_product_category WHERE category_key = 'haircare_perm_cream');
INSERT INTO biz_product_category (parent_id, name, category_key, sort_order, is_builtin)
SELECT p.id, '防晒', 'functional_sunscreen', 10, 1 FROM biz_product_category p
WHERE p.category_key = 'functional' AND NOT EXISTS (SELECT 1 FROM biz_product_category WHERE category_key = 'functional_sunscreen');
INSERT INTO biz_product_category (parent_id, name, category_key, sort_order, is_builtin)
SELECT p.id, '祛斑', 'functional_spot_removal', 20, 1 FROM biz_product_category p
WHERE p.category_key = 'functional' AND NOT EXISTS (SELECT 1 FROM biz_product_category WHERE category_key = 'functional_spot_removal');
INSERT INTO biz_product_category (parent_id, name, category_key, sort_order, is_builtin)
SELECT p.id, '美白', 'functional_whitening', 30, 1 FROM biz_product_category p
WHERE p.category_key = 'functional' AND NOT EXISTS (SELECT 1 FROM biz_product_category WHERE category_key = 'functional_whitening');
INSERT INTO biz_product_category (parent_id, name, category_key, sort_order, is_builtin)
SELECT p.id, '祛痘产品', 'functional_acne_care', 40, 1 FROM biz_product_category p
WHERE p.category_key = 'functional' AND NOT EXISTS (SELECT 1 FROM biz_product_category WHERE category_key = 'functional_acne_care');
INSERT INTO biz_product_category (parent_id, name, category_key, sort_order, description, is_builtin)
SELECT p.id, '香水', 'fragrance_perfume', 10, '', 1 FROM biz_product_category p
WHERE p.category_key = 'fragrance' AND NOT EXISTS (SELECT 1 FROM biz_product_category WHERE category_key = 'fragrance_perfume');
INSERT INTO biz_product_category (parent_id, name, category_key, sort_order, description, is_builtin)
SELECT p.id, '香薰精油(高酒精属于危险品)', 'fragrance_essential_oil_hazardous', 20, '高酒精属于危险品', 1 FROM biz_product_category p
WHERE p.category_key = 'fragrance' AND NOT EXISTS (SELECT 1 FROM biz_product_category WHERE category_key = 'fragrance_essential_oil_hazardous');

View File

@@ -0,0 +1,29 @@
CREATE TABLE IF NOT EXISTS biz_coze_credential (
id BIGINT NOT NULL AUTO_INCREMENT,
module_type VARCHAR(64) NOT NULL,
credential_name VARCHAR(128) NOT NULL,
workflow_id VARCHAR(128) NOT NULL,
token VARCHAR(512) NOT NULL,
enabled TINYINT NOT NULL DEFAULT 1,
max_concurrent INT NOT NULL DEFAULT 0,
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_coze_credential_module_name (module_type, credential_name),
KEY idx_coze_credential_module_enabled (module_type, enabled, sort_order, id)
);
INSERT INTO biz_coze_credential (module_type, credential_name, workflow_id, token, enabled, max_concurrent, sort_order)
VALUES
('APPEARANCE_PATENT', 'appearance-1', '7639685157562089513', 'Bearer sat_CztofPRhIKFKeaPZBIRxocfckqhsdCZZ45NPhkf7WOZjbX36vnVSfVV30T6u2rSX', 1, 0, 10),
('APPEARANCE_PATENT', 'appearance-2', '7639688221823778850', 'Bearer sat_2VsTpfWXYG3EkCo8OQfLwJEWqhduCL4NEw7sSCsABppkW61kkSRPIac9coNlEnHE', 1, 0, 20),
('SIMILAR_ASIN', 'similar-1', '7639708860686024756', 'Bearer sat_vqmb97BiXbMW3XVyG9htWQKjZ2F55CaW2FRrT9bDdPha7a3hxmXnMNCE1XNHdCVU', 1, 0, 10),
('SIMILAR_ASIN', 'similar-2', '7635328462404583478', 'Bearer sat_gsInckdsN0qqhnZSK7K4Zevw6neJCHmYHyIbm85oGgJIGEmSdkH5OFctSKLsAHvT', 1, 0, 20)
ON DUPLICATE KEY UPDATE
workflow_id = VALUES(workflow_id),
token = VALUES(token),
enabled = VALUES(enabled),
max_concurrent = VALUES(max_concurrent),
sort_order = VALUES(sort_order),
updated_at = CURRENT_TIMESTAMP;

View File

@@ -0,0 +1,28 @@
-- =============================================================================
-- V52 占位脚本:用于 coze credential token 轮换
-- =============================================================================
-- 背景:
-- V51 历史脚本中以明文形式写入了 4 个生产 coze token已记录在 git 提交记录里)。
-- 出于安全考虑,需要在 coze 平台立刻吊销这 4 个 token并由运维带外用新
-- token 覆盖 biz_coze_credential 表对应记录。
--
-- 本脚本的目的:
-- 1. 不在 git 里写入任何明文新 token保证版本库中再也搜不到 sat_ 前缀的明文。
-- 2. 仅占用 V52 版本号,让 Flyway 顺利推进到下一版本,不影响线上数据。
-- 3. 通过 Flyway history 留下"V52 已飞、token 轮换由运维带外完成"的可审计痕迹。
--
-- 运维操作 (带外执行,不要写进 git)
-- UPDATE biz_coze_credential SET token = '<NEW_TOKEN_HERE>'
-- WHERE module_type = 'APPEARANCE_PATENT' AND credential_name = 'appearance-1';
-- UPDATE biz_coze_credential SET token = '<NEW_TOKEN_HERE>'
-- WHERE module_type = 'APPEARANCE_PATENT' AND credential_name = 'appearance-2';
-- UPDATE biz_coze_credential SET token = '<NEW_TOKEN_HERE>'
-- WHERE module_type = 'SIMILAR_ASIN' AND credential_name = 'similar-1';
-- UPDATE biz_coze_credential SET token = '<NEW_TOKEN_HERE>'
-- WHERE module_type = 'SIMILAR_ASIN' AND credential_name = 'similar-2';
--
-- application.yml 中两个 coze-token 默认值已同步清空,
-- AIIMAGE_*_COZE_TOKEN 环境变量为空时 SimilarAsinCozeClient.hasConfiguredCredential()
-- 会返回 false并打印 "coze token not configured, skip async coze"。
-- =============================================================================
SELECT 1;

View File

@@ -4,7 +4,7 @@
Source Server : AI-线
Source Server Type : MySQL
Source Server Version : 80045 (8.0.45)
Source Host : 8.136.19.173:3306
Source Host : 47.111.163.154:3306
Source Schema : aiimage
Target Server Type : MySQL

View File

@@ -0,0 +1,68 @@
package com.nanri.aiimage.common.util;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class FailedStatusRowFilterTest {
@Test
void findsStatusColumnByChineseOrEnglishAlias() {
assertEquals(2, FailedStatusRowFilter.findStatusColumnIndex(List.of("id", "asin", "\u72b6\u6001")));
assertEquals(1, FailedStatusRowFilter.findStatusColumnIndex(List.of("id", " Status ")));
assertEquals(-1, FailedStatusRowFilter.findStatusColumnIndex(List.of("id", "asin", "country")));
}
@Test
void matchesConfiguredFailedStatusValues() {
assertTrue(FailedStatusRowFilter.matchesFailedStatus("\u9519\u8bef"));
assertTrue(FailedStatusRowFilter.matchesFailedStatus("\u5931\u8d25"));
assertTrue(FailedStatusRowFilter.matchesFailedStatus("FAILED"));
assertTrue(FailedStatusRowFilter.matchesFailedStatus(" failed "));
assertTrue(FailedStatusRowFilter.isBlankStatus(" "));
assertFalse(FailedStatusRowFilter.matchesFailedStatus("\u6210\u529f"));
}
@Test
void retainsOnlyFailedRowsWhenStatusFilteringEnabled() {
List<String> rows = List.of("\u9519\u8bef", "\u6210\u529f", "FAILED", "\u5904\u7406\u4e2d");
FailedStatusRowFilter.FilterResult<String> result =
FailedStatusRowFilter.retainFailedRows(rows, true, value -> value);
assertTrue(result.filterApplied());
assertEquals(List.of("\u9519\u8bef", "FAILED"), result.rows());
assertEquals(2, result.filteredCount());
}
@Test
void keepsOriginalRowsWhenStatusFilteringDisabled() {
List<String> rows = List.of("\u9519\u8bef", "\u6210\u529f");
FailedStatusRowFilter.FilterResult<String> result =
FailedStatusRowFilter.retainFailedRows(rows, false, value -> value);
assertFalse(result.filterApplied());
assertEquals(rows, result.rows());
assertEquals(0, result.filteredCount());
}
@Test
void canRetainFailedAndBlankRowsTogether() {
List<String> rows = List.of("\u9519\u8bef", "", "\u6210\u529f", "FAILED");
FailedStatusRowFilter.FilterResult<String> result =
FailedStatusRowFilter.retainRows(
rows,
true,
value -> value,
value -> FailedStatusRowFilter.matchesFailedStatus(value)
|| FailedStatusRowFilter.isBlankStatus(value)
);
assertTrue(result.filterApplied());
assertEquals(List.of("\u9519\u8bef", "", "FAILED"), result.rows());
assertEquals(1, result.filteredCount());
}
}

View File

@@ -0,0 +1,153 @@
package com.nanri.aiimage.modules.similarasin.util;
import org.junit.jupiter.api.Test;
import javax.imageio.ImageIO;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.Collections;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertFalse;
class SimilarAsinImageEmbedderTest {
private final SimilarAsinImageEmbedder embedder = new SimilarAsinImageEmbedder();
@Test
void resizeImageProducesThumbnailUnderHardCap() throws Exception {
BufferedImage src = new BufferedImage(1200, 1600, BufferedImage.TYPE_INT_RGB);
Graphics2D g = src.createGraphics();
try {
g.setColor(new Color(0x33, 0x66, 0x99));
g.fillRect(0, 0, 1200, 1600);
} finally {
g.dispose();
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(src, "jpg", baos);
byte[] thumb = embedder.resizeImage("https://example.com/big.jpg", baos.toByteArray());
assertNotNull(thumb);
assertTrue(thumb.length > 0, "thumb 不可为空");
assertTrue(thumb.length <= SimilarAsinImageEmbedder.MAX_THUMB_SIZE_BYTES,
"thumb 字节应 <= 40KB 上限,实际=" + thumb.length);
BufferedImage decoded = ImageIO.read(new java.io.ByteArrayInputStream(thumb));
assertNotNull(decoded, "thumb 应可被 ImageIO 重新解码");
int longEdge = Math.max(decoded.getWidth(), decoded.getHeight());
assertEquals(SimilarAsinImageEmbedder.TARGET_LONG_EDGE_PX, longEdge,
"长边应等于 TARGET_LONG_EDGE_PX=300");
}
@Test
void resizeOversizeExceptionCarriesUrlAndSize() {
SimilarAsinImageEmbedder.ResizeOversizeException ex =
new SimilarAsinImageEmbedder.ResizeOversizeException("https://example.com/huge.jpg", 99999);
assertEquals("https://example.com/huge.jpg", ex.url());
assertEquals(99999, ex.size());
assertTrue(ex.getMessage().contains("oversize"));
assertTrue(ex.getMessage().contains("99999"));
}
@Test
void resizeUnsupportedFormatThrowsIOException() {
byte[] notAnImage = "not an image at all, just some bytes".getBytes();
Exception ex = assertThrows(Exception.class,
() -> embedder.resizeImage("https://example.com/broken.bin", notAnImage));
assertTrue(ex instanceof java.io.IOException || ex instanceof RuntimeException,
"应为 IOException 或 RuntimeException 兜底,实际=" + ex.getClass().getSimpleName());
assertTrue(ex.getMessage() == null
|| ex.getMessage().toLowerCase().contains("unsupported")
|| ex.getMessage().toLowerCase().contains("unable"),
"异常消息应反映不支持/无法解析");
}
@Test
void safeDnsRejectsPrivateIpsOnLookup() {
SimilarAsinImageEmbedder.SafeDns safeDns = new SimilarAsinImageEmbedder.SafeDns(
hostname -> Collections.singletonList(InetAddress.getByName("127.0.0.1")));
UnknownHostException ex = assertThrows(UnknownHostException.class,
() -> safeDns.lookup("rebound.example.com"));
assertTrue(ex.getMessage().contains("blocked private/loopback ip"),
"异常消息应说明被阻断,实际=" + ex.getMessage());
}
@Test
void safeDnsAllowsPublicIpResolution() throws Exception {
SimilarAsinImageEmbedder.SafeDns safeDns = new SimilarAsinImageEmbedder.SafeDns(
hostname -> Collections.singletonList(InetAddress.getByName("8.8.8.8")));
List<InetAddress> result = safeDns.lookup("dns.google");
assertEquals(1, result.size());
assertEquals("8.8.8.8", result.get(0).getHostAddress());
}
@Test
void safeDnsRejectsMixedResultsContainingPrivateIp() {
SimilarAsinImageEmbedder.SafeDns safeDns = new SimilarAsinImageEmbedder.SafeDns(
hostname -> List.of(
InetAddress.getByName("8.8.8.8"),
InetAddress.getByName("10.0.0.5")));
// 任一结果落入私网即整体阻断,关闭 DNS rebinding 通道
assertThrows(UnknownHostException.class,
() -> safeDns.lookup("partial-rebound.example.com"));
}
@Test
void isPrivateIpCoversCgnatAndStandardRanges() throws Exception {
assertTrue(SimilarAsinImageEmbedder.isPrivateIp(InetAddress.getByName("127.0.0.1")), "loopback");
assertTrue(SimilarAsinImageEmbedder.isPrivateIp(InetAddress.getByName("10.0.0.1")), "10/8");
assertTrue(SimilarAsinImageEmbedder.isPrivateIp(InetAddress.getByName("192.168.1.1")), "192.168/16");
assertTrue(SimilarAsinImageEmbedder.isPrivateIp(InetAddress.getByName("169.254.0.1")), "link-local");
assertTrue(SimilarAsinImageEmbedder.isPrivateIp(InetAddress.getByName("172.16.0.1")), "site-local 172.16");
assertTrue(SimilarAsinImageEmbedder.isPrivateIp(InetAddress.getByName("100.64.0.1")), "CGNAT lower");
assertTrue(SimilarAsinImageEmbedder.isPrivateIp(InetAddress.getByName("100.127.255.255")), "CGNAT upper");
assertFalse(SimilarAsinImageEmbedder.isPrivateIp(InetAddress.getByName("8.8.8.8")), "public DNS");
assertFalse(SimilarAsinImageEmbedder.isPrivateIp(InetAddress.getByName("100.63.0.1")), "below CGNAT 段");
assertFalse(SimilarAsinImageEmbedder.isPrivateIp(InetAddress.getByName("100.128.0.1")), "above CGNAT 段");
// 受限广播
assertTrue(SimilarAsinImageEmbedder.isPrivateIp(InetAddress.getByName("255.255.255.255")), "受限广播");
// IPv6 ULA fc00::/7
assertTrue(SimilarAsinImageEmbedder.isPrivateIp(InetAddress.getByName("fc00::1")), "ULA fc00::");
assertTrue(SimilarAsinImageEmbedder.isPrivateIp(InetAddress.getByName("fd12:3456:789a::1")), "ULA fd::");
// IPv6 公网放行
assertFalse(SimilarAsinImageEmbedder.isPrivateIp(InetAddress.getByName("2001:4860:4860::8888")), "Google IPv6 DNS");
}
@Test
void validateHttpsUrlRejectsHttpAndPrivateHosts() {
assertThrows(SimilarAsinImageEmbedder.UnsupportedUrlException.class,
() -> SimilarAsinImageEmbedder.validateHttpsUrl("http://example.com/a.jpg"));
assertThrows(SimilarAsinImageEmbedder.UnsupportedUrlException.class,
() -> SimilarAsinImageEmbedder.validateHttpsUrl("https://localhost/a.jpg"));
assertThrows(SimilarAsinImageEmbedder.UnsupportedUrlException.class,
() -> SimilarAsinImageEmbedder.validateHttpsUrl("https://127.0.0.1/a.jpg"));
assertThrows(SimilarAsinImageEmbedder.UnsupportedUrlException.class,
() -> SimilarAsinImageEmbedder.validateHttpsUrl("https://10.0.0.5/a.jpg"));
assertThrows(SimilarAsinImageEmbedder.UnsupportedUrlException.class,
() -> SimilarAsinImageEmbedder.validateHttpsUrl("https://192.168.1.1/a.jpg"));
assertThrows(SimilarAsinImageEmbedder.UnsupportedUrlException.class,
() -> SimilarAsinImageEmbedder.validateHttpsUrl("https://172.16.0.1/a.jpg"));
// 公网 https 应通过
SimilarAsinImageEmbedder.validateHttpsUrl("https://m.media-amazon.com/images/I/abc.jpg");
SimilarAsinImageEmbedder.validateHttpsUrl("https://cbu01.alicdn.com/img/ibank/xxx.jpg");
}
@Test
void downloadOversizeExceptionCarriesUrlAndSize() {
SimilarAsinImageEmbedder.DownloadOversizeException ex =
new SimilarAsinImageEmbedder.DownloadOversizeException("https://example.com/big.jpg", 12345678L);
assertEquals("https://example.com/big.jpg", ex.url());
assertEquals(12345678L, ex.size());
assertTrue(ex.getMessage().contains("oversize"));
}
}

View File

@@ -5,10 +5,11 @@ import json
import os
import re
import threading
from urllib.parse import quote
import requests
from requests.adapters import HTTPAdapter
from flask import Blueprint, request, jsonify, session, current_app, g
from flask import Blueprint, request, jsonify, session, current_app, g, Response
import pymysql
from werkzeug.security import generate_password_hash
@@ -48,6 +49,8 @@ admin_api = Blueprint('admin_api', __name__, url_prefix='/api/admin')
_backend_java_session_local = threading.local()
_column_sort_schema_checked = False
_column_sort_schema_lock = threading.Lock()
_product_category_schema_checked = False
_product_category_schema_lock = threading.Lock()
ADMIN_MENU_ACCESS_CONFIG = {
'dedupe-total-data': {
@@ -70,6 +73,11 @@ ADMIN_MENU_ACCESS_CONFIG = {
'route_path': 'query-asin',
'error': '无权访问查询 ASIN 模块',
},
'product-categories': {
'column_key': 'admin_product_categories',
'route_path': 'product-categories',
'error': '无权访问商品类目模块',
},
}
ADMIN_MENU_ACCESS_CONFIG.update({
@@ -142,14 +150,24 @@ def _proxy_backend_java(method, path, *, params=None, json_data=None, files=None
def _format_permission_item(item):
created_at = item.get('createdAt')
if created_at in (None, ''):
created_at = item.get('created_at')
if hasattr(created_at, 'strftime'):
created_at_text = created_at.strftime('%Y-%m-%d %H:%M')
else:
created_at_text = (str(created_at).replace('T', ' ')[:16]) if created_at else ''
sort_order = item.get('sortOrder')
if sort_order is None:
sort_order = item.get('sort_order')
return {
'id': item.get('id'),
'name': item.get('name') or '',
'column_key': item.get('columnKey') or '',
'menu_type': item.get('menuType') or 'app',
'route_path': item.get('routePath') or '',
'sort_order': item.get('sortOrder') if item.get('sortOrder') is not None else 0,
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
'column_key': item.get('columnKey') or item.get('column_key') or '',
'menu_type': item.get('menuType') or item.get('menu_type') or 'app',
'route_path': item.get('routePath') or item.get('route_path') or '',
'sort_order': sort_order if sort_order is not None else 0,
'created_at': created_at_text,
}
@@ -300,6 +318,92 @@ def _get_next_local_column_sort_order():
conn.close()
PRODUCT_CATEGORY_DEFAULT_TREE = [
{
'name': '护肤品',
'key': 'skincare',
'children': [
('面霜', 'skincare_cream'),
('精华', 'skincare_essence'),
('面膜', 'skincare_mask'),
('爽肤水', 'skincare_toner'),
('身体乳', 'skincare_body_lotion'),
],
},
{
'name': '彩妆',
'key': 'makeup',
'children': [
('口红', 'makeup_lipstick'),
('粉底', 'makeup_foundation'),
('眼影', 'makeup_eyeshadow'),
('睫毛', 'makeup_mascara'),
],
},
{
'name': '洗护',
'key': 'haircare',
'children': [
('洗发水', 'haircare_shampoo'),
('护发素', 'haircare_conditioner'),
('染发剂', 'haircare_hair_dye'),
('烫发膏', 'haircare_perm_cream'),
],
},
{
'name': '功效类',
'key': 'functional',
'children': [
('防晒', 'functional_sunscreen'),
('祛斑', 'functional_spot_removal'),
('美白', 'functional_whitening'),
('祛痘产品', 'functional_acne_care'),
],
},
{
'name': '香氛',
'key': 'fragrance',
'children': [
('香水', 'fragrance_perfume'),
('香薰精油(高酒精属于危险品)', 'fragrance_essential_oil_hazardous'),
],
},
]
def _ensure_local_admin_menu_item(name, column_key, route_path, sort_order):
_ensure_column_sort_schema()
conn = get_db()
try:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO columns (name, column_key, menu_type, route_path, sort_order)
VALUES (%s, %s, 'admin', %s, %s)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
menu_type = 'admin',
route_path = VALUES(route_path),
sort_order = VALUES(sort_order)
""",
(name, column_key, route_path, int(sort_order)),
)
conn.commit()
finally:
conn.close()
def _ensure_product_category_schema():
global _product_category_schema_checked
if _product_category_schema_checked:
return
with _product_category_schema_lock:
if _product_category_schema_checked:
return
_ensure_local_admin_menu_item('商品类目', 'admin_product_categories', 'product-categories', 66)
_product_category_schema_checked = True
def _swap_local_column_sort_order(column_id, target_id):
_ensure_column_sort_schema()
conn = get_db()
@@ -440,6 +544,20 @@ def _ensure_backend_menu_access(*menu_names):
return role, current_row, (jsonify({'success': False, 'error': error_message}), 403)
def _ensure_product_category_access():
role, current_row, items, denied = _load_current_backend_menu_items()
if denied:
return role, current_row, denied
if role == 'super_admin':
return role, current_row, None
for item in items or []:
if (item.get('column_key') or '').strip() == 'admin_product_categories':
return role, current_row, None
if (item.get('route_path') or '').strip() == 'product-categories':
return role, current_row, None
return role, current_row, (jsonify({'success': False, 'error': '无权访问商品类目模块'}), 403)
def _get_column_ids_by_route_paths(route_paths, menu_type='admin'):
normalized_paths = [(path or '').strip() for path in (route_paths or []) if (path or '').strip()]
if not normalized_paths:
@@ -496,6 +614,7 @@ def get_admin_current_user():
@admin_api.route('/current-user/menus')
@login_required
def get_admin_current_user_menus():
_ensure_product_category_schema()
_, _, items, denied = _load_current_backend_menu_items()
if denied:
return denied
@@ -684,11 +803,9 @@ def create_user():
(username, pwd_hash, is_admin, want_role, want_created_by),
)
new_uid = cur.lastrowid
_set_user_column_permissions(cur, new_uid, column_ids)
conn.commit()
conn.close()
error_response, status = _sync_user_column_permissions(new_uid, column_ids)
if error_response is not None:
return error_response, status
return jsonify({'success': True, 'msg': '用户创建成功'})
except pymysql.IntegrityError:
return jsonify({'success': False, 'error': '用户名已存在'})
@@ -699,7 +816,7 @@ def create_user():
@admin_api.route('/user/<int:uid>', methods=['PUT'])
@admin_required
def update_user(uid):
"""更新用户(密码、角色)"""
"""更新用户(密码、角色、栏目权限"""
data = request.get_json() or {}
_, _, denied = _ensure_admin_menu_access('users')
if denied:
@@ -709,7 +826,7 @@ def update_user(uid):
role, current_row = get_current_admin_role()
if not role:
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
if want_role is None and not password:
if want_role is None and not password and 'column_ids' not in data:
return jsonify({'success': False, 'error': '请提供要修改的内容'})
try:
conn = get_db()
@@ -744,13 +861,10 @@ def update_user(uid):
"UPDATE users SET is_admin = %s, role = %s WHERE id = %s",
(is_admin, want_role, uid),
)
column_ids = data.get('column_ids')
if 'column_ids' in data:
_set_user_column_permissions(cur, uid, data.get('column_ids'))
conn.commit()
conn.close()
if column_ids is not None:
error_response, status = _sync_user_column_permissions(uid, column_ids)
if error_response is not None:
return error_response, status
return jsonify({'success': True, 'msg': '更新成功'})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
@@ -874,6 +988,7 @@ def list_columns():
_, _, denied = _ensure_admin_menu_access('columns', 'users')
if denied:
return denied
_ensure_product_category_schema()
menu_type = (request.args.get('menu_type') or '').strip()
local_rows = _load_local_columns(menu_type or None)
items = [
@@ -1030,15 +1145,7 @@ def get_user_columns(uid):
if denied:
return denied
menu_type = (request.args.get('menu_type') or '').strip()
result, error_response, status = _proxy_backend_java(
'GET',
f'/api/admin/permission-users/{uid}/columns',
params={'menuType': menu_type} if menu_type else None,
)
if error_response is not None:
return error_response, status
payload = result.get('data') or {}
return jsonify({'success': True, 'column_ids': payload.get('columnIds') or []})
return jsonify({'success': True, 'column_ids': _get_local_user_column_ids(uid, menu_type or None)})
@admin_api.route('/user/<int:uid>/column-permissions')
@@ -1048,14 +1155,8 @@ def get_user_column_permissions(uid):
if denied:
return denied
menu_type = (request.args.get('menu_type') or '').strip()
result, error_response, status = _proxy_backend_java(
'GET',
f'/api/admin/permission-users/{uid}/column-permissions',
params={'menuType': menu_type} if menu_type else None,
)
if error_response is not None:
return error_response, status
return jsonify({'success': True, 'items': [_format_permission_item(item) for item in (result.get('data') or [])]})
items = _get_local_user_column_permission_items(uid, menu_type or None)
return jsonify({'success': True, 'items': [_format_permission_item(item) for item in items]})
def _set_user_column_permissions(cur, user_id, column_ids):
@@ -1072,6 +1173,189 @@ def _set_user_column_permissions(cur, user_id, column_ids):
# ---------- 版本管理web_config ----------
# ---------- 商品类目 ----------
def _format_product_category_item(item, include_children=True):
formatted = {
'id': item.get('id'),
'parent_id': item.get('parentId'),
'name': item.get('name') or '',
'category_key': item.get('categoryKey') or '',
'sort_order': item.get('sortOrder') if item.get('sortOrder') is not None else 0,
'description': item.get('description') or '',
'is_builtin': bool(item.get('isBuiltin')),
'child_count': item.get('childCount') if item.get('childCount') is not None else 0,
'level': item.get('level') if item.get('level') is not None else 0,
'path': item.get('path') or '',
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16],
}
if include_children:
formatted['children'] = [_format_product_category_item(child) for child in (item.get('children') or [])]
return formatted
def _build_product_category_payload_for_java(data):
sort_order = data.get('sort_order') if 'sort_order' in data else data.get('sortOrder')
parent_id = data.get('parent_id') if 'parent_id' in data else data.get('parentId')
if parent_id == '':
parent_id = None
if sort_order == '':
sort_order = None
return {
'parentId': parent_id,
'name': (data.get('name') or '').strip(),
'sortOrder': sort_order,
'description': (data.get('description') or '').strip(),
}
@admin_api.route('/product-categories')
@login_required
def list_product_categories():
_ensure_product_category_schema()
_, _, denied = _ensure_product_category_access()
if denied:
return denied
keyword = (request.args.get('keyword') or '').strip()
query = f'?keyword={quote(keyword)}' if keyword else ''
result, error_response, status = _proxy_backend_java('GET', f'/api/admin/product-categories{query}')
if error_response is not None:
return error_response, status
payload = result.get('data') or {}
return jsonify({
'success': True,
'tree': [_format_product_category_item(item) for item in (payload.get('tree') or [])],
'items': [_format_product_category_item(item, include_children=False) for item in (payload.get('items') or [])],
'total': payload.get('total') or 0,
'page': payload.get('page') or 1,
'page_size': payload.get('pageSize') or 0,
'has_more': bool(payload.get('hasMore')),
})
@admin_api.route('/product-categories/children')
@login_required
def list_product_category_children():
_ensure_product_category_schema()
_, _, denied = _ensure_product_category_access()
if denied:
return denied
parent_id_raw = (request.args.get('parent_id') or request.args.get('parentId') or '').strip()
page = max(1, int(request.args.get('page', 1)))
page_size = min(100, max(1, int(request.args.get('page_size', request.args.get('pageSize', 20)))))
params = {
'page': page,
'pageSize': page_size,
}
if parent_id_raw:
params['parentId'] = parent_id_raw
result, error_response, status = _proxy_backend_java(
'GET',
'/api/admin/product-categories/children',
params=params,
)
if error_response is not None:
return error_response, status
payload = result.get('data') or {}
return jsonify({
'success': True,
'items': [_format_product_category_item(item, include_children=False) for item in (payload.get('items') or [])],
'total': payload.get('total') or 0,
'page': payload.get('page') or page,
'page_size': payload.get('pageSize') or page_size,
'has_more': bool(payload.get('hasMore')),
})
@admin_api.route('/product-categories/search')
@login_required
def search_product_categories():
_ensure_product_category_schema()
_, _, denied = _ensure_product_category_access()
if denied:
return denied
keyword = (request.args.get('keyword') or '').strip()
page = max(1, int(request.args.get('page', 1)))
page_size = min(100, max(1, int(request.args.get('page_size', request.args.get('pageSize', 20)))))
result, error_response, status = _proxy_backend_java(
'GET',
'/api/admin/product-categories/search',
params={
'keyword': keyword,
'page': page,
'pageSize': page_size,
},
)
if error_response is not None:
return error_response, status
payload = result.get('data') or {}
return jsonify({
'success': True,
'items': [_format_product_category_item(item, include_children=False) for item in (payload.get('items') or [])],
'total': payload.get('total') or 0,
'page': payload.get('page') or page,
'page_size': payload.get('pageSize') or page_size,
'has_more': bool(payload.get('hasMore')),
})
@admin_api.route('/product-category', methods=['POST'])
@login_required
def create_product_category():
_ensure_product_category_schema()
_, _, denied = _ensure_product_category_access()
if denied:
return denied
result, error_response, status = _proxy_backend_java(
'POST',
'/api/admin/product-category',
json_data=_build_product_category_payload_for_java(request.get_json() or {}),
)
if error_response is not None:
return error_response, status
return jsonify({
'success': True,
'msg': result.get('message') or '创建成功',
'item': _format_product_category_item(result.get('data') or {}, include_children=False),
})
@admin_api.route('/product-category/<int:item_id>', methods=['PUT'])
@login_required
def update_product_category(item_id):
_ensure_product_category_schema()
_, _, denied = _ensure_product_category_access()
if denied:
return denied
result, error_response, status = _proxy_backend_java(
'PUT',
f'/api/admin/product-category/{item_id}',
json_data=_build_product_category_payload_for_java(request.get_json() or {}),
)
if error_response is not None:
return error_response, status
return jsonify({
'success': True,
'msg': result.get('message') or '保存成功',
'item': _format_product_category_item(result.get('data') or {}, include_children=False),
})
@admin_api.route('/product-category/<int:item_id>', methods=['DELETE'])
@login_required
def delete_product_category(item_id):
_ensure_product_category_schema()
_, _, denied = _ensure_product_category_access()
if denied:
return denied
result, error_response, status = _proxy_backend_java('DELETE', f'/api/admin/product-category/{item_id}')
if error_response is not None:
return error_response, status
return jsonify({'success': True, 'msg': result.get('message') or '删除成功'})
@admin_api.route('/versions')
@admin_required
def list_versions():
@@ -1786,18 +2070,18 @@ def list_skip_price_asins():
group_id_raw = (request.args.get('group_id') or '').strip()
shop_name = (request.args.get('shop_name') or '').strip()
asin = (request.args.get('asin') or '').strip()
params = {'page': page, 'pageSize': page_size}
if current_row and current_row.get('id'):
params['operatorId'] = current_row.get('id')
params['superAdmin'] = 'true' if role == 'super_admin' else 'false'
params = {'page': page, 'page_size': page_size, 'pageSize': page_size}
params.update(_skip_price_asin_auth_params(role, current_row))
if group_id_raw:
try:
group_id = int(group_id_raw)
if group_id > 0:
params['group_id'] = group_id
params['groupId'] = group_id
except (TypeError, ValueError):
pass
if shop_name:
params['shop_name'] = shop_name
params['shopName'] = shop_name
if asin:
params['asin'] = asin
@@ -1839,6 +2123,64 @@ def list_skip_price_asins():
})
def _skip_price_asin_auth_params(role, current_row):
operator_id = current_row.get('id') if current_row else None
super_admin = 'true' if role == 'super_admin' else 'false'
return {
'operator_id': operator_id,
'operatorId': operator_id,
'super_admin': super_admin,
'superAdmin': super_admin,
}
@admin_api.route('/skip-price-asins/export')
@login_required
def export_skip_price_asins():
role, current_row, denied = _ensure_backend_menu_access('skip-price-asin')
if denied:
return denied
params = _skip_price_asin_auth_params(role, current_row)
group_id_raw = (request.args.get('group_id') or request.args.get('groupId') or '').strip()
shop_name = (request.args.get('shop_name') or request.args.get('shopName') or '').strip()
asin = (request.args.get('asin') or '').strip()
if group_id_raw:
params['group_id'] = group_id_raw
params['groupId'] = group_id_raw
if shop_name:
params['shop_name'] = shop_name
params['shopName'] = shop_name
if asin:
params['asin'] = asin
url = f"{backend_java_base_url}/api/admin/skip-price-asins/export"
try:
resp = _get_backend_java_session().get(url, params=params, timeout=60)
except requests.RequestException:
return jsonify({'success': False, 'error': 'backend-java 服务不可用'}), 502
if resp.status_code >= 400:
try:
data = resp.json()
error = data.get('message') or data.get('error') or '导出失败'
except ValueError:
error = '导出失败'
return jsonify({'success': False, 'error': error}), resp.status_code
headers = {}
disposition = resp.headers.get('Content-Disposition')
if disposition:
headers['Content-Disposition'] = disposition
return Response(
resp.content,
status=resp.status_code,
headers=headers,
content_type=resp.headers.get(
'Content-Type',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
),
)
@admin_api.route('/skip-price-asin', methods=['POST'])
@login_required
def create_skip_price_asin():
@@ -1872,10 +2214,7 @@ def create_skip_price_asin():
result, error_response, status = _proxy_backend_java(
'POST',
'/api/admin/skip-price-asins',
params={
'operatorId': current_row.get('id') if current_row else None,
'superAdmin': 'true' if role == 'super_admin' else 'false',
},
params=_skip_price_asin_auth_params(role, current_row),
json_data=payload,
)
if error_response is not None:
@@ -1914,10 +2253,7 @@ def delete_skip_price_asin_country(item_id, country):
result, error_response, status = _proxy_backend_java(
'DELETE',
f'/api/admin/skip-price-asins/{item_id}/countries/{country}',
params={
'operatorId': current_row.get('id') if current_row else None,
'superAdmin': 'true' if role == 'super_admin' else 'false',
},
params=_skip_price_asin_auth_params(role, current_row),
)
if error_response is not None:
return error_response, status
@@ -1957,10 +2293,7 @@ def update_skip_price_asin_country(item_id, country):
result, error_response, status = _proxy_backend_java(
'PUT',
f'/api/admin/skip-price-asins/{item_id}/countries/{country}',
params={
'operatorId': current_row.get('id') if current_row else None,
'superAdmin': 'true' if role == 'super_admin' else 'false',
},
params=_skip_price_asin_auth_params(role, current_row),
json_data=payload,
)
if error_response is not None:
@@ -2020,11 +2353,9 @@ def import_skip_price_asins():
group_id_raw = (request.form.get('group_id') or '').strip()
if not group_id_raw:
return jsonify({'success': False, 'error': '请先选择分组'})
params = {
'groupId': group_id_raw,
'operatorId': current_row.get('id') if current_row else None,
'superAdmin': 'true' if role == 'super_admin' else 'false',
}
params = _skip_price_asin_auth_params(role, current_row)
params['group_id'] = group_id_raw
params['groupId'] = group_id_raw
files = {
'file': (file_storage.filename, file_storage.stream, file_storage.mimetype or 'application/octet-stream')
}
@@ -2075,11 +2406,9 @@ def delete_import_skip_price_asins():
group_id_raw = (request.form.get('group_id') or '').strip()
if not group_id_raw:
return jsonify({'success': False, 'error': '请先选择分组'})
params = {
'groupId': group_id_raw,
'operatorId': current_row.get('id') if current_row else None,
'superAdmin': 'true' if role == 'super_admin' else 'false',
}
params = _skip_price_asin_auth_params(role, current_row)
params['group_id'] = group_id_raw
params['groupId'] = group_id_raw
files = {
'file': (file_storage.filename, file_storage.stream, file_storage.mimetype or 'application/octet-stream')
}
@@ -2159,6 +2488,54 @@ def list_query_asins():
})
@admin_api.route('/query-asins/export')
@login_required
def export_query_asins():
role, current_row, denied = _ensure_backend_menu_access('query-asin')
if denied:
return denied
params = {
'operatorId': current_row.get('id') if current_row else None,
'superAdmin': 'true' if role == 'super_admin' else 'false',
}
group_id_raw = (request.args.get('group_id') or request.args.get('groupId') or '').strip()
shop_name = (request.args.get('shop_name') or request.args.get('shopName') or '').strip()
asin = (request.args.get('asin') or '').strip()
if group_id_raw:
params['groupId'] = group_id_raw
if shop_name:
params['shopName'] = shop_name
if asin:
params['asin'] = asin
url = f"{backend_java_base_url}/api/admin/query-asins/export"
try:
resp = _get_backend_java_session().get(url, params=params, timeout=60)
except requests.RequestException:
return jsonify({'success': False, 'error': 'backend-java 服务不可用'}), 502
if resp.status_code >= 400:
try:
data = resp.json()
error = data.get('message') or data.get('error') or '导出失败'
except ValueError:
error = '导出失败'
return jsonify({'success': False, 'error': error}), resp.status_code
headers = {}
disposition = resp.headers.get('Content-Disposition')
if disposition:
headers['Content-Disposition'] = disposition
return Response(
resp.content,
status=resp.status_code,
headers=headers,
content_type=resp.headers.get(
'Content-Type',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
),
)
def _format_query_asin_import_progress(progress):
return {
'status': progress.get('status') or 'pending',
@@ -2364,3 +2741,46 @@ def update_query_asin_country(item_id, country):
'msg': result.get('message') or '保存成功',
'item': _format_query_asin_item(result.get('data') or {}),
})
def _get_local_user_column_ids(user_id, menu_type=None):
conn = get_db()
try:
with conn.cursor() as cur:
sql = """
SELECT c.id
FROM user_column_permission ucp
INNER JOIN columns c ON c.id = ucp.column_id
WHERE ucp.user_id = %s
"""
params = [user_id]
if menu_type:
sql += " AND c.menu_type = %s"
params.append(menu_type)
sql += " ORDER BY c.sort_order ASC, c.id ASC"
cur.execute(sql, params)
rows = cur.fetchall()
return [int(row['id']) for row in rows if row.get('id') is not None]
finally:
conn.close()
def _get_local_user_column_permission_items(user_id, menu_type=None):
conn = get_db()
try:
with conn.cursor() as cur:
sql = """
SELECT c.id, c.name, c.column_key, c.menu_type, c.route_path, c.sort_order, c.created_at
FROM user_column_permission ucp
INNER JOIN columns c ON c.id = ucp.column_id
WHERE ucp.user_id = %s
"""
params = [user_id]
if menu_type:
sql += " AND c.menu_type = %s"
params.append(menu_type)
sql += " ORDER BY c.sort_order ASC, c.id ASC"
cur.execute(sql, params)
return cur.fetchall()
finally:
conn.close()

View File

@@ -1,14 +1,48 @@
import os
from pathlib import Path
try:
from dotenv import load_dotenv
except ImportError:
def load_dotenv(*args, **kwargs):
return False
base_url = "https://api.coze.cn/v1"
coze_token = "sat_12nW40INoJxArrDXbY4lSCoudbqkOYTcphC99BP2efWyzxmsk4q81WDX3ezWgqZ5"
workflow_id = "7608812635877900322"
STITCH_WORKFLOW_ID = "7608813873483300907"
BASE_DIR = Path(__file__).resolve().parent
LOADED_ENV_FILES = []
def _load_env_files():
candidates = (
BASE_DIR / ".env",
BASE_DIR.parent / "app" / ".env",
BASE_DIR.parent / ".env",
)
for env_path in candidates:
if env_path.is_file():
load_dotenv(dotenv_path=env_path, override=False)
LOADED_ENV_FILES.append(str(env_path))
def _get_env(*names, default=None):
for name in names:
value = os.getenv(name)
if value not in (None, ""):
return value, name
return default, "config.default"
_load_env_files()
# MySQL 配置
mysql_host = "8.136.19.173"
mysql_user = "aiimage"
mysql_password = "WTFrb5y6hNLz6hNy" # 请修改为您的数据库密码
mysql_database = "aiimage"
mysql_host, mysql_host_source = _get_env("mysql_host", "MYSQL_HOST", default="47.110.241.161")
mysql_user, mysql_user_source = _get_env("mysql_user", "MYSQL_USER", default="aiimage")
mysql_password, mysql_password_source = _get_env("mysql_password", "MYSQL_PASSWORD", default="WTFrb5y6hNLz6hNy")
mysql_database, mysql_database_source = _get_env("mysql_database", "MYSQL_DATABASE", default="aiimage")
cache_path = "./user_data"
@@ -22,20 +56,20 @@ bucket_path = "nanri-image/"
file_url_pre = f"https://{bucket}.oss-cn-hangzhou.aliyuncs.com/"
import os
backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://127.0.0.1:18080').rstrip('/')
# backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://8.136.19.173:18080').rstrip('/')
# backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://47.111.163.154:18080').rstrip('/')
# backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://121.196.149.225:18080').rstrip('/')
os.environ['OSS_ACCESS_KEY_ID'] = accessKeyId
os.environ['OSS_ACCESS_KEY_SECRET'] = accessKeySecret
os.environ['SECRET_KEY'] = "ddffc7c1d02121d9554d7b080b2511b6"
backend_java_base_url, backend_java_base_url_source = _get_env(
"java_api_base",
"BACKEND_JAVA_BASE_URL",
default="http://127.0.0.1:18080",
# default="http://47.111.163.154:18080",
)
backend_java_base_url = backend_java_base_url.rstrip("/")
os.environ["OSS_ACCESS_KEY_ID"] = accessKeyId
os.environ["OSS_ACCESS_KEY_SECRET"] = accessKeySecret
os.environ["SECRET_KEY"] = "ddffc7c1d02121d9554d7b080b2511b6"
debug = True
version = "1.0.0"
APP_UPDATE_URL = ""
os.environ['APP_VERSION'] = version
os.environ['APP_UPDATE_URL'] = version
os.environ["APP_VERSION"] = version
os.environ["APP_UPDATE_URL"] = version

View File

@@ -44,6 +44,7 @@ PyQt5==5.15.11
PyQt5-Qt5==5.15.2
PyQt5_sip==12.17.1
python-dateutil==2.9.0.post0
python-dotenv==1.0.1
pythonnet==3.0.5
pytz==2026.1.post1
pywebview==6.1

3848
backend/static/admin.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -10,16 +10,32 @@ try:
from config import mysql_user as config_mysql_user
from config import mysql_password as config_mysql_password
from config import mysql_database as config_mysql_database
from config import mysql_host_source as config_mysql_host_source
from config import mysql_user_source as config_mysql_user_source
from config import mysql_database_source as config_mysql_database_source
except ImportError:
config_mysql_host = 'localhost'
config_mysql_user = 'root'
config_mysql_password = ''
config_mysql_database = 'maixiang_ai'
config_mysql_host_source = 'fallback.default'
config_mysql_user_source = 'fallback.default'
config_mysql_database_source = 'fallback.default'
mysql_host = os.environ.get('MYSQL_HOST', config_mysql_host)
mysql_user = os.environ.get('MYSQL_USER', config_mysql_user)
mysql_password = os.environ.get('MYSQL_PASSWORD', config_mysql_password)
mysql_database = os.environ.get('MYSQL_DATABASE', config_mysql_database)
mysql_host = config_mysql_host
mysql_user = config_mysql_user
mysql_password = config_mysql_password
mysql_database = config_mysql_database
mysql_host_source = config_mysql_host_source
mysql_user_source = config_mysql_user_source
mysql_database_source = config_mysql_database_source
def describe_db_target():
return (
f"{mysql_user}@{mysql_host}/{mysql_database} "
f"(host={mysql_host_source}, user={mysql_user_source}, database={mysql_database_source})"
)
def get_db():

File diff suppressed because it is too large Load Diff

View File

@@ -8,33 +8,33 @@
<el-dialog v-model="dialogVisible" width="560px" class="secret-settings-dialog" :append-to-body="true">
<template #header>
<div class="dialog-header">
<div class="dialog-title">设置</div>
<div class="dialog-subtitle">按当前登录用户保存在本机可分别管理外观专利和货源查询密钥</div>
<div class="dialog-title">密钥设置</div>
<div class="dialog-subtitle">按当前登录用户保存在本机外观专利和货源查询共用同一个 Coze 接口密钥</div>
</div>
</template>
<div class="secret-settings-body">
<section v-for="item in moduleStates" :key="item.moduleKey" class="secret-card">
<section class="secret-card">
<div class="secret-card-head">
<div>
<div class="secret-card-title">{{ item.title }}</div>
<div class="secret-card-desc">{{ item.description }}</div>
<div class="secret-card-title">通用 Coze 密钥</div>
<div class="secret-card-desc">用于外观专利检测和货源查询</div>
</div>
<button
v-if="item.exists"
v-if="secretState.exists"
type="button"
class="link-danger"
@click="clearSecret(item.moduleKey)"
@click="clearSecret"
>
清空
</button>
</div>
<input
v-model="item.value"
v-model="secretState.value"
class="secret-input"
type="password"
:placeholder="`请输入${item.title}密钥`"
placeholder="请输入 Coze 接口密钥"
autocomplete="off"
spellcheck="false"
/>
@@ -43,14 +43,14 @@
<div class="retention-label">保留时长</div>
<div class="retention-options">
<label v-for="option in retentionOptions" :key="option.value" class="retention-option">
<input v-model="item.retention" type="radio" :value="option.value" />
<input v-model="secretState.retention" type="radio" :value="option.value" />
<span>{{ option.label }}</span>
</label>
</div>
</div>
<div class="secret-meta">
<span v-if="item.exists">{{ formatRetentionText(item.retention, item.expiresAt) }}</span>
<span v-if="secretState.exists">{{ formatRetentionText(secretState.retention, secretState.expiresAt) }}</span>
<span v-else>当前未保存</span>
</div>
</section>
@@ -73,14 +73,10 @@ import {
clearStoredApiSecret,
getStoredApiSecretSnapshot,
saveStoredApiSecret,
type ApiSecretModuleKey,
type ApiSecretRetention,
} from '@/shared/utils/api-secret-store'
type ModuleState = {
moduleKey: ApiSecretModuleKey
title: string
description: string
type SecretState = {
value: string
retention: ApiSecretRetention
expiresAt: number | null
@@ -90,25 +86,18 @@ type ModuleState = {
const dialogVisible = ref(false)
const retentionOptions: Array<{ value: ApiSecretRetention; label: string }> = [
{ value: 'session', label: '本次打开有效' },
{ value: '1d', label: '1天' },
{ value: '7d', label: '7天' },
{ value: '30d', label: '30天' },
{ value: 'session', label: '本次打开有效' },
{ value: '1d', label: '1 天' },
{ value: '7d', label: '7 天' },
{ value: '30d', label: '30 天' },
{ value: 'forever', label: '长期保留' },
]
const moduleStates = ref<ModuleState[]>([])
const secretState = ref<SecretState>(emptySecretState())
function buildModuleState(
moduleKey: ApiSecretModuleKey,
title: string,
description: string,
): ModuleState {
const snapshot = getStoredApiSecretSnapshot(moduleKey)
return {
moduleKey,
title,
description,
function loadStates() {
const snapshot = getStoredApiSecretSnapshot()
secretState.value = {
value: snapshot.value,
retention: snapshot.retention,
expiresAt: snapshot.expiresAt,
@@ -116,11 +105,13 @@ function buildModuleState(
}
}
function loadStates() {
moduleStates.value = [
buildModuleState('appearance-patent', '外观专利', '用于外观专利检测的 Coze 接口密钥。'),
buildModuleState('similar-asin', '货源查询', '用于货源查询的 Coze 接口密钥。'),
]
function emptySecretState(): SecretState {
return {
value: '',
retention: 'session',
expiresAt: null,
exists: false,
}
}
function formatRetentionText(retention: ApiSecretRetention, expiresAt: number | null) {
@@ -136,16 +127,14 @@ function formatRetentionText(retention: ApiSecretRetention, expiresAt: number |
return `有效期至 ${year}-${month}-${day} ${hour}:${minute}`
}
function clearSecret(moduleKey: ApiSecretModuleKey) {
clearStoredApiSecret(moduleKey)
function clearSecret() {
clearStoredApiSecret()
loadStates()
ElMessage.success('已清空密钥')
}
function saveAll() {
for (const item of moduleStates.value) {
saveStoredApiSecret(item.moduleKey, item.value, item.retention)
}
saveStoredApiSecret(secretState.value.value, secretState.value.retention)
loadStates()
dialogVisible.value = false
ElMessage.success('密钥设置已保存')
@@ -192,6 +181,45 @@ loadStates()
line-height: 1;
}
:global(.secret-settings-dialog) {
--el-dialog-bg-color: #171b20;
--el-dialog-padding-primary: 20px;
overflow: hidden;
border: 1px solid #2c3540;
border-radius: 14px;
background: #171b20;
box-shadow: 0 24px 70px rgba(0, 0, 0, .52);
}
:global(.secret-settings-dialog .el-dialog__header) {
padding: 22px 22px 12px;
margin: 0;
background: #171b20;
}
:global(.secret-settings-dialog .el-dialog__body) {
padding: 12px 22px 16px;
background: #171b20;
}
:global(.secret-settings-dialog .el-dialog__footer) {
padding: 0 22px 22px;
background: #171b20;
}
:global(.secret-settings-dialog .el-dialog__headerbtn) {
top: 18px;
right: 18px;
}
:global(.secret-settings-dialog .el-dialog__close) {
color: #8793a0;
}
:global(.secret-settings-dialog .el-dialog__close:hover) {
color: #d8e3ee;
}
.dialog-header {
display: flex;
flex-direction: column;
@@ -217,10 +245,10 @@ loadStates()
}
.secret-card {
padding: 16px;
border: 1px solid #2f363f;
border-radius: 12px;
background: #22272d;
padding: 18px;
border: 1px solid #313b46;
border-radius: 10px;
background: #20252b;
}
.secret-card-head {
@@ -331,7 +359,11 @@ loadStates()
}
.footer-btn-primary {
background: #5aa2ea;
background: #4f8fda;
color: #fff;
}
.footer-btn-primary:hover {
background: #67a6ed;
}
</style>

View File

@@ -18,10 +18,8 @@
</div>
<div class="prompt-card">
<div class="section-title">AI 提示词</div>
<textarea v-model="aiPrompt" class="prompt-input" rows="8" placeholder="可选,留空时使用下方默认提示词" />
<div class="prompt-default-label">留空时默认使用以下提示词</div>
<div class="prompt-preview">{{ defaultAiPrompt }}</div>
<div class="section-title">新增条件要求</div>
<textarea v-model="aiPrompt" class="prompt-input" rows="8" placeholder="可选,仅在激活任务时传递这里填写的 prompt" />
</div>
<div class="run-row">
@@ -32,7 +30,7 @@
{{ pushing ? '推送中...' : '推送到 Python 队列' }}
</button>
</div>
<p class="loading-msg">Python 回传字段按 asin国家url标题提交后端接收分片后 10 一批调用 Coze结果区展示真实 Coze 批次进度</p>
<p class="loading-msg">Python 回传字段按 asin国家url标题提交后端接收分片后累计 50 条调用 Coze当前任务区展示 Coze 回流和结果组装进度</p>
<div v-if="visibleTaskSummary" class="parse-card">
<div>任务 ID{{ visibleTaskSummary.taskId }}</div>
@@ -74,7 +72,19 @@
<div class="left">
<span class="id">{{ item.sourceFilename || '外观专利检测' }}</span>
<div class="files">任务 ID{{ item.taskId }}</div>
<div class="files">开始时间{{ formatDateTime(item.startedAt || item.createdAt) }}</div>
<div class="files">结束时间{{ formatDateTime(item.finishedAt) }}</div>
<div class="files">行数{{ item.rowCount ?? '-' }}</div>
<div v-if="pendingResultHint(item)" class="files result-hint">{{ pendingResultHint(item) }}</div>
<div v-if="showFileProgress(item)" class="file-progress">
<div class="file-progress-meta">
<span>{{ displayFileProgressMessage(item, '处理中') }}</span>
<span>{{ fileProgressPercent(item) }}%</span>
</div>
<div class="file-progress-track">
<div class="file-progress-bar" :style="{ width: `${fileProgressPercent(item)}%` }"></div>
</div>
</div>
</div>
<div class="task-right">
<span class="status running">{{ statusText(item) }}</span>
@@ -94,21 +104,20 @@
<div class="left">
<span class="id">{{ item.sourceFilename || '外观专利检测' }}</span>
<div class="files">任务 ID{{ item.taskId ?? '-' }}</div>
<div class="files">开始时间{{ formatDateTime(item.startedAt || item.createdAt) }}</div>
<div class="files">结束时间{{ formatDateTime(item.finishedAt) }}</div>
<div v-if="item.resultFilename" class="files">
{{ item.resultFilename || '下载结果' }}
</div>
<div v-if="pendingResultHint(item)" class="files result-hint">{{ pendingResultHint(item) }}</div>
<div v-if="showFileProgress(item)" class="file-progress">
<div class="file-progress-meta">
<span>{{ item.fileProgressMessage || '结果生成中' }}</span>
<span>{{ displayFileProgressMessage(item, '结果生成中') }}</span>
<span>{{ fileProgressPercent(item) }}%</span>
</div>
<div class="file-progress-track">
<div class="file-progress-bar" :style="{ width: `${fileProgressPercent(item)}%` }"></div>
</div>
<div class="file-progress-count">
{{ item.fileProgressCurrent || 0 }}/{{ item.fileProgressTotal || 0 }}
</div>
</div>
<div v-if="item.error" class="files">错误{{ item.error }}</div>
</div>
@@ -185,6 +194,7 @@ const dashboard = ref<AppearancePatentDashboardVo>({
failedTaskCount: 0,
})
const historyItems = ref<AppearancePatentHistoryItem[]>([])
const liveProgressItems = ref<Record<number, AppearancePatentHistoryItem>>({})
const visibleTaskSummary = computed(() => parseResult.value || queuedTaskSummary.value)
const pendingParseItem = computed<AppearancePatentHistoryItem | null>(() => {
@@ -201,8 +211,25 @@ const pendingParseItem = computed<AppearancePatentHistoryItem | null>(() => {
}
})
const currentItems = computed(() => {
const runningItems = historyItems.value.filter((i) => i.taskId != null && pollingTaskIds.value.includes(i.taskId))
return pendingParseItem.value ? [pendingParseItem.value, ...runningItems] : runningItems
const activeIds = new Set([...pollingTaskIds.value, ...pendingFileTaskIds.value])
const activeItems = historyItems.value
.filter((item) => {
const status = normalizeTaskStatus(item)
return item.taskId != null && (activeIds.has(item.taskId) || status === 'RUNNING' || status === 'PENDING')
})
.map((item) => mergeCurrentTaskItem(item))
for (const [taskIdText, liveItem] of Object.entries(liveProgressItems.value)) {
const taskId = Number(taskIdText)
if (!Number.isFinite(taskId) || taskId <= 0) continue
if (!activeIds.has(taskId) && normalizeTaskStatus(liveItem) !== 'RUNNING' && normalizeTaskStatus(liveItem) !== 'PENDING') {
continue
}
if (activeItems.some((item) => item.taskId === taskId)) continue
activeItems.push(mergeCurrentTaskItem(liveItem))
}
if (!pendingParseItem.value) return activeItems
const exists = activeItems.some((item) => item.taskId === pendingParseItem.value?.taskId)
return exists ? activeItems : [pendingParseItem.value, ...activeItems]
})
const historyOnlyItems = computed(() =>
historyItems.value.filter((i) => {
@@ -211,12 +238,32 @@ const historyOnlyItems = computed(() =>
}),
)
function mergeCurrentTaskItem(item: AppearancePatentHistoryItem) {
if (item.taskId == null) return item
const liveItem = liveProgressItems.value[item.taskId]
if (!liveItem) return item
return {
...item,
...liveItem,
sourceFilename: liveItem.sourceFilename || item.sourceFilename,
resultFilename: liveItem.resultFilename || item.resultFilename,
rowCount: liveItem.rowCount ?? item.rowCount,
createdAt: liveItem.createdAt || item.createdAt,
}
}
function effectiveAiPrompt() {
return aiPrompt.value.trim() || defaultAiPrompt
const extraPrompt = aiPrompt.value.trim()
if (!extraPrompt) return defaultAiPrompt
return `${defaultAiPrompt}\n\n新增条件要求\n${extraPrompt}`
}
function queueAiPrompt() {
return aiPrompt.value.trim()
}
function effectiveCozeApiKey() {
return getStoredApiSecret('appearance-patent').trim()
return getStoredApiSecret().trim()
}
function maskSecret(secret: string) {
@@ -275,6 +322,19 @@ function loadPollingIds() {
}
}
function formatDateTime(value?: string) {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
async function uploadAppearancePathsToJava(paths: Array<string | BrandExpandFolderItem>) {
const api = getPywebviewApi()
if (!api?.upload_file_to_java) {
@@ -388,7 +448,7 @@ async function pushToPythonQueue() {
ts: Date.now(),
data: {
taskId,
prompt: parsedPayload.aiPrompt || currentParseResult.aiPrompt || effectiveAiPrompt(),
prompt: queueAiPrompt(),
api_key: effectiveCozeApiKey(),
sourceFileCount: currentParseResult.sourceFileCount || 0,
totalRows: currentParseResult.totalRows || 0,
@@ -447,6 +507,11 @@ function addPollingTask(taskId: number) {
function removePollingTask(taskId: number) {
pollingTaskIds.value = pollingTaskIds.value.filter((id) => id !== taskId)
if (!pendingFileTaskIds.value.includes(taskId)) {
const next = { ...liveProgressItems.value }
delete next[taskId]
liveProgressItems.value = next
}
savePollingIds()
}
@@ -460,6 +525,11 @@ function addPendingFileTask(taskId: number) {
function removePendingFileTask(taskId: number) {
pendingFileTaskIds.value = pendingFileTaskIds.value.filter((id) => id !== taskId)
if (!pollingTaskIds.value.includes(taskId)) {
const next = { ...liveProgressItems.value }
delete next[taskId]
liveProgressItems.value = next
}
savePollingIds()
}
@@ -512,7 +582,13 @@ async function refreshTaskProgress() {
const task = detail.task
if (!task?.id) continue
const item = detail.items?.[0]
if (item) mergeHistoryItem(item)
if (item) {
liveProgressItems.value = {
...liveProgressItems.value,
[task.id]: item,
}
mergeHistoryItem(item)
}
if (task.status === 'SUCCESS' || task.status === 'FAILED') {
removePollingTask(task.id)
shouldRefreshDashboard = true
@@ -594,6 +670,16 @@ function seedPendingFileTasksFromHistory() {
if (pendingFileTaskIds.value.length) ensurePolling()
}
function seedRunningTasksFromHistory() {
const ids = historyItems.value
.filter((item) => item.taskId != null && normalizeTaskStatus(item) === 'RUNNING')
.map((item) => item.taskId as number)
if (!ids.length) return
pollingTaskIds.value = Array.from(new Set([...pollingTaskIds.value, ...ids]))
savePollingIds()
ensurePolling()
}
async function loadDashboard() {
dashboard.value = await getAppearancePatentDashboard()
}
@@ -604,7 +690,7 @@ async function loadHistory(options: { force?: boolean } = {}) {
if (historyInFlight) return historyInFlight
historyInFlight = getAppearancePatentHistory()
.then((res) => {
historyItems.value = res.items || []
historyItems.value = mergeHistoryItemsPreservingLiveProgress(historyItems.value, res.items || [])
lastHistoryLoadedAt = Date.now()
})
.finally(() => {
@@ -613,6 +699,66 @@ async function loadHistory(options: { force?: boolean } = {}) {
return historyInFlight
}
function mergeHistoryItemsPreservingLiveProgress(
previousItems: AppearancePatentHistoryItem[],
incomingItems: AppearancePatentHistoryItem[],
) {
const previousByTaskId = new Map<number, AppearancePatentHistoryItem>()
const previousByResultId = new Map<number, AppearancePatentHistoryItem>()
for (const item of previousItems || []) {
if (item.taskId != null) previousByTaskId.set(item.taskId, item)
if (item.resultId != null) previousByResultId.set(item.resultId, item)
}
return (incomingItems || []).map((incoming) => {
const previous = incoming.resultId != null
? previousByResultId.get(incoming.resultId)
: incoming.taskId != null
? previousByTaskId.get(incoming.taskId)
: undefined
return mergeHistoryItemPreservingLiveProgress(previous, incoming)
})
}
function mergeHistoryItemPreservingLiveProgress(
previous: AppearancePatentHistoryItem | undefined,
incoming: AppearancePatentHistoryItem,
) {
if (!previous) return incoming
const shouldPreserveLiveProgress =
(normalizeTaskStatus(previous) === 'RUNNING' || isResultPreparing(previous) || showFileProgress(previous))
&& !showFileProgress(incoming)
&& !canDownload(incoming)
if (!shouldPreserveLiveProgress) {
return incoming
}
return {
...incoming,
fileJobId: incoming.fileJobId ?? previous.fileJobId,
fileStatus: incoming.fileStatus || previous.fileStatus,
fileError: incoming.fileError || previous.fileError,
fileReady: incoming.fileReady || previous.fileReady,
fileProgressPercent: hasMeaningfulProgressValue(incoming.fileProgressPercent)
? incoming.fileProgressPercent
: previous.fileProgressPercent,
fileProgressCurrent: hasMeaningfulProgressValue(incoming.fileProgressCurrent)
? incoming.fileProgressCurrent
: previous.fileProgressCurrent,
fileProgressTotal: hasMeaningfulProgressValue(incoming.fileProgressTotal)
? incoming.fileProgressTotal
: previous.fileProgressTotal,
fileProgressMessage: (incoming.fileProgressMessage || '').trim()
? incoming.fileProgressMessage
: previous.fileProgressMessage,
}
}
function hasMeaningfulProgressValue(value: unknown) {
const num = Number(value)
return Number.isFinite(num) && num > 0
}
function statusText(item: AppearancePatentHistoryItem) {
const status = normalizeTaskStatus(item)
if (status === 'RUNNING') return '执行中'
@@ -653,6 +799,9 @@ function pendingResultHint(item: AppearancePatentHistoryItem) {
if (isResultBuildFailed(item)) {
return item.fileError || '结果文件生成失败,请稍后重试或检查后端日志'
}
if (showFileProgress(item)) {
return ''
}
if (!isResultPreparing(item)) return ''
const fileStatus = (item.fileStatus || '').toUpperCase()
if (fileStatus === 'RUNNING') {
@@ -670,12 +819,69 @@ function canDownload(item: AppearancePatentHistoryItem) {
function fileProgressPercent(item: AppearancePatentHistoryItem) {
const percent = Number(item.fileProgressPercent || 0)
if (!Number.isFinite(percent)) return 0
return Math.max(0, Math.min(100, Math.round(percent)))
if (Number.isFinite(percent) && percent > 0) {
return Math.max(0, Math.min(100, Math.round(percent)))
}
if (shouldShowFallbackJobProgress(item)) {
return activeFileJobStatus(item) === 'PENDING' ? 6 : 10
}
return 0
}
function displayFileProgressMessage(item: AppearancePatentHistoryItem, fallback: string) {
const message = (item.fileProgressMessage || '').trim()
if (!message) return fallback
return translateFileProgressMessage(message)
}
function translateFileProgressMessage(message: string) {
const submittedMatch = message.match(/^Coze submitted (\d+) batches, completed (\d+), waiting for results$/)
if (submittedMatch) {
return `已提交 Coze ${submittedMatch[1]} 批,已完成 ${submittedMatch[2]} 批,等待结果回流`
}
const completedMatch = message.match(/^Coze completed (\d+) batches, waiting for remaining Python data$/)
if (completedMatch) {
return `Coze 已完成 ${completedMatch[1]} 批,等待 Python 继续回传数据`
}
const translations: Record<string, string> = {
'Receiving Python data, waiting for 50 rows before Coze': '正在接收 Python 数据,累计 50 条后提交 Coze',
'Submitting Coze': '正在提交 Coze',
'Coze submitted, waiting for result': 'Coze 已提交,等待结果回流',
'Waiting for Python upload': '等待 Python 继续回传数据',
'Coze results ready, assembling xlsx': 'Coze 结果已回流,正在组装 xlsx',
'Assembling xlsx': '正在组装 xlsx',
'Uploading result file': '正在上传结果文件',
'Result file generated': '结果文件已生成',
}
return translations[message] || message
}
function showFileProgress(item: AppearancePatentHistoryItem) {
return isResultPreparing(item) && (item.fileProgressTotal || 0) > 0
const status = normalizeTaskStatus(item)
return (status === 'RUNNING' || isResultPreparing(item)) && (hasExplicitFileProgress(item) || shouldShowFallbackJobProgress(item))
}
function hasExplicitFileProgress(item: AppearancePatentHistoryItem) {
return hasFileProgressCount(item)
|| Number(item.fileProgressPercent || 0) > 0
|| Boolean((item.fileProgressMessage || '').trim())
}
function hasFileProgressCount(item: AppearancePatentHistoryItem) {
return (item.fileProgressTotal || 0) > 0
}
function activeFileJobStatus(item: AppearancePatentHistoryItem) {
return (item.fileStatus || '').toUpperCase()
}
function hasActiveFileJob(item: AppearancePatentHistoryItem) {
const fileStatus = activeFileJobStatus(item)
return item.fileJobId != null || fileStatus === 'PENDING' || fileStatus === 'RUNNING'
}
function shouldShowFallbackJobProgress(item: AppearancePatentHistoryItem) {
return hasActiveFileJob(item) && !hasExplicitFileProgress(item)
}
async function downloadResult(item: AppearancePatentHistoryItem) {
@@ -717,6 +923,7 @@ onMounted(async () => {
loadDashboard().catch(() => undefined),
loadHistory().catch(() => undefined),
])
seedRunningTasksFromHistory()
seedPendingFileTasksFromHistory()
if (pollingTaskIds.value.length || pendingFileTaskIds.value.length) ensurePolling()
})
@@ -751,8 +958,6 @@ onUnmounted(() => {
.prompt-input { width: 100%; box-sizing: border-box; resize: vertical; min-height: 180px; padding: 10px 12px; border: 1px solid #333; border-radius: 8px; background: #202020; color: #d8d8d8; font-size: 12px; line-height: 1.6; outline: none; }
.secret-input { width: 100%; box-sizing: border-box; height: 38px; padding: 0 12px; border: 1px solid #333; border-radius: 8px; background: #202020; color: #d8d8d8; font-size: 12px; outline: none; }
.prompt-input:focus, .secret-input:focus { border-color: #3498db; }
.prompt-default-label { margin-top: 10px; color: #8d8d8d; font-size: 12px; }
.prompt-preview { margin-top: 10px; padding: 12px; border: 1px solid #2a2a2a; border-radius: 8px; background: #202020; color: #9ea7b3; font-size: 12px; line-height: 1.6; white-space: pre-wrap; }
.parse-card, .queue-payload { margin-top: 14px; padding: 12px; border: 1px solid #2a2a2a; border-radius: 8px; background: #202020; color: #b8c1cc; font-size: 12px; }
.queue-payload { max-height: 220px; overflow: auto; color: #8fd3ff; white-space: pre-wrap; }
.panel-header { padding: 16px 20px; border-bottom: 1px solid #2a2a2a; font-size: 15px; font-weight: 600; color: #ddd; }
@@ -780,7 +985,6 @@ onUnmounted(() => {
.file-progress-meta { display: flex; justify-content: space-between; gap: 12px; color: #d8c278; font-size: 12px; }
.file-progress-track { margin-top: 5px; height: 8px; border-radius: 999px; overflow: hidden; background: #303030; border: 1px solid #3b3b3b; }
.file-progress-bar { height: 100%; border-radius: inherit; background: linear-gradient(90deg, #4aa3ff, #f0c75e); transition: width .25s ease; }
.file-progress-count { margin-top: 4px; color: #858585; font-size: 11px; }
.download { padding: 6px 10px; color: #d6ecff; background: rgba(52, 152, 219, .18); }
.btn-delete { padding: 6px 10px; color: #ff8f8f; background: rgba(231, 76, 60, .12); }
@media (max-width: 1100px) {

View File

@@ -209,6 +209,9 @@
<div v-if="item.platform" class="files">
平台: {{ item.platform }}
</div>
<div class="files">
开始时间: {{ formatDateTime(taskStartTime(item.taskId)) }}
</div>
<div class="files">
创建时间: {{ formatDateTime(item.createdAt) }}
</div>
@@ -260,6 +263,9 @@
<div v-if="item.platform" class="files">
平台: {{ item.platform }}
</div>
<div class="files">
开始时间: {{ formatDateTime(taskStartTime(item.taskId)) }}
</div>
<div class="files">
创建时间: {{ formatDateTime(item.createdAt) }}
</div>
@@ -358,6 +364,7 @@ const activeTaskId = ref<number | null>(null);
const activeQueueItem = ref<QueryAsinShopQueueItem | null>(null);
const queueWorkerRunning = ref(false);
const autoQueueEnabled = ref(false);
const taskStartTimes = ref<Record<number, string>>({});
let historyPollTimer: number | null = null;
const timers = createCategorizedTimers("query-asin");
@@ -392,6 +399,10 @@ function queueStateStorageKey() {
return `query-asin:queue-state:${uidForStorage()}`;
}
function taskStartTimeStorageKey() {
return `query-asin:start-times:${uidForStorage()}`;
}
function rowKeyForMatch(row: QueryAsinShopQueueItem) {
return `${(row.shopName || "").trim()}::${row.shopId || ""}`;
}
@@ -600,6 +611,15 @@ function saveQueueState() {
window.localStorage.setItem(queueStateStorageKey(), JSON.stringify(payload));
}
function saveTaskStartTimes() {
if (typeof window === "undefined") return;
if (!Object.keys(taskStartTimes.value).length) {
window.localStorage.removeItem(taskStartTimeStorageKey());
return;
}
window.localStorage.setItem(taskStartTimeStorageKey(), JSON.stringify(taskStartTimes.value));
}
function loadQueueState() {
try {
const raw =
@@ -622,6 +642,48 @@ function loadQueueState() {
}
}
function loadTaskStartTimes() {
try {
const raw =
typeof window !== "undefined"
? window.localStorage.getItem(taskStartTimeStorageKey())
: null;
const parsed = raw ? JSON.parse(raw) : {};
const next: Record<number, string> = {};
for (const [taskId, value] of Object.entries(parsed || {})) {
const numericTaskId = Number(taskId);
if (!Number.isFinite(numericTaskId) || numericTaskId <= 0) continue;
if (typeof value !== "string" || !value.trim()) continue;
next[numericTaskId] = value;
}
taskStartTimes.value = next;
} catch {
taskStartTimes.value = {};
}
}
function setTaskStartTime(taskId: number, startedAt = new Date().toISOString()) {
if (!Number.isFinite(taskId) || taskId <= 0) return;
taskStartTimes.value = {
...taskStartTimes.value,
[taskId]: startedAt,
};
saveTaskStartTimes();
}
function clearTaskStartTime(taskId?: number | null) {
if (!taskId || !(taskId in taskStartTimes.value)) return;
const next = { ...taskStartTimes.value };
delete next[taskId];
taskStartTimes.value = next;
saveTaskStartTimes();
}
function taskStartTime(taskId?: number | null) {
if (!taskId) return "";
return taskStartTimes.value[taskId] || "";
}
function clearActiveQueueTask() {
activeTaskId.value = null;
activeQueueItem.value = null;
@@ -1003,6 +1065,7 @@ async function processQueue() {
continue;
}
setTaskStartTime(created.taskId);
queuePushResult.value =
pendingQueue.value.length > 0
? `任务 ${created.taskId} 已入队,等待完成后自动继续下一个(剩余 ${pendingQueue.value.length} 条)`
@@ -1064,8 +1127,10 @@ async function deleteTaskRecord(item: QueryAsinHistoryItem) {
if (activeTaskId.value === item.taskId) {
clearActiveQueueTask();
}
clearTaskStartTime(item.taskId);
} else if (item.resultId) {
await deleteQueryAsinHistory(item.resultId);
clearTaskStartTime(item.taskId);
} else {
throw new Error("缺少可删除的任务标识");
}
@@ -1077,6 +1142,7 @@ async function deleteTaskRecord(item: QueryAsinHistoryItem) {
if (item.taskId && activeTaskId.value === item.taskId) {
clearActiveQueueTask();
}
clearTaskStartTime(item.taskId);
removeHistoryItemLocally(item);
await loadCandidates();
resetQueueWorkerIfIdle();
@@ -1090,6 +1156,7 @@ async function deleteTaskRecord(item: QueryAsinHistoryItem) {
onMounted(async () => {
loadMatchedItems();
loadQueueState();
loadTaskStartTimes();
await Promise.all([loadCandidates(), loadDashboard(), loadHistory()]);
reconcileActiveQueueTaskWithHistory();

View File

@@ -291,7 +291,9 @@ function sanitizeSchedulePickerValues(values: string[], ensureOne: boolean) {
if (!deduped.length && ensureOne) return [getDefaultScheduleValue()]
return deduped
}
function buildQueuePayload(taskId: number, item: Pick<ShopMatchHistoryItem, 'shopName' | 'shopId' | 'platform' | 'companyName' | 'matchStatus' | 'matchMessage'>, countryCodes: string[], stageIndex?: number, finalStage = true) { return { type: 'shop-match-run', ts: Date.now(), data: { taskId, user_id: currentUserId(), items: [{ shopName: item.shopName, shopId: item.shopId, platform: item.platform, companyName: item.companyName, matched: true, matchStatus: item.matchStatus, matchMessage: item.matchMessage }], country_codes: [...countryCodes], risk_listing_filter: shopMatchListingFilter.value, stage_index: stageIndex, final_stage: finalStage } } }
function readSkipAsinsByCountry(item: ShopMatchHistoryItem) { return item.skipAsinsByCountry || item.skip_asins_by_country || {} }
function readSkipAsinDetailsByCountry(item: ShopMatchHistoryItem) { return item.skipAsinDetailsByCountry || item.skip_asin_details_by_country || {} }
function buildQueuePayload(taskId: number, item: Pick<ShopMatchHistoryItem, 'shopName' | 'shopId' | 'platform' | 'companyName' | 'matchStatus' | 'matchMessage' | 'skipAsinsByCountry' | 'skip_asins_by_country' | 'skipAsinDetailsByCountry' | 'skip_asin_details_by_country'>, countryCodes: string[], stageIndex?: number, finalStage = true) { const skipAsinsByCountry = readSkipAsinsByCountry(item as ShopMatchHistoryItem); const skipAsinDetailsByCountry = readSkipAsinDetailsByCountry(item as ShopMatchHistoryItem); return { type: 'shop-match-run', ts: Date.now(), data: { taskId, user_id: currentUserId(), items: [{ shopName: item.shopName, shopId: item.shopId, platform: item.platform, companyName: item.companyName, matched: true, matchStatus: item.matchStatus, matchMessage: item.matchMessage, skip_asins_by_country: skipAsinsByCountry, skipAsinsByCountry, skip_asin_details_by_country: skipAsinDetailsByCountry, skipAsinDetailsByCountry }], country_codes: [...countryCodes], skip_asins_by_country: skipAsinsByCountry, skipAsinsByCountry, skip_asin_details_by_country: skipAsinDetailsByCountry, skipAsinDetailsByCountry, risk_listing_filter: shopMatchListingFilter.value, stage_index: stageIndex, final_stage: finalStage } } }
function nextScheduleStage(taskId: number) { const task = taskSnapshots.value[taskId]?.task; const stages = task?.scheduleStages || []; const currentStageIndex = task?.currentStageIndex; if (typeof currentStageIndex !== 'number') return null; const stage = stages.find((item) => item.stageIndex === currentStageIndex); if (!stage?.scheduledAt) return null; return { stageIndex: currentStageIndex, scheduledAt: stage.scheduledAt, finalStage: currentStageIndex === stages.length - 1 } }
async function dispatchScheduledTask(taskId: number, item: ShopMatchHistoryItem) { const api = getPywebviewApi(); if (!api?.enqueue_json) throw new Error('当前客户端未提供 enqueue_json'); const stage = nextScheduleStage(taskId); const snapshot = taskSnapshots.value[taskId]; const countryCodes = snapshot?.task?.countryCodes || orderedCountryCodes.value; if (!stage) throw new Error(`任务 ${taskId} 缺少待执行阶段`); clearDispatchTimer(taskId); await withTransientRetry(() => activateShopMatchTask(taskId, stage.stageIndex), (attempt, maxAttempts) => { queuePushResult.value = `任务 ${taskId} 等待后端恢复后再激活执行(${attempt}/${maxAttempts}...` }); taskDetails.value = { ...taskDetails.value, [taskId]: 'RUNNING' }; if (snapshot?.task) taskSnapshots.value = { ...taskSnapshots.value, [taskId]: { ...snapshot, task: { ...snapshot.task, status: 'RUNNING', activeStageIndex: stage.stageIndex } } }; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); const payload = buildQueuePayload(taskId, item, countryCodes, stage.stageIndex, stage.finalStage); queuePayloadText.value = JSON.stringify(payload, null, 2); const result = await api.enqueue_json(payload); if (!result?.success) throw new Error(result?.error || `任务 ${taskId} 推送失败`); addPollingTask(taskId); queuePushResult.value = `任务 ${taskId} 已推送第 ${stage.stageIndex + 1}`; ensurePolling(true) }
function scheduledTimestamp(value?: string) { const date = parseScheduleDateTime(value); return date ? date.getTime() : Number.NaN }

View File

@@ -17,38 +17,11 @@
</div>
</div>
<div class="prompt-card">
<div class="section-title">筛选条件</div>
<textarea v-model="filterConditionInput" class="prompt-input" rows="4" placeholder="请输入筛选条件" />
<div class="condition-actions">
<button type="button" class="opt-btn" :disabled="savingCondition" @click="saveFilterCondition">
{{ savingCondition ? '保存中...' : '保存' }}
</button>
</div>
<div class="section-title condition-list-title">备选区</div>
<div v-if="!filterConditions.length" class="empty-conditions">暂无筛选条件输入后保存即可加入备选区</div>
<ul v-else class="condition-list">
<li v-for="condition in filterConditions" :key="condition.id" class="condition-item">
<label class="condition-check">
<input
v-model="selectedFilterConditionId"
type="radio"
name="similar-asin-filter-condition"
:value="condition.id"
@change="applySelectedFilterCondition(condition)"
/>
<span :title="condition.conditionText">{{ condition.conditionText }}</span>
</label>
<button type="button" class="link-danger" @click="removeFilterCondition(condition.id)">删除</button>
</li>
</ul>
</div>
<div class="run-row">
<button type="button" class="btn-run" :disabled="parsing || !uploadedFiles.length" @click="parseFiles">
{{ parsing ? '解析中...' : '解析并创建任务' }}
</button>
<button type="button" class="btn-run btn-queue" :disabled="pushing || !parsedRows.length" @click="pushToPythonQueue">
<button type="button" class="btn-run btn-queue" :disabled="pushing || !parseResult?.taskId" @click="pushToPythonQueue">
{{ pushing ? '推送中...' : '推送到 Python 队列' }}
</button>
</div>
@@ -84,22 +57,6 @@
</div>
<div class="subsection-title">匹配任务</div>
<div class="result-list-wrap preview-wrap">
<div class="result-list-header">
<span>解析结果</span>
<span v-if="parsedRows.length" class="muted"> {{ parsedRows.length }} </span>
</div>
<div v-if="!parsedRows.length" class="empty-tasks">暂无解析结果</div>
<el-table v-else :data="previewRows" height="120" class="result-table">
<el-table-column prop="displayId" label="ID" width="90" />
<el-table-column prop="asin" label="ASIN" width="130" />
<el-table-column prop="country" label="国家" width="100" />
<el-table-column prop="price" label="价格" width="90" />
<el-table-column prop="title" label="标题" min-width="160" show-overflow-tooltip />
<el-table-column prop="url" label="URL" min-width="160" show-overflow-tooltip />
</el-table>
</div>
<div class="result-list-wrap">
<div class="result-list-header">
<span>当前任务</span>
@@ -110,7 +67,22 @@
<div class="left">
<span class="id">{{ item.sourceFilename || '货源查询' }}</span>
<div class="files">任务 ID{{ item.taskId }}</div>
<div class="files">开始时间{{ formatDateTime(item.startedAt || item.createdAt) }}</div>
<div class="files">结束时间{{ formatDateTime(item.finishedAt) }}</div>
<div class="files">行数{{ item.rowCount ?? '-' }}</div>
<div v-if="pendingResultHint(item)" class="files result-hint">{{ pendingResultHint(item) }}</div>
<div v-if="showFileProgress(item)" class="file-progress">
<div class="file-progress-meta">
<span>{{ displayFileProgressStage(item, '处理中') }}</span>
<span>总进度 {{ fileProgressPercent(item) }}%</span>
</div>
<div class="file-progress-track">
<div class="file-progress-bar" :style="{ width: `${fileProgressPercent(item)}%` }"></div>
</div>
<div v-if="hasFileProgressCount(item)" class="file-progress-count">
{{ fileProgressCountLabel(item) }}
</div>
</div>
</div>
<div class="task-right">
<span class="status running">{{ statusText(item) }}</span>
@@ -130,20 +102,22 @@
<div class="left">
<span class="id">{{ item.sourceFilename || '货源查询' }}</span>
<div class="files">任务 ID{{ item.taskId ?? '-' }}</div>
<div class="files">开始时间{{ formatDateTime(item.startedAt || item.createdAt) }}</div>
<div class="files">结束时间{{ formatDateTime(item.finishedAt) }}</div>
<div v-if="item.resultFilename" class="files">
{{ item.resultFilename || '下载结果' }}
</div>
<div v-if="pendingResultHint(item)" class="files result-hint">{{ pendingResultHint(item) }}</div>
<div v-if="showFileProgress(item)" class="file-progress">
<div class="file-progress-meta">
<span>{{ item.fileProgressMessage || '结果生成中' }}</span>
<span>{{ fileProgressPercent(item) }}%</span>
<span>{{ displayFileProgressStage(item, '结果生成中') }}</span>
<span>总进度 {{ fileProgressPercent(item) }}%</span>
</div>
<div class="file-progress-track">
<div class="file-progress-bar" :style="{ width: `${fileProgressPercent(item)}%` }"></div>
</div>
<div class="file-progress-count">
{{ item.fileProgressCurrent || 0 }}/{{ item.fileProgressTotal || 0 }}
<div v-if="hasFileProgressCount(item)" class="file-progress-count">
{{ fileProgressCountLabel(item) }}
</div>
</div>
<div v-if="item.error" class="files">错误{{ item.error }}</div>
@@ -159,6 +133,7 @@
</div>
</section>
</div>
</div>
</template>
@@ -168,21 +143,15 @@ import { ElMessage } from 'element-plus'
import BrandTopBar from '@/pages/brand/components/BrandTopBar.vue'
import {
activateSimilarAsinTask,
addSimilarAsinFilterCondition,
deleteSimilarAsinFilterCondition,
deleteSimilarAsinHistory,
deleteSimilarAsinTask,
getSimilarAsinDashboard,
getSimilarAsinHistory,
getSimilarAsinResultDownloadUrl,
getSimilarAsinTaskProgressBatch,
listSimilarAsinFilterConditions,
parseSimilarAsin,
type SimilarAsinFilterConditionVo,
type SimilarAsinParsedGroup,
type SimilarAsinDashboardVo,
type SimilarAsinHistoryItem,
type SimilarAsinParsedRow,
type SimilarAsinParseVo,
type UploadedFileRef,
type UploadFileVo,
@@ -197,15 +166,10 @@ import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
const selectedFileNames = ref<string[]>([])
const uploadedFiles = ref<UploadFileVo[]>([])
const parseResult = ref<SimilarAsinParseVo | null>(null)
const parsedGroups = ref<SimilarAsinParsedGroup[]>([])
type TaskSummary = Pick<SimilarAsinParseVo, 'taskId' | 'totalRows' | 'acceptedRows' | 'groupCount' | 'droppedRows'>
const queuedTaskSummary = ref<TaskSummary | null>(null)
const filterConditionInput = ref('')
const filterConditions = ref<SimilarAsinFilterConditionVo[]>([])
const selectedFilterConditionId = ref<number | null>(null)
const parsing = ref(false)
const pushing = ref(false)
const savingCondition = ref(false)
const queuePayloadText = ref('')
const pollingTaskIds = ref<number[]>([])
const pendingFileTaskIds = ref<number[]>([])
@@ -225,10 +189,30 @@ const dashboard = ref<SimilarAsinDashboardVo>({
})
const historyItems = ref<SimilarAsinHistoryItem[]>([])
const parsedRows = computed<SimilarAsinParsedRow[]>(() => parseResult.value?.items || [])
const previewRows = computed(() => parsedRows.value)
const visibleTaskSummary = computed(() => parseResult.value || queuedTaskSummary.value)
const currentItems = computed(() => historyItems.value.filter((i) => i.taskId != null && pollingTaskIds.value.includes(i.taskId)))
const pendingParseItem = computed<SimilarAsinHistoryItem | null>(() => {
const result = parseResult.value
if (!result?.taskId) return null
const exists = historyItems.value.some((item) => item.taskId === result.taskId)
if (exists) return null
return {
taskId: result.taskId,
sourceFilename: result.sourceFilename,
rowCount: result.acceptedRows,
taskStatus: 'PENDING',
success: false,
}
})
const currentItems = computed(() => {
const activeIds = new Set([...pollingTaskIds.value, ...pendingFileTaskIds.value])
const activeItems = historyItems.value.filter((item) => {
const status = normalizeTaskStatus(item)
return item.taskId != null && (activeIds.has(item.taskId) || status === 'RUNNING' || status === 'PENDING')
})
if (!pendingParseItem.value) return activeItems
const exists = activeItems.some((item) => item.taskId === pendingParseItem.value?.taskId)
return exists ? activeItems : [pendingParseItem.value, ...activeItems]
})
const historyOnlyItems = computed(() =>
historyItems.value.filter((i) => {
const status = normalizeTaskStatus(i)
@@ -236,18 +220,21 @@ const historyOnlyItems = computed(() =>
}),
)
function selectedFilterConditionText() {
const selectedId = selectedFilterConditionId.value
if (selectedId == null) return ''
return filterConditions.value.find((item) => item.id === selectedId)?.conditionText?.trim() || ''
}
function effectiveFilterCondition() {
return filterConditionInput.value.trim() || selectedFilterConditionText()
}
function effectiveCozeApiKey() {
return getStoredApiSecret('similar-asin').trim()
return getStoredApiSecret().trim()
}
function formatDateTime(value?: string) {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
function maskSecret(secret: string) {
@@ -272,6 +259,10 @@ function uidForStorage() {
return typeof window !== 'undefined' ? window.localStorage.getItem('uid') || '0' : '0'
}
function currentUserId() {
return Number(uidForStorage()) || 0
}
function pollingKey() {
return `similar-asin:tasks:${uidForStorage()}`
}
@@ -336,7 +327,6 @@ async function selectFiles() {
uploadedFiles.value = files
selectedFileNames.value = files.map((f) => f.relativePath || f.originalFilename || f.fileKey)
parseResult.value = null
parsedGroups.value = []
queuedTaskSummary.value = null
queuePayloadText.value = ''
}
@@ -359,7 +349,6 @@ async function selectFolder() {
uploadedFiles.value = files
selectedFileNames.value = result.items.map((item) => item.relativePath || item.absolutePath)
parseResult.value = null
parsedGroups.value = []
queuedTaskSummary.value = null
queuePayloadText.value = ''
ElMessage.success(`已选择文件夹内 ${result.items.length} 个 Excel 文件`)
@@ -368,62 +357,11 @@ async function selectFolder() {
}
}
async function loadFilterConditions() {
try {
filterConditions.value = await listSimilarAsinFilterConditions()
const ids = new Set(filterConditions.value.map((item) => item.id))
if (selectedFilterConditionId.value != null && !ids.has(selectedFilterConditionId.value)) {
selectedFilterConditionId.value = null
}
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '加载筛选条件失败')
}
}
async function saveFilterCondition() {
const text = filterConditionInput.value.trim()
if (!text) {
ElMessage.warning('请输入筛选条件')
return
}
savingCondition.value = true
try {
const saved = await addSimilarAsinFilterCondition(text)
await loadFilterConditions()
if (saved?.id) selectedFilterConditionId.value = saved.id
ElMessage.success('筛选条件已保存')
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '保存筛选条件失败')
} finally {
savingCondition.value = false
}
}
function applySelectedFilterCondition(condition: SimilarAsinFilterConditionVo) {
filterConditionInput.value = condition.conditionText || ''
}
async function removeFilterCondition(id: number) {
try {
await deleteSimilarAsinFilterCondition(id)
if (selectedFilterConditionId.value === id) selectedFilterConditionId.value = null
await loadFilterConditions()
ElMessage.success('筛选条件已删除')
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '删除筛选条件失败')
}
}
async function parseFiles() {
if (!uploadedFiles.value.length) {
ElMessage.warning('请先选择 Excel')
return
}
const filterCondition = effectiveFilterCondition()
if (!filterCondition) {
ElMessage.warning('请输入筛选条件,或在备选区选择一个筛选条件')
return
}
if (!effectiveCozeApiKey()) {
ElMessage.warning('请先在左上角设置中填写货源查询密钥')
return
@@ -435,10 +373,9 @@ async function parseFiles() {
originalFilename: f.originalFilename,
relativePath: f.relativePath,
}))
const res = await parseSimilarAsin(files, filterCondition, effectiveCozeApiKey())
const res = await parseSimilarAsin(files, effectiveCozeApiKey())
parseResult.value = res
queuedTaskSummary.value = null
parsedGroups.value = []
queuePayloadText.value = ''
ElMessage.success(`解析完成,共 ${res.acceptedRows}`)
} catch (e) {
@@ -471,7 +408,7 @@ async function pushToPythonQueue() {
ts: Date.now(),
data: {
taskId,
prompt: currentParseResult.aiPrompt || effectiveFilterCondition(),
user_id: currentUserId(),
api_key: effectiveCozeApiKey(),
sourceFileCount: currentParseResult.sourceFileCount || 0,
totalRows: currentParseResult.totalRows || 0,
@@ -499,7 +436,6 @@ async function pushToPythonQueue() {
function clearParsedTask() {
parseResult.value = null
parsedGroups.value = []
queuePayloadText.value = ''
}
@@ -660,6 +596,16 @@ function seedPendingFileTasksFromHistory() {
if (pendingFileTaskIds.value.length) ensurePolling()
}
function seedRunningTasksFromHistory() {
const ids = historyItems.value
.filter((item) => item.taskId != null && normalizeTaskStatus(item) === 'RUNNING')
.map((item) => item.taskId as number)
if (!ids.length) return
pollingTaskIds.value = Array.from(new Set([...pollingTaskIds.value, ...ids]))
savePollingIds()
ensurePolling()
}
async function loadDashboard() {
dashboard.value = await getSimilarAsinDashboard()
}
@@ -670,7 +616,7 @@ async function loadHistory(options: { force?: boolean } = {}) {
if (historyInFlight) return historyInFlight
historyInFlight = getSimilarAsinHistory()
.then((res) => {
historyItems.value = res.items || []
historyItems.value = mergeHistoryItemsPreservingLiveProgress(historyItems.value, res.items || [])
lastHistoryLoadedAt = Date.now()
})
.finally(() => {
@@ -679,6 +625,66 @@ async function loadHistory(options: { force?: boolean } = {}) {
return historyInFlight
}
function mergeHistoryItemsPreservingLiveProgress(
previousItems: SimilarAsinHistoryItem[],
incomingItems: SimilarAsinHistoryItem[],
) {
const previousByTaskId = new Map<number, SimilarAsinHistoryItem>()
const previousByResultId = new Map<number, SimilarAsinHistoryItem>()
for (const item of previousItems || []) {
if (item.taskId != null) previousByTaskId.set(item.taskId, item)
if (item.resultId != null) previousByResultId.set(item.resultId, item)
}
return (incomingItems || []).map((incoming) => {
const previous = incoming.resultId != null
? previousByResultId.get(incoming.resultId)
: incoming.taskId != null
? previousByTaskId.get(incoming.taskId)
: undefined
return mergeHistoryItemPreservingLiveProgress(previous, incoming)
})
}
function mergeHistoryItemPreservingLiveProgress(
previous: SimilarAsinHistoryItem | undefined,
incoming: SimilarAsinHistoryItem,
) {
if (!previous) return incoming
const shouldPreserveLiveProgress =
(normalizeTaskStatus(previous) === 'RUNNING' || isResultPreparing(previous) || showFileProgress(previous))
&& !showFileProgress(incoming)
&& !canDownload(incoming)
if (!shouldPreserveLiveProgress) {
return incoming
}
return {
...incoming,
fileJobId: incoming.fileJobId ?? previous.fileJobId,
fileStatus: incoming.fileStatus || previous.fileStatus,
fileError: incoming.fileError || previous.fileError,
fileReady: incoming.fileReady || previous.fileReady,
fileProgressPercent: hasMeaningfulProgressValue(incoming.fileProgressPercent)
? incoming.fileProgressPercent
: previous.fileProgressPercent,
fileProgressCurrent: hasMeaningfulProgressValue(incoming.fileProgressCurrent)
? incoming.fileProgressCurrent
: previous.fileProgressCurrent,
fileProgressTotal: hasMeaningfulProgressValue(incoming.fileProgressTotal)
? incoming.fileProgressTotal
: previous.fileProgressTotal,
fileProgressMessage: (incoming.fileProgressMessage || '').trim()
? incoming.fileProgressMessage
: previous.fileProgressMessage,
}
}
function hasMeaningfulProgressValue(value: unknown) {
const num = Number(value)
return Number.isFinite(num) && num > 0
}
function statusText(item: SimilarAsinHistoryItem) {
const status = normalizeTaskStatus(item)
if (status === 'RUNNING') return '执行中'
@@ -719,6 +725,9 @@ function pendingResultHint(item: SimilarAsinHistoryItem) {
if (isResultBuildFailed(item)) {
return item.fileError || '结果文件生成失败,请稍后重试或检查后端日志'
}
if (showFileProgress(item)) {
return ''
}
if (!isResultPreparing(item)) return ''
const fileStatus = (item.fileStatus || '').toUpperCase()
if (fileStatus === 'RUNNING') {
@@ -736,12 +745,79 @@ function canDownload(item: SimilarAsinHistoryItem) {
function fileProgressPercent(item: SimilarAsinHistoryItem) {
const percent = Number(item.fileProgressPercent || 0)
if (!Number.isFinite(percent)) return 0
return Math.max(0, Math.min(100, Math.round(percent)))
if (Number.isFinite(percent) && percent > 0) {
return Math.max(0, Math.min(100, Math.round(percent)))
}
if (shouldShowFallbackJobProgress(item)) {
return activeFileJobStatus(item) === 'PENDING' ? 6 : 10
}
return 0
}
function displayFileProgressStage(item: SimilarAsinHistoryItem, fallback: string) {
const message = (item.fileProgressMessage || '').trim()
if (!message) return fallback
if (/coze/i.test(message) || /Coze|回流/.test(message)) {
if (/submitting/i.test(message) || /提交/.test(message)) return '正在提交 Coze 批次'
if (/生成结果|结果文件|组装/i.test(message)) return 'Coze 已回流,正在生成结果文件'
if (/waiting/i.test(message) || /等待/.test(message) || /\d+\s*\/\s*\d+/.test(message)) {
return 'Coze 回流中,等待结果回传'
}
return 'Coze 处理中'
}
if (/assembling|xlsx|组装|生成结果/i.test(message)) return '正在组装结果文件'
return message
}
function fileProgressCountLabel(item: SimilarAsinHistoryItem) {
const message = (item.fileProgressMessage || '').trim()
const parsed = parseProgressCountFromMessage(message)
const current = parsed?.current ?? item.fileProgressCurrent ?? 0
const total = parsed?.total ?? item.fileProgressTotal ?? 0
if (/coze/i.test(message) || /Coze|回流/.test(message)) {
if (/submitting/i.test(message) || /提交/.test(message)) {
return `Coze 提交批次 ${current}/${total}`
}
return `Coze 回流批次 ${current}/${total}`
}
return `当前阶段 ${current}/${total}`
}
function parseProgressCountFromMessage(message: string) {
const match = message.match(/(\d+)\s*\/\s*(\d+)/)
if (!match) return null
const current = Number(match[1])
const total = Number(match[2])
if (!Number.isFinite(current) || !Number.isFinite(total)) return null
return { current, total }
}
function showFileProgress(item: SimilarAsinHistoryItem) {
return isResultPreparing(item) && (item.fileProgressTotal || 0) > 0
const status = normalizeTaskStatus(item)
return (status === 'RUNNING' || isResultPreparing(item)) && (hasExplicitFileProgress(item) || shouldShowFallbackJobProgress(item))
}
function hasExplicitFileProgress(item: SimilarAsinHistoryItem) {
return hasFileProgressCount(item)
|| Number(item.fileProgressPercent || 0) > 0
|| Boolean((item.fileProgressMessage || '').trim())
}
function hasFileProgressCount(item: SimilarAsinHistoryItem) {
return (item.fileProgressTotal || 0) > 0
}
function activeFileJobStatus(item: SimilarAsinHistoryItem) {
return (item.fileStatus || '').toUpperCase()
}
function hasActiveFileJob(item: SimilarAsinHistoryItem) {
const fileStatus = activeFileJobStatus(item)
return item.fileJobId != null || fileStatus === 'PENDING' || fileStatus === 'RUNNING'
}
function shouldShowFallbackJobProgress(item: SimilarAsinHistoryItem) {
return hasActiveFileJob(item) && !hasExplicitFileProgress(item)
}
async function downloadResult(item: SimilarAsinHistoryItem) {
@@ -780,11 +856,11 @@ async function deleteTaskRecord(item: SimilarAsinHistoryItem) {
onMounted(async () => {
loadPollingIds()
await Promise.all([
loadFilterConditions().catch(() => undefined),
loadDashboard().catch(() => undefined),
loadHistory().catch(() => undefined),
])
seedPendingFileTasksFromHistory()
seedRunningTasksFromHistory()
if (pollingTaskIds.value.length || pendingFileTaskIds.value.length) ensurePolling()
})
@@ -813,20 +889,6 @@ onUnmounted(() => {
.btn-run:disabled { opacity: .55; cursor: not-allowed; }
.selected-files { margin-top: 14px; color: #999; font-size: 12px; word-break: break-all; }
.selected-files span { display: block; margin: 4px 0; }
.prompt-card { margin-bottom: 14px; }
.prompt-input { width: 100%; box-sizing: border-box; resize: vertical; min-height: 96px; max-height: 140px; padding: 8px 12px; border: 1px solid #333; border-radius: 8px; background: #202020; color: #d8d8d8; font-size: 12px; line-height: 1.5; outline: none; }
.prompt-input:focus { border-color: #3498db; }
.condition-actions { display: flex; justify-content: flex-end; margin-top: 8px; }
.condition-actions .opt-btn { padding: 6px 14px; }
.condition-list-title { margin-top: 10px; }
.empty-conditions { color: #666; font-size: 12px; padding: 10px 4px; }
.condition-list { list-style: none; margin: 0; padding: 0; max-height: 96px; overflow: auto; display: flex; flex-direction: column; gap: 6px; }
.condition-item { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 6px; border-bottom: 1px solid #303030; }
.condition-item:last-child { border-bottom: none; }
.condition-check { display: flex; align-items: center; gap: 8px; min-width: 0; color: #cfd6df; font-size: 12px; cursor: pointer; }
.condition-check input { width: 16px; height: 16px; margin: 0; flex: 0 0 auto; }
.condition-check span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.link-danger { border: none; background: transparent; color: #ff8f8f; cursor: pointer; font-size: 12px; padding: 2px 0; white-space: nowrap; }
.parse-card, .queue-payload { margin-top: 14px; padding: 12px; border: 1px solid #2a2a2a; border-radius: 8px; background: #202020; color: #b8c1cc; font-size: 12px; }
.queue-payload { max-height: 220px; overflow: auto; color: #8fd3ff; white-space: pre-wrap; }
.panel-header { padding: 16px 20px; border-bottom: 1px solid #2a2a2a; font-size: 15px; font-weight: 600; color: #ddd; }
@@ -838,7 +900,6 @@ onUnmounted(() => {
.result-list-wrap { border: 1px solid #2a2a2a; border-radius: 8px; background: #1e1e1e; min-height: 180px; margin: 0 0 16px; }
.result-list-header { display: flex; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid #2a2a2a; color: #ddd; font-size: 14px; }
.empty-tasks { color: #666; font-size: 13px; padding: 18px; text-align: center; }
.result-table { --el-table-bg-color: #222; --el-table-tr-bg-color: #222; --el-table-header-bg-color: #2a2a2a; --el-table-text-color: #ccc; --el-table-border-color: #333; }
.task-list { list-style: none; margin: 0; padding: 12px; }
.task-item { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 12px 14px; border: 1px solid #2a2a2a; border-radius: 8px; margin-bottom: 8px; background: #222; }
.left { flex: 1; min-width: 0; }

View File

@@ -631,6 +631,28 @@ export interface ProductRiskShopQueueItem {
matchStatus?: string;
matchMessage?: string;
queryAsins?: QueryAsinCountryAsins[];
skipAsinsByCountry?: Record<string, string[]>;
skip_asins_by_country?: Record<string, string[]>;
skipAsinDetailsByCountry?: Record<string, Array<{ asin?: string; minimumPrice?: string }>>;
skip_asin_details_by_country?: Record<string, Array<{ asin?: string; minimumPrice?: string }>>;
}
export interface ShopMatchResultRow {
asin?: string;
minimumPrice?: string;
minimum_price?: string;
status?: string;
done?: boolean;
}
export interface ShopMatchSubmitShopPayload {
shopName?: string;
error?: string;
countries?: Record<string, ShopMatchResultRow[]>;
skipAsinsByCountry?: Record<string, string[]>;
skip_asins_by_country?: Record<string, string[]>;
skipAsinDetailsByCountry?: Record<string, Array<{ asin?: string; minimumPrice?: string }>>;
skip_asin_details_by_country?: Record<string, Array<{ asin?: string; minimumPrice?: string }>>;
}
export interface ProductRiskMatchShopsVo {
@@ -737,6 +759,10 @@ export interface ProductRiskHistoryItem {
fileError?: string;
fileReady?: boolean;
scheduledAt?: string;
skipAsinsByCountry?: Record<string, string[]>;
skip_asins_by_country?: Record<string, string[]>;
skipAsinDetailsByCountry?: Record<string, Array<{ asin?: string; minimumPrice?: string }>>;
skip_asin_details_by_country?: Record<string, Array<{ asin?: string; minimumPrice?: string }>>;
}
export interface ProductRiskHistoryVo {
@@ -1535,6 +1561,8 @@ export interface AppearancePatentHistoryItem {
error?: string;
rowCount?: number;
createdAt?: string;
startedAt?: string;
finishedAt?: string;
}
export interface AppearancePatentHistoryVo {
@@ -1654,12 +1682,6 @@ export function getAppearancePatentResultDownloadUrl(resultId: number) {
// ========== 货源查询 ==========
export interface SimilarAsinFilterConditionVo {
id: number;
conditionText: string;
createdAt?: string;
}
export interface SimilarAsinParsedRow {
rowIndex: number;
sourceFileKey?: string;
@@ -1670,6 +1692,7 @@ export interface SimilarAsinParsedRow {
groupKey?: string;
asin: string;
country: string;
sku?: string;
price?: string;
url?: string;
title?: string;
@@ -1724,6 +1747,8 @@ export interface SimilarAsinHistoryItem {
error?: string;
rowCount?: number;
createdAt?: string;
startedAt?: string;
finishedAt?: string;
}
export interface SimilarAsinHistoryVo {
@@ -1750,49 +1775,19 @@ export interface SimilarAsinTaskBatchVo {
missingTaskIds?: number[];
}
export function parseSimilarAsin(files: UploadedFileRef[], filterCondition: string, apiKey?: string) {
export function parseSimilarAsin(files: UploadedFileRef[], apiKey?: string) {
return unwrapJavaResponse(
post<
JavaApiResponse<SimilarAsinParseVo>,
{ user_id: number; files: UploadedFileRef[]; ai_prompt: string; api_key?: string }
{ user_id: number; files: UploadedFileRef[]; api_key?: string }
>(`${JAVA_API_PREFIX}/similar-asin/parse`, {
user_id: getCurrentUserId(),
files,
ai_prompt: filterCondition,
api_key: apiKey,
}),
);
}
export function listSimilarAsinFilterConditions() {
return unwrapJavaResponse(
get<JavaApiResponse<SimilarAsinFilterConditionVo[]>>(
`${JAVA_API_PREFIX}/similar-asin/filter-conditions`,
{ params: { user_id: getCurrentUserId() } },
),
);
}
export function addSimilarAsinFilterCondition(conditionText: string) {
return unwrapJavaResponse(
post<
JavaApiResponse<SimilarAsinFilterConditionVo>,
{ user_id: number; condition_text: string }
>(`${JAVA_API_PREFIX}/similar-asin/filter-conditions`, {
user_id: getCurrentUserId(),
condition_text: conditionText,
}),
);
}
export function deleteSimilarAsinFilterCondition(id: number) {
return unwrapJavaResponse(
del<JavaApiResponse<null>>(`${JAVA_API_PREFIX}/similar-asin/filter-conditions/${id}`, {
params: { user_id: getCurrentUserId() },
}),
);
}
export function getSimilarAsinDashboard() {
return unwrapJavaResponse(
get<JavaApiResponse<SimilarAsinDashboardVo>>(

View File

@@ -1,4 +1,4 @@
export type ApiSecretModuleKey = 'appearance-patent' | 'similar-asin'
type LegacyApiSecretModuleKey = 'appearance-patent' | 'similar-asin'
export type ApiSecretRetention = 'session' | '1d' | '7d' | '30d' | 'forever'
@@ -18,17 +18,19 @@ export type ApiSecretSnapshot = {
}
const STORAGE_PREFIX = 'brand:api-secret'
const COMMON_SECRET_KEY = 'common'
const LEGACY_SECRET_KEYS: LegacyApiSecretModuleKey[] = ['appearance-patent', 'similar-asin']
function currentUserStorageId() {
if (typeof window === 'undefined') return '0'
return window.localStorage.getItem('uid') || '0'
}
function buildStorageKey(moduleKey: ApiSecretModuleKey) {
function buildStorageKey(moduleKey: string) {
return `${STORAGE_PREFIX}:${currentUserStorageId()}:${moduleKey}`
}
function readStorageRecord(storage: Storage, moduleKey: ApiSecretModuleKey): ApiSecretRecord | null {
function readStorageRecord(storage: Storage, moduleKey: string): ApiSecretRecord | null {
const raw = storage.getItem(buildStorageKey(moduleKey))
if (!raw) return null
try {
@@ -81,11 +83,11 @@ function isExpired(record: ApiSecretRecord) {
return record.expiresAt != null && record.expiresAt <= Date.now()
}
function clearStorageRecord(storage: Storage, moduleKey: ApiSecretModuleKey) {
function clearStorageRecord(storage: Storage, moduleKey: string) {
storage.removeItem(buildStorageKey(moduleKey))
}
function getLiveRecord(moduleKey: ApiSecretModuleKey): ApiSecretRecord | null {
function getLiveRecordFromKey(moduleKey: string): ApiSecretRecord | null {
if (typeof window === 'undefined') return null
const sessionRecord = readStorageRecord(window.sessionStorage, moduleKey)
@@ -102,12 +104,42 @@ function getLiveRecord(moduleKey: ApiSecretModuleKey): ApiSecretRecord | null {
return localRecord
}
export function getStoredApiSecret(moduleKey: ApiSecretModuleKey) {
return getLiveRecord(moduleKey)?.value || ''
function clearLegacyStoredApiSecrets() {
if (typeof window === 'undefined') return
for (const moduleKey of LEGACY_SECRET_KEYS) {
clearStorageRecord(window.sessionStorage, moduleKey)
clearStorageRecord(window.localStorage, moduleKey)
}
}
export function getStoredApiSecretSnapshot(moduleKey: ApiSecretModuleKey): ApiSecretSnapshot {
const record = getLiveRecord(moduleKey)
function migrateLegacyRecord(record: ApiSecretRecord) {
if (typeof window === 'undefined') return
const storage = record.retention === 'session' ? window.sessionStorage : window.localStorage
storage.setItem(buildStorageKey(COMMON_SECRET_KEY), JSON.stringify(record))
clearLegacyStoredApiSecrets()
}
function getLiveRecord(): ApiSecretRecord | null {
const commonRecord = getLiveRecordFromKey(COMMON_SECRET_KEY)
if (commonRecord) return commonRecord
for (const moduleKey of LEGACY_SECRET_KEYS) {
const legacyRecord = getLiveRecordFromKey(moduleKey)
if (legacyRecord) {
migrateLegacyRecord(legacyRecord)
return legacyRecord
}
}
return null
}
export function getStoredApiSecret() {
return getLiveRecord()?.value || ''
}
export function getStoredApiSecretSnapshot(): ApiSecretSnapshot {
const record = getLiveRecord()
if (!record) {
return {
value: '',
@@ -127,14 +159,13 @@ export function getStoredApiSecretSnapshot(moduleKey: ApiSecretModuleKey): ApiSe
}
export function saveStoredApiSecret(
moduleKey: ApiSecretModuleKey,
value: string,
retention: ApiSecretRetention,
) {
if (typeof window === 'undefined') return
const trimmedValue = value.trim()
clearStoredApiSecret(moduleKey)
clearStoredApiSecret()
if (!trimmedValue) return
const now = Date.now()
@@ -146,11 +177,12 @@ export function saveStoredApiSecret(
}
const storage = retention === 'session' ? window.sessionStorage : window.localStorage
storage.setItem(buildStorageKey(moduleKey), JSON.stringify(record))
storage.setItem(buildStorageKey(COMMON_SECRET_KEY), JSON.stringify(record))
}
export function clearStoredApiSecret(moduleKey: ApiSecretModuleKey) {
export function clearStoredApiSecret() {
if (typeof window === 'undefined') return
clearStorageRecord(window.sessionStorage, moduleKey)
clearStorageRecord(window.localStorage, moduleKey)
clearStorageRecord(window.sessionStorage, COMMON_SECRET_KEY)
clearStorageRecord(window.localStorage, COMMON_SECRET_KEY)
clearLegacyStoredApiSecrets()
}

View File

@@ -5,11 +5,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>外观专利检测</title>
<script type="module" crossorigin src="/assets/appearance-patent.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-CcblbV5J.js">
<link rel="modulepreload" crossorigin href="/assets/brand-CEi7uRFN.js">
<link rel="modulepreload" crossorigin href="/assets/pywebview-ZMN052YE.js">
<link rel="modulepreload" crossorigin href="/assets/brand-DfxdJ4w6.js">
<link rel="modulepreload" crossorigin href="/assets/categorized-timers-JPA-olTr.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-DNJAt1gR.css">
<link rel="stylesheet" crossorigin href="/assets/appearance-patent-D7zGgGOj.css">
<link rel="stylesheet" crossorigin href="/assets/appearance-patent--ibZ-SKG.css">
</head>
<body>
<div id="app"></div>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More