Merge branch 'master' into dev/koko

This commit is contained in:
koko
2026-04-25 15:47:02 +08:00
127 changed files with 8081 additions and 8083 deletions

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -1040,7 +1040,8 @@ class ApproveTask:
for country_code in country_codes: for country_code in country_codes:
# 检查是否收到暂停请求 # 检查是否收到暂停请求
if task_id in runing_task and runing_task[task_id].get("stop_requested", False): if task_id in runing_task and runing_task[task_id].get("stop_requested", False):
self.log(f"检测到任务 {task_id} 的暂停请求,停止处理国家", "WARNING") self.log(f"检测到任务 {task_id} 的暂停请求,停止处理国家", "ERROR")
driver.close_store()
break break
# 打开店铺 # 打开店铺
@@ -1058,7 +1059,7 @@ class ApproveTask:
import traceback import traceback
self.log(f"处理国家 {country_code} 失败: {str(e)}", "ERROR") self.log(f"处理国家 {country_code} 失败: {str(e)}", "ERROR")
self.log(traceback.format_exc(), "ERROR") self.log(traceback.format_exc(), "ERROR")
if "与页面的连接已断开" in e: if "与页面的连接已断开" in str(e):
iskill = True iskill = True
# 更新已处理国家数 # 更新已处理国家数

View File

@@ -0,0 +1,712 @@
import json
import sys
import os
import io
# sys.stdout.reconfigure(encoding='utf-8')
import time
import re
import traceback
from datetime import datetime
from DrissionPage import Chromium, ChromiumOptions
import requests
from amazon.del_brand import AmamzonBase, kill_process
from amazon.tool import show_notification, get_shop_info, remove_special_characters, split_currency_values
from config import runing_task, runing_shop, base_dir, DELETE_BRAND_API_BASE
class AmzonePStatus(AmamzonBase):
mark_name = "状态查询"
def SwitchPage(self):
"""
切换至 管理所有库存页面
1、等待 //navigation-favorites-bar[@class="hydrated"] 出现
"""
navigation = self.tab.ele('xpath://navigation-favorites-bar[@class="hydrated"]')
navigation.wait.displayed(raise_err=False)
page_btn = navigation.sr('xpath://internal-fav-bar-links[@data-internal="navigation"]').sr(
'xpath://a[@data-page-id="ezdpc-gui-inventory-mons"]')
page_btn.wait.displayed(raise_err=False)
page_btn.click(timeout=5)
self.tab.wait.doc_loaded()
# 等待搜索框出现
search_region = self.tab.ele('xpath://div[@id="searchBoxContainer"]//kat-input-group')
search_region.wait.displayed(raise_err=False, timeout=60)
def search_asin(self, asin):
search_region = self.tab.ele('xpath://div[@id="searchBoxContainer"]//kat-input-group')
search_region.wait.displayed(raise_err=False)
time.sleep(0.6)
search_input = self.tab.ele("xpath://kat-input[contains(@class,'SearchBox-module__searchInput')]").sr(
'xpath://span[@class="container"]//input[@part="input"]')
search_input.input(asin, clear=True)
sku_ls = []
for _ in range(3):
search_btn = self.tab.ele("xpath://kat-icon[@name='search']")
search_btn.click()
load_ele = self.tab.ele("xpath://div[contains(@class,'Loader-module__loader')]")
# load_ele.wait.hidden(timeout=3, raise_err=False)
load_ele.wait.deleted(timeout=3, raise_err=False)
time.sleep(0.5)
sku_ls = self.tab.eles("xpath://div[@data-sku]", timeout=3)
if len(sku_ls) > 0:
break
return sku_ls
def search_asin_action(self, asin: str):
load_ele = self.tab.eles("xpath://div[contains(@class,'Loader-module__loader')]", timeout=5)
if len(load_ele) > 0:
load_ele[0].wait.deleted(timeout=3, raise_err=False)
time.sleep(0.5)
sku_ls = self.search_asin(asin=asin)
print(f"{self.mark_name}{asin} 搜索到 {len(sku_ls)} 个SKU")
def clear_tab(self):
all_tab = self.browser.get_tabs()
close_tab = []
for tab in all_tab:
tab_id = tab if isinstance(tab, str) else tab.tab_id
if self.tab.tab_id == tab_id:
continue
close_tab.append(tab)
print("需要关闭的标签页", close_tab)
print("当前操作的tab_id", self.tab.tab_id)
self.browser.close_tabs(close_tab)
def run_page_action(self, appoint_asin: str = None):
"""
miniprice_info :
{
"B0DSJGJBWV" : "19.81" # asin : 最低价
}
"""
print(f"{self.mark_name}】,开始执行")
num = 0
retry_num = 0
already_asin = set()
get_page_faild = 0
while retry_num < 3: # 最多重试3次
try:
print(self.mark_name, "指定asin操作", appoint_asin)
self.search_asin_action(asin=appoint_asin)
load_ele = self.tab.eles("xpath://div[contains(@class,'Loader-module__loader')]", timeout=5)
if len(load_ele) > 0:
load_ele[0].wait.deleted(timeout=3, raise_err=False)
time.sleep(0.5)
# 获取当前页码
try:
page_pamel = self.tab.eles('xpath://kat-pagination', timeout=5)
if len(page_pamel) > 0:
current_page = page_pamel[0].sr('xpath:.//ul[@class="pages"]//li[@aria-current="true"]').text
# 总页数
total_page = page_pamel[0].sr.eles(
'xpath:.//ul[@class="pages"]//span[@class="page__inner"][last()]')
if len(total_page) > 0:
total_page = total_page[-1].text
else:
total_page = 0
print(f"{self.mark_name}】当前页码: {current_page} / 总页数: {total_page}")
get_page_faild = 0
except Exception as e:
print("{self.mark_name}", "获取页码失败", e)
get_page_faild += 1
if get_page_faild > 2:
show_notification(f"{self.mark_name}】获取页码失败超3次停止任务")
break
sku_ls = self.tab.eles("xpath://div[@data-sku]", timeout=10)
print(f"{self.mark_name}】获取到 {len(sku_ls)}")
# for sku_ele in sku_ls[0:2]:
if len(sku_ls) == 0:
yield (appoint_asin,"查询不到")
break
for sku_ele in sku_ls:
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
print(f"{self.mark_name}】ASIN {asin} 找到....")
if asin in already_asin:
print(f"{self.mark_name}{asin} 已经处理过了,跳过")
continue
# 状态
status_ele = sku_ele.eles('xpath:.//div[contains(@class,"Status-module__container")]//kat-label')
if len(status_ele) > 0:
statu = status_ele[0].attr("emphasis")
else:
statu = "未获取到状态"
already_asin.add(asin)
yield (asin,statu)
print(f"{self.mark_name}",(asin,statu))
# 判断是否存在需要翻页的情况
page_pamel = self.tab.eles('xpath://kat-pagination', timeout=5)
if len(page_pamel) == 0:
break
next_page_btn = page_pamel[0].sr('xpath:.//span[@part="pagination-nav-right"]')
class_str = next_page_btn.attr('class')
if "end" in class_str:
break
next_page_btn.click()
num += 1
print(f"{self.mark_name}】【程序计算】正在翻页,已翻 {num} 页...")
already_asin = set()
except Exception as e:
print(f"{self.mark_name}】处理跟价操作异常", e)
traceback.print_exc()
retry_num += 1
self.tab.refresh()
self.tab.wait.doc_loaded(raise_err=False, timeout=120)
class StatusTask:
country_info = {
"DE": "德国",
"FR": "法国",
"ES": "西班牙",
"IT": "意大利",
"UK": "英国"
}
def __init__(self, user_info: dict = None):
"""初始化审批任务处理器
Args:
user_info: 用户信息字典,包含 company, username, password
"""
self.user_info = user_info or {}
self.running = True
def log(self, message: str, level: str = "INFO"):
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
if level == "ERROR":
show_notification(message, "error")
print(f"[{timestamp}] [PriceTask] [{level}] {message}")
def process_task(self, task_data: dict):
"""处理审批任务主入口
Args:
task_data: 任务数据
"""
try:
data = task_data.get("data", {})
task_id = data.get("taskId")
items = data.get("items", [])[0]
shop_name = items.get("shop_name")
queryAsins = items.get("queryAsins", [])
user_id = data.get("user_id")
stage_index = data.get("stage_index")
final_stage = bool(data.get("final_stage", True))
# 用于测试
limit = data.get("limit", None)
if not task_id:
self.log("任务ID为空跳过", "WARNING")
return
# if not items:
# self.log("店铺列表为空,跳过", "WARNING")
# return
if not queryAsins:
self.log("queryAsins 列表为空,跳过", "WARNING")
return
self.log(f"开始处理审批任务 {task_id},共 1 个店铺,{len(queryAsins)} 个国家")
from config import runing_task
runing_task[task_id] = {
"status": "running",
"start_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"total_shops": 1,
"processed_shops": 0,
"total_countries": len(queryAsins),
"processed_countries": 0,
"total_asins": 0,
"processed_asins": 0,
"success_count": 0,
"failed_count": 0,
"stop_requested": False
}
# 检查是否收到暂停请求
if task_id in runing_task and runing_task[task_id].get("stop_requested", False):
self.log(f"检测到任务 {task_id} 的暂停请求,停止处理", "WARNING")
runing_task[task_id]["status"] = "stopped"
return
self.log(f"开始处理店铺: {shop_name}")
show_notification(f"开始处理店铺: {shop_name}", "info")
try:
self.process_shop(items, queryAsins, task_id, user_id, stage_index,
final_stage, limit=limit)
# self.process_shop(shop_item, country_codes, task_id,risk_listing_filter)
# 更新已处理店铺数
if task_id in runing_task:
runing_task[task_id]["processed_shops"] += 1
except Exception as e:
import traceback
self.log(f"处理店铺 {shop_name} 失败: {str(e)}", "ERROR")
self.log(traceback.format_exc(), "ERROR")
# 更新任务状态
if task_id in runing_task:
if runing_task[task_id].get("stop_requested", False):
runing_task[task_id]["status"] = "stopped"
self.log(f"任务 {task_id} 已被暂停!")
else:
runing_task[task_id]["status"] = "completed"
self.log(f"任务 {task_id} 处理完成!")
except Exception as e:
import traceback
self.log(f"任务处理失败: {traceback.format_exc()}", "ERROR")
if task_id:
from config import runing_task
if task_id in runing_task:
runing_task[task_id]["status"] = "failed"
runing_task[task_id]["error"] = str(e)
def open_shop(self, max_retries, company_name, shop_name, iskill=False):
error_info = ""
driver = None
for retry in range(max_retries):
try:
self.log(f"尝试打开店铺 {shop_name} (第 {retry + 1}/{max_retries} 次)")
if iskill:
self.log("重试前先杀掉浏览器进程...")
kill_process("v6")
kill_process("v5")
time.sleep(2)
# 组装用户信息并创建驱动
user_info = {
**self.user_info,
"company": company_name
}
driver = AmzonePStatus(user_info)
browser = driver.open_shop(shop_name)
if browser and browser != "店铺不存在":
self.log(f"成功打开店铺 {shop_name}")
else:
self.log(f"打开店铺失败: {browser}", "WARNING")
driver = None
continue
# 判断是否需要登录
need_login = driver.need_login()
print("【是否需要登录】:", need_login)
if need_login:
self.log(f"店铺 {shop_name} 需要登录,正在登录...")
# 获取店铺凭证
response = get_shop_info(shop_name)
print("【获取店铺凭证返回】:", response.text)
shop_data = response.json()
if not shop_data:
mes = f"获取店铺凭证失败,响应数据: {shop_data.get('message', '未知错误')}"
self.log(mes, "ERROR")
show_notification(mes, "ERROR")
continue
password = shop_data["data"]["password"]
login_success = driver.login(password)
if login_success:
self.log(f"店铺 {shop_name} 登录成功,正在重新打开店铺...")
browser = driver.open_shop(shop_name)
if browser and browser != "店铺不存在":
self.log(f"成功打开店铺 {shop_name} 登录后")
break
else:
self.log(f"登录后打开店铺失败: {browser}", "WARNING")
driver = None
else:
self.log(f"店铺 {shop_name} 登录失败", "WARNING")
driver = None
else:
break
except Exception as e:
import traceback
self.log(f"打开店铺异常: {traceback.format_exc()}", "INFO")
driver = None
error_info = str(e)
time.sleep(10)
# 如果还有重试机会,等待后继续
if retry < max_retries - 1:
time.sleep(3)
# 检查是否成功打开
if not driver or not browser or browser == "店铺不存在":
error_msg = f"店铺 {shop_name} 打开失败,已重试 {max_retries} 次,跳过该店铺,{error_info}"
self.log(error_msg, "ERROR")
# 从执行列表中移除
if shop_name in runing_shop:
del runing_shop[shop_name]
return driver
return driver
def process_shop(self, shop_item: dict, country_codes: list, task_id: int,
user_id=None, stage_index=None, final_stage: bool = True, limit: str = None):
"""处理单个店铺
Args:
shop_item: 店铺信息
country_codes: 国家代码列表
task_id: 任务ID
risk_listing_filter: 风险商品筛选条件
"""
shop_name = shop_item.get("shopName", "未知店铺")
company_name = shop_item.get("companyName", "")
if not company_name:
self.log(f"店铺 {shop_name} 的公司名称为空,跳过", "WARNING")
return
if task_id in runing_task:
runing_task[task_id]["current_shop"] = shop_name
# 将店铺添加到正在执行中的店铺列表
start_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
runing_shop[shop_name] = start_time
self.log(f"店铺 {shop_name} 已添加到执行列表,账号: {company_name},开始时间: {start_time}")
# 店铺打开重试最多3次
max_retries = 3
iskill = False
# 处理每个国家
for value in country_codes:
country_code = value.get("country")
all_asin = value.get("asins")
# 打开店铺
driver = self.open_shop(max_retries=max_retries, company_name=company_name,
shop_name=shop_name, iskill=iskill)
if driver is None:
self.log(f"任务 {task_id} 启动店铺失败,结束任务", "ERROR")
for country_code in country_codes:
self.post_result(task_id, shop_name, country_code, "", "", is_done=True)
return
# 检查是否收到暂停请求
if task_id in runing_task and runing_task[task_id].get("stop_requested", False):
self.log(f"检测到任务 {task_id} 的暂停请求,停止处理国家", "ERROR")
driver.close_store()
break
try:
self.process_country(driver, country_code, task_id, shop_name,all_asin=all_asin )
except Exception as e:
import traceback
self.log(f"处理国家 {country_code} 失败: {str(e)}", "ERROR")
self.log(traceback.format_exc(), "ERROR")
if "与页面的连接已断开" in str(e):
iskill = True
# 更新已处理国家数
if task_id in runing_task:
runing_task[task_id]["processed_countries"] += 1
# 关闭店铺
driver.close_store()
# 最后回传,标记完成
try:
# self.post_result(task_id, shop_name, country_code, "", "", is_done=True)
if final_stage:
self.post_result(task_id, shop_name, country_code, "", "", is_done=True)
else:
self.post_stage_finished(task_id, user_id, stage_index)
except Exception as e:
self.log(f"回传结果失败: {str(e)}", "ERROR")
# 从正在执行中的店铺列表中移除
if shop_name in runing_shop:
del runing_shop[shop_name]
self.log(f"店铺 {shop_name} 已从执行列表中移除")
def process_country(self, driver: AmzonePStatus, country_code: str, task_id: int, shop_name: str,
all_asin:list,
limit: str = None):
"""处理单个国家的审批任务
Args:
driver: AmzoneApprove驱动实例
country_code: 国家代码(如 UK, DE, FR 等)
task_id: 任务ID
shop_name: 店铺名称
risk_listing_filter: 风险商品筛选条件
"""
# 转换国家代码为中文名称
country_name = self.country_info.get(country_code, country_code)
info_mes = f"开始处理国家: {country_name} ({country_code})"
self.log(info_mes)
show_notification(info_mes, "info")
# 更新当前处理的国家
if task_id in runing_task:
runing_task[task_id]["current_country"] = country_name
# 切换国家最多重试3次
max_retries = 3
switch_success = False
for retry in range(max_retries):
try:
self.log(f"尝试切换到国家 {country_name} (第 {retry + 1}/{max_retries} 次)")
if retry > 1:
# 刷新不行就重新打开店铺
self.log("重试前重新打开店铺...")
try:
driver.close_store()
time.sleep(3)
driver.open_shop(shop_name)
except Exception as e:
self.log(f"关闭重新打开店铺: {str(e)}", "WARNING")
# 如果不是第一次尝试,先刷新页面
if retry > 0:
self.log("重试前刷新页面...")
try:
driver.tab.refresh()
time.sleep(3)
except Exception as e:
self.log(f"刷新页面失败: {str(e)}", "WARNING")
switch_success = driver.SwitchingCountries(country_name)
if switch_success:
self.log(f"成功切换到国家 {country_name}")
break
else:
self.log(f"切换到国家 {country_name} 失败", "WARNING")
except Exception as e:
import traceback
self.log(f"切换国家 {country_name} 异常: {str(e)}", "ERROR")
self.log(traceback.format_exc(), "ERROR")
# 如果还有重试机会,等待后继续
if retry < max_retries - 1:
time.sleep(2)
# 如果切换失败,直接返回
if not switch_success:
error_message = f"切换到国家 {country_name} 失败,已重试 {max_retries} 次,跳过该国家"
self.log(error_message, "ERROR")
if task_id in runing_task:
runing_task[task_id]["processed_countries"] += 1
return
# 切换到库存管理页面
for retry in range(max_retries):
try:
driver.SwitchPage()
self.log(f"已切换到库存管理页面")
except Exception as e:
import traceback
self.log(f"切换页面失败: {str(e)}", "ERROR")
self.log(traceback.format_exc(), "ERROR")
if retry >= max_retries - 1:
if task_id in runing_task:
runing_task[task_id]["processed_countries"] += 1
self.log(f"切换页面失败重试退出", "ERROR")
return
# 处理所有需要审批的商品通过yield获取结果
# 指定 asin
for ap_asin in all_asin:
print(f"{driver.mark_name}】开始处理 ->",ap_asin)
for asin, status in driver.run_page_action(
appoint_asin=ap_asin
):
# 检查是否收到暂停请求
if task_id in runing_task and runing_task[task_id].get("stop_requested", False):
self.log(f"检测到任务 {task_id} 的暂停请求停止处理ASIN", "WARNING")
break
self.log(f"ASIN {asin} 处理结果: {status}")
# 更新任务状态
if task_id in runing_task:
runing_task[task_id]["current_asin"] = asin
runing_task[task_id]["processed_asins"] += 1
runing_task[task_id]["failed_count"] += 1
# 回传结果到API
try:
self.post_result(task_id, shop_name, country_code, asin, status)
except Exception as e:
self.log(f"回传结果失败: {str(e)}", "ERROR")
self.log(f"国家 {country_name} 处理完成")
def post_stage_finished(self, task_id: int, user_id, stage_index):
# import requests
# from config import DELETE_BRAND_API_BASE
if user_id in (None, "", 0):
raise ValueError("user_id is required for stage completion callback")
if stage_index is None:
raise ValueError("stage_index is required for stage completion callback")
url = f"{DELETE_BRAND_API_BASE}/api/shop-match/tasks/{task_id}/stage-finished"
payload = {"stage_index": stage_index}
params = {"user_id": user_id}
max_retries = 3
for retry in range(max_retries):
try:
self.log(f"Attempting stage completion callback ({retry + 1}/{max_retries})")
response = requests.post(
url,
params=params,
json=payload,
headers={"Content-Type": "application/json"},
timeout=30,
verify=False,
)
self.log(f"Stage completion callback response: {response.text}")
data = response.json() if response.text else {}
if response.status_code == 200 and isinstance(data, dict) and data.get("success"):
self.log(f"Stage completion callback succeeded: task={task_id}, stage={stage_index}")
return
self.log(f"Stage completion callback failed, status={response.status_code}", "WARNING")
except Exception as e:
self.log(f"Stage completion callback exception: {str(e)}", "ERROR")
if retry < max_retries - 1:
time.sleep(2)
raise RuntimeError(f"Stage completion callback failed after retries: task={task_id}, stage={stage_index}")
def post_result(self, task_id: int, shop_name: str, country_code: str, asin: str, status: dict,
is_done: bool = False):
"""回传处理结果到API
Args:
task_id: 任务ID
shop_name: 店铺名称
country_code: 国家代码
asin: ASIN
status: 处理状态
"""
url = f"{DELETE_BRAND_API_BASE}/api/query-asin/tasks/{task_id}/result"
payload = {
"shops": [
{
"shopName": shop_name,
"countryResults": [
{
"country": country_code,
"items": [
{
"asin": asin,
"status": status
}
]
}
],
"shopDone": is_done,
"submissionId": f"{int(time.time())}"
}
]
}
if is_done:
payload["shops"][0]["success"] = is_done
max_retries = 3
for retry in range(max_retries):
try:
print("================【跟价】=====================")
self.log(f"尝试回传结果 (第 {retry + 1}/{max_retries} 次)")
self.log(f"回传URL: {url}")
self.log(f"回传数据: {payload}")
response = requests.post(
url,
json=payload,
headers={"Content-Type": "application/json"},
timeout=30,
verify=False
)
self.log(f"回传结果: {response.text}")
data = response.json() if response.text else {}
if response.status_code == 200 and isinstance(data, dict) and data.get("success"):
self.log(f"结果回传成功: {asin} - {status}")
return
else:
self.log(f"结果回传失败,状态码: {response.status_code}", "WARNING")
print("=====================================")
except Exception as e:
self.log(f"调用API异常: {str(e)}", "ERROR")
print("=====================================")
# 如果还有重试机会,等待后继续
if retry < max_retries - 1:
time.sleep(2)
self.log(f"已达到最大重试次数,结果回传最终失败", "ERROR")
raise RuntimeError("已达到最大重试次数,结果回传最终失败")
if __name__ == '__main__':
# 使用示例
user_info = {
"company": "rongchuang123",
"username": "自动化_Robot",
"password": "#20zsg25"
}
shop_name = "魏振峰"
country = "德国"
kill_process('v6')
driver = AmzonePStatus(user_info)
browser = driver.open_shop(shop_name)
sw_suc = driver.SwitchingCountries(country)
driver.SwitchPage()
risk_listing_filter = "Active"
_shopMallName = "WEIZHENFENG168"
ap_asin = "B0F1N18XFW"
skip_asin = []
for _ in range(3):
try:
sku_ls = driver.search(filter_type=risk_listing_filter)
break
except Exception as e:
print(e)
driver.tab.refresh()
if len(sku_ls) > 0:
print("有数据,开始操作")
for asin, status in driver.run_page_action(
current_shop_name=_shopMallName,
appoint_asin=ap_asin, skip_asin=skip_asin
):
print(f"ASIN {asin} 的处理结果: {status}")
print("已完成操作")

View File

@@ -9,6 +9,7 @@ from amazon.del_brand import AmazoneDriver, kill_process
from amazon.approve import ApproveTask from amazon.approve import ApproveTask
from amazon.match_action import MatchTak from amazon.match_action import MatchTak
from amazon.price_match import PriceTask from amazon.price_match import PriceTask
from amazon.asin_status import StatusTask
from amazon.tool import get_shop_info,show_notification from amazon.tool import get_shop_info,show_notification
@@ -55,7 +56,8 @@ class TaskMonitor:
task_type_info = { task_type_info = {
"product-risk-resolve-run" : "产品风险审批", "product-risk-resolve-run" : "产品风险审批",
"shop-match-run" : "匹配价格", "shop-match-run" : "匹配价格",
"price-track-run" : "跟价" "price-track-run" : "跟价",
"query-asin-run" : "状态查询"
} }
try: try:
while self.running: while self.running:
@@ -122,7 +124,8 @@ class TaskMonitor:
TASK_INFO = { TASK_INFO = {
"产品风险审批" : ApproveTask, "产品风险审批" : ApproveTask,
"匹配价格" : MatchTak, "匹配价格" : MatchTak,
"跟价" : PriceTask "跟价" : PriceTask,
"状态查询" : StatusTask
} }
self.log(f"线程 {id(task_data)} 开始处理产品风险审批任务...") self.log(f"线程 {id(task_data)} 开始处理产品风险审批任务...")
# 创建ApproveTask实例并处理任务 # 创建ApproveTask实例并处理任务

View File

@@ -445,7 +445,7 @@ class MatchTak:
import traceback import traceback
self.log(f"处理国家 {country_code} 失败: {str(e)}", "ERROR") self.log(f"处理国家 {country_code} 失败: {str(e)}", "ERROR")
self.log(traceback.format_exc(), "ERROR") self.log(traceback.format_exc(), "ERROR")
if "与页面的连接已断开" in e: if "与页面的连接已断开" in str(e):
iskill = True iskill = True
# 更新已处理国家数 # 更新已处理国家数

View File

@@ -46,18 +46,30 @@ def calculate_target_price(
# 不是自己的购物车:直接将紫鸟的总和作为"第一名"价格,强制抛入阶梯跟价逻辑 # 不是自己的购物车:直接将紫鸟的总和作为"第一名"价格,强制抛入阶梯跟价逻辑
return calculate_standard_competitor_pricing(my_price, total_price) return calculate_standard_competitor_pricing(my_price, total_price)
# # 第一名是自己, 第二名 Amazon US 卖家 的情况
shop_name_1 = front_end_data.get("top_sellers")[0].get("shop_name")
shop_name_2 = front_end_data.get("top_sellers")[1].get("shop_name") if len(front_end_data.get("top_sellers")) >=2 else ""
if shop_name_1 == current_shop_name and "Amazon" in shop_name_2:
price_2 = float(front_end_data.get("top_sellers")[1].get("price")) # 实际第二名价格
diff = abs(my_price - price_2)
if diff < 5:
return price_2 -3
elif 5.0 < diff <= 8.0:
return price_2 -3
# --- Step 3: 常规核心分流逻辑 (如果没有紫鸟后台数据) --- # --- Step 3: 常规核心分流逻辑 (如果没有紫鸟后台数据) ---
# 分支 A第一名是 Amazon US 卖家 (特殊强敌优先) # 分支 A第一名是 Amazon US 卖家 (特殊强敌优先)
price_1 = float(front_end_data.get("top_sellers")[0].get("price")) # 实际第一名价格 price_1 = float(front_end_data.get("top_sellers")[0].get("price")) # 实际第一名价格
if "Amazon." in cart_seller: if "Amazon." in cart_seller:
diff = abs(my_price - price_1) diff = abs(my_price - price_1)
if diff <= 5.0 or my_price > price_1: if diff <= 5.0 :
return price_1 - 5.0 return None
elif 5.0 < diff <= 8.0: elif 5.0 < diff <= 8.0:
return price_1 - 3.0 return price_1 - 3.0
elif 8.0 < diff <= 12.0: elif diff > 8.0:
return None # 跳过,不跟价 return price_1 - 6 # 跳过,不跟价
# 分支 B不是 Amazon US且目前是自己的购物车 # 分支 B不是 Amazon US且目前是自己的购物车
@@ -78,7 +90,6 @@ def calculate_target_price(
else: else:
return None return None
# 分支 C不是 Amazon US也不是自己的购物车 # 分支 C不是 Amazon US也不是自己的购物车
else: else:
# 正常情况下的普通跟价,调用阶梯逻辑 # 正常情况下的普通跟价,调用阶梯逻辑
@@ -104,9 +115,10 @@ def calculate_standard_competitor_pricing(my_current_price, target_competitor_pr
return base_target - 1.0 return base_target - 1.0
elif 1.5 <= diff <= 2.5: elif 1.5 <= diff <= 2.5:
return base_target - 1.0 return base_target - 1.0
elif diff > 2.5: elif 2.5 < diff <= 3 :
return my_current_price # 差距过大,跳过不跟价 return None # 差距过大,跳过不跟价
elif diff > 3:
return base_target
@@ -586,18 +598,16 @@ class AmzonePriceMatch(AmamzonBase):
# 如果紫鸟里当前价格低于最低价则删除,否则跳过 # 如果紫鸟里当前价格低于最低价则删除,否则跳过
if mode == "status" and miniprice_info.get(asin): if mode == "status" and miniprice_info.get(asin):
backend_price = float(miniprice_info.get(asin)) backend_price = float(miniprice_info.get(asin))
if float(current_price) < backend_price: if float(current_price) <= backend_price:
# yield (asin, {
# "statu": "删除(当前价格低于最低价)",
# "deleteSkipAsin": True,
# "removeAsin": asin,
# })
yield (asin, { yield (asin, {
"statu": "删除(当前价格低于最低价)", "statu": "跳过(当前价格低于或等于最低价)",
"deleteSkipAsin": True, "currentPrice": current_price,
"removeAsin": asin, "minimumPrice": bottom_price, # 最低价格
})
continue
else:
yield (asin, {
"statu": "跳过(当前价格不低于最低价)",
"deleteSkipAsin": True,
"removeAsin": asin,
}) })
continue continue
@@ -924,6 +934,11 @@ class PriceTask:
skip_asins_by_country = shop_item.get("skip_asins_by_country",{}) skip_asins_by_country = shop_item.get("skip_asins_by_country",{})
asin_rows_by_country = shop_item.get("asin_rows_by_country",{}) asin_rows_by_country = shop_item.get("asin_rows_by_country",{})
skip_asin_details_by_country = shop_item.get("skip_asin_details_by_country",{}) skip_asin_details_by_country = shop_item.get("skip_asin_details_by_country",{})
minimum_price_by_country_and_asin = shop_item.get("minimum_price_by_country_and_asin",{})
if len(minimum_price_by_country_and_asin) > 0:
country_codes = minimum_price_by_country_and_asin.keys()
# 指定ASIN的情况
mode = shop_item.get("mode") mode = shop_item.get("mode")
if not company_name: if not company_name:
@@ -955,20 +970,25 @@ class PriceTask:
# 检查是否收到暂停请求 # 检查是否收到暂停请求
if task_id in runing_task and runing_task[task_id].get("stop_requested", False): if task_id in runing_task and runing_task[task_id].get("stop_requested", False):
self.log(f"检测到任务 {task_id} 的暂停请求,停止处理国家", "WARNING") self.log(f"检测到任务 {task_id} 的暂停请求,停止处理国家", "ERROR")
driver.close_store()
break break
skip_asin = skip_asins_by_country.get(country_code,[]) #需要跳过的asin skip_asin = skip_asins_by_country.get(country_code,[]) #需要跳过的asin
appoint_asin = asin_rows_by_country.get(country_code,[]) appoint_asin = asin_rows_by_country.get(country_code,[])
miniprice_info = {i.get('asin'):i.get('minimumPrice') for i in skip_asin_details_by_country.get(country_code,[])} if mode == "status":
miniprice_info = {i.get('asin'):i.get('minimumPrice') for i in skip_asin_details_by_country.get(country_code,[])}
else:
miniprice_info = minimum_price_by_country_and_asin.get(country_code,{})
try: try:
self.process_country(driver, country_code, task_id, shop_name, risk_listing_filter, self.process_country(driver, country_code, task_id, shop_name, risk_listing_filter,
shopMallName=shopMallName,skip_asin=skip_asin, limit=limit, shopMallName=shopMallName,skip_asin=skip_asin, limit=limit,
appoint_asin=appoint_asin,mode=mode,miniprice_info=miniprice_info) appoint_asin=appoint_asin,mode=mode,miniprice_info=miniprice_info,
)
except Exception as e: except Exception as e:
import traceback import traceback
self.log(f"处理国家 {country_code} 失败: {str(e)}", "ERROR") self.log(f"处理国家 {country_code} 失败: {str(e)}", "ERROR")
self.log(traceback.format_exc(), "ERROR") self.log(traceback.format_exc(), "ERROR")
if "与页面的连接已断开" in e: if "与页面的连接已断开" in str(e):
iskill = True iskill = True
# 更新已处理国家数 # 更新已处理国家数
if task_id in runing_task: if task_id in runing_task:
@@ -994,8 +1014,7 @@ class PriceTask:
def process_country(self, driver: AmzonePriceMatch, country_code: str, task_id: int, shop_name: str, def process_country(self, driver: AmzonePriceMatch, country_code: str, task_id: int, shop_name: str,
risk_listing_filter: str,shopMallName:str,skip_asin:list,appoint_asin:list,mode:str, risk_listing_filter: str,shopMallName:str,skip_asin:list,appoint_asin:list,mode:str,
miniprice_info:dict={}, miniprice_info:dict={}, limit: str = None):
limit: str = None):
"""处理单个国家的审批任务 """处理单个国家的审批任务
Args: Args:
@@ -1113,7 +1132,7 @@ class PriceTask:
for i in range(max_range): for i in range(max_range):
if len(appoint_asin) > i: if len(appoint_asin) > i:
_shopMallName = appoint_asin[i]["shopMallName"] _shopMallName = appoint_asin[i]["shopMallName"]
ap_asin = appoint_asin[i]["shopMallName"] ap_asin = appoint_asin[i]["asin"]
else: else:
_shopMallName = shopMallName _shopMallName = shopMallName
ap_asin = None ap_asin = None
@@ -1278,11 +1297,11 @@ class PriceTask:
if __name__ == '__main__': if __name__ == '__main__':
# 使用示例 # 使用示例
user_info = { user_info = {
"company": "rongchuang123", "company": "尾号5578的公司115",
"username": "自动化_Robot", "username": "自动化_Robot",
"password": "#20zsg25" "password": "#20zsg25"
} }
shop_name = "魏振峰" shop_name = "林建华"
country = "德国" country = "德国"
kill_process('v6') kill_process('v6')
driver = AmzonePriceMatch(user_info) driver = AmzonePriceMatch(user_info)
@@ -1291,7 +1310,7 @@ if __name__ == '__main__':
driver.SwitchPage() driver.SwitchPage()
risk_listing_filter = "Active" risk_listing_filter = "Active"
_shopMallName = "WEIZHENFENG168" _shopMallName = "WEIZHENFENG168"
ap_asin ="B0F1N18XFW" ap_asin ="B0FHGQY18W"
skip_asin = [] skip_asin = []
for _ in range(3): for _ in range(3):
try: try:
@@ -1304,7 +1323,7 @@ if __name__ == '__main__':
print("有数据,开始操作") print("有数据,开始操作")
for asin, status in driver.run_page_action( for asin, status in driver.run_page_action(
current_shop_name=_shopMallName, current_shop_name=_shopMallName,
appoint_asin=ap_asin,skip_asin=skip_asin appoint_asin=ap_asin,skip_asin=skip_asin,mode="asin"
): ):
print(f"ASIN {asin} 的处理结果: {status}") print(f"ASIN {asin} 的处理结果: {status}")
print("已完成操作") print("已完成操作")

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -433,7 +433,7 @@ def main():
webview.start( webview.start(
debug=True, debug=True,
storage_path=cache_path, storage_path=cache_path,
private_mode=True private_mode=False
) )

View File

@@ -5,9 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>格式转换 - 数富AI</title> <title>格式转换 - 数富AI</title>
<script type="module" crossorigin src="/assets/convert.js"></script> <script type="module" crossorigin src="/assets/convert.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-9YBa--7x.js"> <link rel="modulepreload" crossorigin href="/assets/pywebview-CeWJDVeG.js">
<link rel="modulepreload" crossorigin href="/assets/brand-CRmRvyGD.js"> <link rel="modulepreload" crossorigin href="/assets/brand-UU-ckLq6.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-D74jYk6P.css"> <link rel="stylesheet" crossorigin href="/assets/pywebview-ij4pgMq8.css">
<link rel="stylesheet" crossorigin href="/assets/convert-7wWJ02Tw.css"> <link rel="stylesheet" crossorigin href="/assets/convert-7wWJ02Tw.css">
</head> </head>
<body> <body>

View File

@@ -5,9 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>数据去重 - 数富AI</title> <title>数据去重 - 数富AI</title>
<script type="module" crossorigin src="/assets/dedupe.js"></script> <script type="module" crossorigin src="/assets/dedupe.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-9YBa--7x.js"> <link rel="modulepreload" crossorigin href="/assets/pywebview-CeWJDVeG.js">
<link rel="modulepreload" crossorigin href="/assets/brand-CRmRvyGD.js"> <link rel="modulepreload" crossorigin href="/assets/brand-UU-ckLq6.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-D74jYk6P.css"> <link rel="stylesheet" crossorigin href="/assets/pywebview-ij4pgMq8.css">
<link rel="stylesheet" crossorigin href="/assets/dedupe-BpNHwt51.css"> <link rel="stylesheet" crossorigin href="/assets/dedupe-BpNHwt51.css">
</head> </head>
<body> <body>

View File

@@ -5,9 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>删除品牌 - 数富AI</title> <title>删除品牌 - 数富AI</title>
<script type="module" crossorigin src="/assets/delete-brand.js"></script> <script type="module" crossorigin src="/assets/delete-brand.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-9YBa--7x.js"> <link rel="modulepreload" crossorigin href="/assets/pywebview-CeWJDVeG.js">
<link rel="modulepreload" crossorigin href="/assets/brand-CRmRvyGD.js"> <link rel="modulepreload" crossorigin href="/assets/brand-UU-ckLq6.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-D74jYk6P.css"> <link rel="stylesheet" crossorigin href="/assets/pywebview-ij4pgMq8.css">
<link rel="stylesheet" crossorigin href="/assets/delete-brand-CWLpe7lu.css"> <link rel="stylesheet" crossorigin href="/assets/delete-brand-CWLpe7lu.css">
</head> </head>
<body> <body>

View File

@@ -5,9 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>数据拆分 - 数富AI</title> <title>数据拆分 - 数富AI</title>
<script type="module" crossorigin src="/assets/split.js"></script> <script type="module" crossorigin src="/assets/split.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-9YBa--7x.js"> <link rel="modulepreload" crossorigin href="/assets/pywebview-CeWJDVeG.js">
<link rel="modulepreload" crossorigin href="/assets/brand-CRmRvyGD.js"> <link rel="modulepreload" crossorigin href="/assets/brand-UU-ckLq6.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-D74jYk6P.css"> <link rel="stylesheet" crossorigin href="/assets/pywebview-ij4pgMq8.css">
<link rel="stylesheet" crossorigin href="/assets/split-CRUIYKS6.css"> <link rel="stylesheet" crossorigin href="/assets/split-CRUIYKS6.css">
</head> </head>
<body> <body>

View File

@@ -414,6 +414,7 @@
<a class="nav-item" href="/new_web_source/shop-match.html">定时匹配</a> <a class="nav-item" href="/new_web_source/shop-match.html">定时匹配</a>
<a class="nav-item" href="/new_web_source/price-track.html">跟价</a> <a class="nav-item" href="/new_web_source/price-track.html">跟价</a>
<span class="nav-item disabled">巡店删除</span> <span class="nav-item disabled">巡店删除</span>
<a class="nav-item" href="/new_web_source/query-asin.html">查询ASIN</a>
<span class="nav-item disabled">取款</span> <span class="nav-item disabled">取款</span>
<span class="nav-item disabled">店铺状态查询</span> <span class="nav-item disabled">店铺状态查询</span>
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@@ -11,5 +11,5 @@ import java.util.List;
public class ModuleCleanupProperties { public class ModuleCleanupProperties {
private boolean enabled = true; private boolean enabled = true;
private String cron = "0 0 0 * * *"; private String cron = "0 0 0 * * *";
private List<String> moduleTypes = new ArrayList<>(List.of("DEDUPE", "SPLIT", "CONVERT", "DELETE_BRAND", "PRODUCT_RISK_RESOLVE", "PRICE_TRACK", "SHOP_MATCH", "PATROL_DELETE")); private List<String> moduleTypes = new ArrayList<>(List.of("DEDUPE", "SPLIT", "CONVERT", "DELETE_BRAND", "PRODUCT_RISK_RESOLVE", "PRICE_TRACK", "SHOP_MATCH", "PATROL_DELETE", "QUERY_ASIN"));
} }

View File

@@ -3,6 +3,7 @@ package com.nanri.aiimage.modules.dedupe.service;
import cn.hutool.core.io.FileUtil; import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.IdUtil; import cn.hutool.core.util.IdUtil;
import cn.hutool.json.JSONUtil; import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.nanri.aiimage.common.exception.BusinessException; import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.config.StorageProperties; import com.nanri.aiimage.config.StorageProperties;
import com.nanri.aiimage.modules.dedupe.model.dto.DedupeRunRequest; import com.nanri.aiimage.modules.dedupe.model.dto.DedupeRunRequest;
@@ -15,11 +16,13 @@ import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity; import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.Cell; import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.DataFormatter; import org.apache.poi.ss.usermodel.DataFormatter;
import org.apache.poi.ss.usermodel.Row; import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet; import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook; import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.util.WorkbookUtil;
import org.apache.poi.xssf.streaming.SXSSFWorkbook; import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
@@ -39,12 +42,14 @@ import java.util.Set;
import java.util.zip.ZipEntry; import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream; import java.util.zip.ZipOutputStream;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j
public class DedupeRunService { public class DedupeRunService {
private static final String HEADER_SPLIT_MARKER = "idASIN\u56fd\u5bb6\u72b6\u6001\u4ef7\u683c\u53d8\u4f53\u6570\u91cf";
private static final String HEADER_STOP_COLUMN = "\u7f29\u7565\u56fe\u5730\u57408";
private final FileTaskMapper fileTaskMapper; private final FileTaskMapper fileTaskMapper;
private final FileResultMapper fileResultMapper; private final FileResultMapper fileResultMapper;
private final StorageProperties storageProperties; private final StorageProperties storageProperties;
@@ -52,8 +57,9 @@ public class DedupeRunService {
private final DedupeTotalDataService dedupeTotalDataService; private final DedupeTotalDataService dedupeTotalDataService;
public DedupeRunVo run(DedupeRunRequest request) { public DedupeRunVo run(DedupeRunRequest request) {
long runStartNs = System.nanoTime();
if (!request.isKeepIntegerIds() && !request.isKeepUnderscoreIds() && !request.isKeepIntegerMainIdsWhenNoSubIds()) { if (!request.isKeepIntegerIds() && !request.isKeepUnderscoreIds() && !request.isKeepIntegerMainIdsWhenNoSubIds()) {
throw new BusinessException("请至少选择一种 ID 保留规则"); throw new BusinessException("\u8bf7\u81f3\u5c11\u9009\u62e9\u4e00\u79cd ID \u4fdd\u7559\u89c4\u5219");
} }
FileTaskEntity task = new FileTaskEntity(); FileTaskEntity task = new FileTaskEntity();
@@ -80,9 +86,11 @@ public class DedupeRunService {
DedupeResultItemVo item = new DedupeResultItemVo(); DedupeResultItemVo item = new DedupeResultItemVo();
item.setSourceFilename(sourceFile.getOriginalFilename()); item.setSourceFilename(sourceFile.getOriginalFilename());
try { try {
long fileStartNs = System.nanoTime();
File inputFile = findLocalSourceFile(sourceFile.getFileKey()); File inputFile = findLocalSourceFile(sourceFile.getFileKey());
long findFileNs = elapsedNs(fileStartNs);
if (inputFile == null || !inputFile.exists()) { if (inputFile == null || !inputFile.exists()) {
throw new BusinessException("上传文件不存在,请重新上传"); throw new BusinessException("\u4e0a\u4f20\u6587\u4ef6\u4e0d\u5b58\u5728\uff0c\u8bf7\u91cd\u65b0\u4e0a\u4f20");
} }
String inputName = sourceFile.getOriginalFilename() == null ? inputFile.getName() : sourceFile.getOriginalFilename(); String inputName = sourceFile.getOriginalFilename() == null ? inputFile.getName() : sourceFile.getOriginalFilename();
@@ -90,6 +98,7 @@ public class DedupeRunService {
File outputDir = FileUtil.mkdir(FileUtil.file(storageProperties.getLocalTempDir(), "dedupe-result")); File outputDir = FileUtil.mkdir(FileUtil.file(storageProperties.getLocalTempDir(), "dedupe-result"));
File outputFile = buildNamedOutputFile(outputDir, outputFilename); File outputFile = buildNamedOutputFile(outputDir, outputFilename);
long cleanStartNs = System.nanoTime();
cleanExcelByLegacyRules( cleanExcelByLegacyRules(
inputFile, inputFile,
outputFile, outputFile,
@@ -98,6 +107,7 @@ public class DedupeRunService {
request.isKeepUnderscoreIds(), request.isKeepUnderscoreIds(),
request.isKeepIntegerMainIdsWhenNoSubIds() request.isKeepIntegerMainIdsWhenNoSubIds()
); );
long cleanNs = elapsedNs(cleanStartNs);
if (folderMode) { if (folderMode) {
archiveEntries.add(new DedupeArchiveEntry(sourceFile.getRelativePath(), inputName, outputFile)); archiveEntries.add(new DedupeArchiveEntry(sourceFile.getRelativePath(), inputName, outputFile));
@@ -105,13 +115,15 @@ public class DedupeRunService {
continue; continue;
} }
String ossObjectKey = ossStorageService.uploadResultFile(outputFile, "DEDUPE"); long uploadStartNs = System.nanoTime();
// 只存 objectKey不存预签名 URL OssStorageService.UploadedResult uploadedResult = ossStorageService.uploadResultFileWithFreshDownloadUrl(outputFile, "DEDUPE");
long uploadNs = elapsedNs(uploadStartNs);
String ossObjectKey = uploadedResult.objectKey();
String downloadFilename = request.getFiles().size() == 1 ? inputName : outputFile.getName(); String downloadFilename = request.getFiles().size() == 1 ? inputName : outputFile.getName();
item.setSuccess(true); item.setSuccess(true);
item.setOutputFilename(downloadFilename); item.setOutputFilename(downloadFilename);
item.setDownloadUrl(ossStorageService.generateFreshDownloadUrl(ossObjectKey)); item.setDownloadUrl(uploadedResult.downloadUrl());
successCount++; successCount++;
FileResultEntity resultEntity = new FileResultEntity(); FileResultEntity resultEntity = new FileResultEntity();
@@ -119,13 +131,26 @@ public class DedupeRunService {
resultEntity.setModuleType("DEDUPE"); resultEntity.setModuleType("DEDUPE");
resultEntity.setSourceFilename(inputName); resultEntity.setSourceFilename(inputName);
resultEntity.setResultFilename(downloadFilename); resultEntity.setResultFilename(downloadFilename);
resultEntity.setResultFileUrl(ossObjectKey); // 存 objectKey resultEntity.setResultFileUrl(ossObjectKey);
resultEntity.setResultFileSize(outputFile.length()); resultEntity.setResultFileSize(outputFile.length());
resultEntity.setResultContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); resultEntity.setResultContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
resultEntity.setSuccess(1); resultEntity.setSuccess(1);
resultEntity.setUserId(request.getUserId()); resultEntity.setUserId(request.getUserId());
resultEntity.setCreatedAt(LocalDateTime.now()); resultEntity.setCreatedAt(LocalDateTime.now());
long resultInsertStartNs = System.nanoTime();
fileResultMapper.insert(resultEntity); fileResultMapper.insert(resultEntity);
long resultInsertNs = elapsedNs(resultInsertStartNs);
log.info(
"dedupe run file done fileKey={} filename={} size={} findFileMs={} cleanMs={} uploadMs={} resultInsertMs={} totalFileMs={}",
sourceFile.getFileKey(),
inputName,
inputFile.length(),
toMs(findFileNs),
toMs(cleanNs),
toMs(uploadNs),
toMs(resultInsertNs),
toMs(elapsedNs(fileStartNs))
);
} catch (Exception ex) { } catch (Exception ex) {
item.setSuccess(false); item.setSuccess(false);
item.setError(ex.getMessage()); item.setError(ex.getMessage());
@@ -146,20 +171,20 @@ public class DedupeRunService {
if (folderMode && !archiveEntries.isEmpty()) { if (folderMode && !archiveEntries.isEmpty()) {
File zipFile = packageFolderDedupeResultsAsZip(request.getArchiveName(), archiveEntries); File zipFile = packageFolderDedupeResultsAsZip(request.getArchiveName(), archiveEntries);
String ossObjectKey = ossStorageService.uploadResultFile(zipFile, "DEDUPE"); OssStorageService.UploadedResult uploadedResult = ossStorageService.uploadResultFileWithFreshDownloadUrl(zipFile, "DEDUPE");
// 只存 objectKey String ossObjectKey = uploadedResult.objectKey();
DedupeResultItemVo item = new DedupeResultItemVo(); DedupeResultItemVo item = new DedupeResultItemVo();
item.setSourceFilename(request.getArchiveName()); item.setSourceFilename(request.getArchiveName());
item.setOutputFilename(zipFile.getName()); item.setOutputFilename(zipFile.getName());
item.setSuccess(true); item.setSuccess(true);
item.setDownloadUrl(ossStorageService.generateFreshDownloadUrl(ossObjectKey)); item.setDownloadUrl(uploadedResult.downloadUrl());
FileResultEntity resultEntity = new FileResultEntity(); FileResultEntity resultEntity = new FileResultEntity();
resultEntity.setTaskId(task.getId()); resultEntity.setTaskId(task.getId());
resultEntity.setModuleType("DEDUPE"); resultEntity.setModuleType("DEDUPE");
resultEntity.setSourceFilename(request.getArchiveName()); resultEntity.setSourceFilename(request.getArchiveName());
resultEntity.setResultFilename(zipFile.getName()); resultEntity.setResultFilename(zipFile.getName());
resultEntity.setResultFileUrl(ossObjectKey); // 存 objectKey resultEntity.setResultFileUrl(ossObjectKey);
resultEntity.setResultFileSize(zipFile.length()); resultEntity.setResultFileSize(zipFile.length());
resultEntity.setResultContentType("application/zip"); resultEntity.setResultContentType("application/zip");
resultEntity.setSuccess(1); resultEntity.setSuccess(1);
@@ -181,6 +206,14 @@ public class DedupeRunService {
vo.setSuccessCount(successCount); vo.setSuccessCount(successCount);
vo.setFailedCount(failedCount); vo.setFailedCount(failedCount);
vo.setItems(items); vo.setItems(items);
log.info(
"dedupe run done userId={} files={} success={} failed={} totalMs={}",
request.getUserId(),
request.getFiles().size(),
successCount,
failedCount,
toMs(elapsedNs(runStartNs))
);
return vo; return vo;
} }
@@ -209,7 +242,7 @@ public class DedupeRunService {
public void deleteHistory(Long resultId, Long userId) { public void deleteHistory(Long resultId, Long userId) {
FileResultEntity entity = fileResultMapper.selectById(resultId); FileResultEntity entity = fileResultMapper.selectById(resultId);
if (entity == null || !"DEDUPE".equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) { if (entity == null || !"DEDUPE".equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
throw new BusinessException("记录不存在"); throw new BusinessException("\u8bb0\u5f55\u4e0d\u5b58\u5728");
} }
fileResultMapper.deleteById(resultId); fileResultMapper.deleteById(resultId);
} }
@@ -217,115 +250,64 @@ public class DedupeRunService {
public Resource getResultFile(Long resultId, Long userId) { public Resource getResultFile(Long resultId, Long userId) {
FileResultEntity entity = fileResultMapper.selectById(resultId); FileResultEntity entity = fileResultMapper.selectById(resultId);
if (entity == null || entity.getResultFileUrl() == null || !"DEDUPE".equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) { if (entity == null || entity.getResultFileUrl() == null || !"DEDUPE".equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
throw new BusinessException("结果文件不存在"); throw new BusinessException("\u7ed3\u679c\u6587\u4ef6\u4e0d\u5b58\u5728");
} }
File file = new File(entity.getResultFileUrl()); File file = new File(entity.getResultFileUrl());
if (!file.exists()) { if (!file.exists()) {
throw new BusinessException("结果文件不存在"); throw new BusinessException("\u7ed3\u679c\u6587\u4ef6\u4e0d\u5b58\u5728");
} }
return new FileSystemResource(file); return new FileSystemResource(file);
} }
private void cleanExcelByLegacyRules(File inputFile, File outputFile, List<String> selectedColumns, private void cleanExcelByLegacyRules(File inputFile, File outputFile, List<String> selectedColumns,
boolean keepIntegerIds, boolean keepUnderscoreIds, boolean keepIntegerIds, boolean keepUnderscoreIds,
boolean keepIntegerMainIdsWhenNoSubIds) throws Exception { boolean keepIntegerMainIdsWhenNoSubIds) throws Exception {
DataFormatter formatter = new DataFormatter(); long readStartNs = System.nanoTime();
try (FileInputStream fis = new FileInputStream(inputFile); DedupeReadResult readResult = readDedupeRows(
Workbook workbook = org.apache.poi.ss.usermodel.WorkbookFactory.create(fis); inputFile,
SXSSFWorkbook outputWorkbook = new SXSSFWorkbook(200)) { selectedColumns,
Sheet sheet = workbook.getSheetAt(0); keepIntegerIds,
Row headerRow = sheet.getRow(0); keepUnderscoreIds,
if (headerRow == null) { keepIntegerMainIdsWhenNoSubIds
throw new BusinessException("Excel 表头为空"); );
} long readNs = elapsedNs(readStartNs);
Map<String, Integer> headerMap = new HashMap<>(); Set<String> candidateAsinValues = new HashSet<>();
for (int i = 0; i < headerRow.getLastCellNum(); i++) { for (DedupeCandidateRow row : readResult.rows()) {
Cell cell = headerRow.getCell(i); if (!row.asinValue().isBlank()) {
String value = normalizeCellText(cell == null ? null : formatter.formatCellValue(cell)); candidateAsinValues.add(row.asinValue());
if (value.isBlank()) {
continue;
}
value = value.split("idASIN国家状态价格变体数量", 2)[0].trim().isEmpty()
? value
: value.split("idASIN国家状态价格变体数量", 2)[0].trim();
if (headerMap.containsKey(value)) {
continue;
}
headerMap.put(value, i);
if ("缩略图地址8".equals(value)) {
break;
}
} }
}
long dbStartNs = System.nanoTime();
Set<String> matchedAsinValues = dedupeTotalDataService.findExistingComparableValues(candidateAsinValues);
long dbNs = elapsedNs(dbStartNs);
List<String> missing = selectedColumns.stream().filter(column -> !headerMap.containsKey(column)).toList(); long writeStartNs = System.nanoTime();
if (!missing.isEmpty()) { int outputRows = 0;
throw new BusinessException("缺少列:" + String.join("", missing)); try (SXSSFWorkbook outputWorkbook = new SXSSFWorkbook(200)) {
} org.apache.poi.ss.usermodel.Sheet outputSheet = outputWorkbook.createSheet(WorkbookUtil.createSafeSheetName(readResult.sheetName()));
Sheet outputSheet = outputWorkbook.createSheet(sheet.getSheetName());
Row outputHeaderRow = outputSheet.createRow(0); Row outputHeaderRow = outputSheet.createRow(0);
for (int i = 0; i < selectedColumns.size(); i++) { for (int i = 0; i < selectedColumns.size(); i++) {
outputHeaderRow.createCell(i).setCellValue(selectedColumns.get(i)); outputHeaderRow.createCell(i).setCellValue(selectedColumns.get(i));
} }
Integer idColumnIndex = headerMap.get("id");
Integer asinColumnIndex = headerMap.get("ASIN");
if (idColumnIndex != null) {
// The preliminary global scan for mainIdHasSubIdMap was removed.
// We now determine subset existence contextually (per group) during the main loop.
}
Set<String> candidateAsinValues = new HashSet<>();
for (int rowNum = 1; rowNum <= sheet.getLastRowNum(); rowNum++) {
Row row = sheet.getRow(rowNum);
if (row == null) {
continue;
}
if (idColumnIndex != null) {
String idValue = normalizeCellText(formatter.formatCellValue(row.getCell(idColumnIndex)));
if (!shouldKeepId(idValue, keepIntegerIds, keepUnderscoreIds, keepIntegerMainIdsWhenNoSubIds, sheet, rowNum, idColumnIndex, formatter)) {
continue;
}
}
if (asinColumnIndex != null) {
String asinValue = dedupeTotalDataService.normalizeComparableValueOrBlank(formatter.formatCellValue(row.getCell(asinColumnIndex)));
if (!asinValue.isBlank()) {
candidateAsinValues.add(asinValue);
}
}
}
Set<String> matchedAsinValues = dedupeTotalDataService.findExistingComparableValues(candidateAsinValues);
Set<String> writtenAsinValues = new HashSet<>(); Set<String> writtenAsinValues = new HashSet<>();
int outputRowIndex = 1; int outputRowIndex = 1;
for (int rowNum = 1; rowNum <= sheet.getLastRowNum(); rowNum++) { for (DedupeCandidateRow candidateRow : readResult.rows()) {
Row row = sheet.getRow(rowNum); String asinValue = candidateRow.asinValue();
if (row == null) { if (!asinValue.isBlank()) {
continue; if (!matchedAsinValues.isEmpty() && matchedAsinValues.contains(asinValue)) {
}
if (idColumnIndex != null) {
String idValue = normalizeCellText(formatter.formatCellValue(row.getCell(idColumnIndex)));
if (!shouldKeepId(idValue, keepIntegerIds, keepUnderscoreIds, keepIntegerMainIdsWhenNoSubIds, sheet, rowNum, idColumnIndex, formatter)) {
continue; continue;
} }
} if (!writtenAsinValues.add(asinValue)) {
if (asinColumnIndex != null) { continue;
String asinValue = dedupeTotalDataService.normalizeComparableValueOrBlank(formatter.formatCellValue(row.getCell(asinColumnIndex)));
if (!asinValue.isBlank()) {
if (!matchedAsinValues.isEmpty() && matchedAsinValues.contains(asinValue)) {
continue;
}
if (!writtenAsinValues.add(asinValue)) {
continue;
}
} }
} }
Row outputRow = outputSheet.createRow(outputRowIndex++); Row outputRow = outputSheet.createRow(outputRowIndex++);
for (int i = 0; i < selectedColumns.size(); i++) { outputRows++;
Integer sourceIndex = headerMap.get(selectedColumns.get(i)); for (int i = 0; i < candidateRow.selectedValues().size(); i++) {
String value = sourceIndex == null ? "" : normalizeCellText(formatter.formatCellValue(row.getCell(sourceIndex))); outputRow.createCell(i).setCellValue(candidateRow.selectedValues().get(i));
outputRow.createCell(i).setCellValue(value);
} }
} }
@@ -334,6 +316,157 @@ public class DedupeRunService {
} }
outputWorkbook.dispose(); outputWorkbook.dispose();
} }
long writeNs = elapsedNs(writeStartNs);
log.info(
"dedupe clean stages file={} scannedRows={} keptRows={} uniqueAsins={} matchedAsins={} outputRows={} readFilterMs={} dbMs={} writeMs={} totalCleanMs={}",
inputFile.getName(),
readResult.scannedRows(),
readResult.rows().size(),
candidateAsinValues.size(),
matchedAsinValues.size(),
outputRows,
toMs(readNs),
toMs(dbNs),
toMs(writeNs),
toMs(readNs + dbNs + writeNs)
);
}
private DedupeReadResult readDedupeRows(File inputFile, List<String> selectedColumns,
boolean keepIntegerIds, boolean keepUnderscoreIds,
boolean keepIntegerMainIdsWhenNoSubIds) throws Exception {
List<DedupeCandidateRow> rows = new ArrayList<>();
PendingMainIdGroup pendingMainIdGroup = new PendingMainIdGroup();
DataFormatter formatter = new DataFormatter();
try (FileInputStream fis = new FileInputStream(inputFile);
Workbook workbook = org.apache.poi.ss.usermodel.WorkbookFactory.create(fis)) {
Sheet sheet = workbook.getSheetAt(0);
Row headerRow = sheet.getRow(0);
if (headerRow == null) {
throw new BusinessException("Excel \u8868\u5934\u4e3a\u7a7a");
}
Map<String, Integer> headerMap = buildHeaderMap(headerRow, formatter);
if (headerMap.isEmpty()) {
throw new BusinessException("Excel \u8868\u5934\u4e3a\u7a7a");
}
List<String> missing = selectedColumns.stream()
.filter(column -> !headerMap.containsKey(column))
.toList();
if (!missing.isEmpty()) {
throw new BusinessException("\u7f3a\u5c11\u5217\uff1a" + String.join("\u3001", missing));
}
List<Integer> selectedIndexes = new ArrayList<>(selectedColumns.size());
for (String selectedColumn : selectedColumns) {
selectedIndexes.add(headerMap.get(selectedColumn));
}
Integer idColumnIndex = headerMap.get("id");
Integer asinColumnIndex = headerMap.get("ASIN");
int scannedRows = 0;
for (int rowNum = 1; rowNum <= sheet.getLastRowNum(); rowNum++) {
Row row = sheet.getRow(rowNum);
if (row == null) {
continue;
}
scannedRows++;
if (idColumnIndex == null) {
rows.add(buildCandidateRow(row, selectedIndexes, asinColumnIndex, formatter));
continue;
}
appendRowByIdRule(
normalizeCellText(formatter.formatCellValue(row.getCell(idColumnIndex))),
row,
selectedIndexes,
asinColumnIndex,
formatter,
keepIntegerIds,
keepUnderscoreIds,
keepIntegerMainIdsWhenNoSubIds,
pendingMainIdGroup,
rows
);
}
pendingMainIdGroup.flush(rows);
return new DedupeReadResult(sheet.getSheetName(), rows, scannedRows);
}
}
private Map<String, Integer> buildHeaderMap(Row headerRow, DataFormatter formatter) {
Map<String, Integer> headerMap = new HashMap<>();
for (int i = 0; i < headerRow.getLastCellNum(); i++) {
Cell cell = headerRow.getCell(i);
String value = normalizeHeaderText(cell == null ? null : formatter.formatCellValue(cell));
if (value.isBlank()) {
continue;
}
if (headerMap.containsKey(value)) {
continue;
}
headerMap.put(value, i);
if (HEADER_STOP_COLUMN.equals(value)) {
break;
}
}
return headerMap;
}
private String normalizeHeaderText(String value) {
String normalized = normalizeCellText(value);
int markerIndex = normalized.indexOf(HEADER_SPLIT_MARKER);
if (markerIndex > 0) {
String prefix = normalized.substring(0, markerIndex).trim();
if (!prefix.isBlank()) {
return prefix;
}
}
return normalized;
}
private DedupeCandidateRow buildCandidateRow(Row row, List<Integer> selectedIndexes, Integer asinColumnIndex, DataFormatter formatter) {
List<String> selectedValues = new ArrayList<>(selectedIndexes.size());
for (Integer selectedIndex : selectedIndexes) {
selectedValues.add(normalizeCellText(formatter.formatCellValue(row.getCell(selectedIndex))));
}
String asinValue = asinColumnIndex == null
? ""
: dedupeTotalDataService.normalizeComparableValueOrBlank(formatter.formatCellValue(row.getCell(asinColumnIndex)));
return new DedupeCandidateRow(selectedValues, asinValue);
}
private void appendRowByIdRule(String idValue, Row row,
List<Integer> selectedIndexes, Integer asinColumnIndex,
DataFormatter formatter,
boolean keepIntegerIds, boolean keepUnderscoreIds,
boolean keepIntegerMainIdsWhenNoSubIds,
PendingMainIdGroup pendingMainIdGroup,
List<DedupeCandidateRow> rows) {
if (idValue == null || idValue.isBlank()) {
return;
}
String mainId = extractMainId(idValue);
if (pendingMainIdGroup.hasDifferentMainId(mainId)) {
pendingMainIdGroup.flush(rows);
}
if (isUnderscoreId(idValue)) {
pendingMainIdGroup.discardIfSameMainId(mainId);
if (keepUnderscoreIds) {
rows.add(buildCandidateRow(row, selectedIndexes, asinColumnIndex, formatter));
}
return;
}
if (isIntegerId(idValue)) {
if (keepIntegerIds) {
rows.add(buildCandidateRow(row, selectedIndexes, asinColumnIndex, formatter));
return;
}
if (keepIntegerMainIdsWhenNoSubIds) {
pendingMainIdGroup.add(mainId, buildCandidateRow(row, selectedIndexes, asinColumnIndex, formatter));
}
}
} }
private File packageFolderDedupeResultsAsZip(String archiveName, List<DedupeArchiveEntry> archiveEntries) { private File packageFolderDedupeResultsAsZip(String archiveName, List<DedupeArchiveEntry> archiveEntries) {
@@ -347,7 +480,7 @@ public class DedupeRunService {
zos.closeEntry(); zos.closeEntry();
} }
} catch (Exception ex) { } catch (Exception ex) {
throw new BusinessException("去重结果打包失败:" + ex.getMessage()); throw new BusinessException("\u53bb\u91cd\u7ed3\u679c\u6253\u5305\u5931\u8d25\uff1a" + ex.getMessage());
} }
return zipFile; return zipFile;
} }
@@ -368,67 +501,61 @@ public class DedupeRunService {
return String.join("/", parts); return String.join("/", parts);
} }
private boolean shouldKeepId(String text, boolean keepIntegerIds, boolean keepUnderscoreIds,
boolean keepIntegerMainIdsWhenNoSubIds, Sheet sheet, int currentRowNum, int idColumnIndex, DataFormatter formatter) {
if (text == null || text.isBlank()) {
return false;
}
if (keepUnderscoreIds && text.matches("\\d+_\\d+")) {
return true;
}
if (keepIntegerIds && text.matches("\\d+")) {
return true;
}
if (keepIntegerMainIdsWhenNoSubIds && text.matches("\\d+")) {
String mainId = extractMainId(text);
if (mainId.isEmpty()) return false;
boolean hasSubIdInGroup = false;
for (int r = currentRowNum + 1; r <= sheet.getLastRowNum(); r++) {
Row nextRow = sheet.getRow(r);
if (nextRow == null) continue;
Cell cell = nextRow.getCell(idColumnIndex);
if (cell == null) continue;
String nextIdValue = normalizeCellText(formatter.formatCellValue(cell));
if (nextIdValue.isBlank()) continue;
String nextMainId = extractMainId(nextIdValue);
if (nextMainId.equals(mainId)) {
if (nextIdValue.matches("\\d+_\\d+")) {
hasSubIdInGroup = true;
break;
}
} else {
// Encountered a different main ID. The group for 'mainId' has ended.
break;
}
}
return !hasSubIdInGroup;
}
return false;
}
private String extractMainId(String text) { private String extractMainId(String text) {
if (text == null || text.isBlank()) { if (text == null || text.isBlank()) {
return ""; return "";
} }
if (text.matches("\\d+")) { if (isIntegerId(text)) {
return text; return text;
} }
if (text.matches("\\d+_\\d+")) { if (isUnderscoreId(text)) {
int idx = text.indexOf('_'); int idx = text.indexOf('_');
return idx > 0 ? text.substring(0, idx) : ""; return idx > 0 ? text.substring(0, idx) : "";
} }
return ""; return "";
} }
private boolean isIntegerId(String text) {
if (text == null || text.isEmpty()) {
return false;
}
for (int i = 0; i < text.length(); i++) {
if (!Character.isDigit(text.charAt(i))) {
return false;
}
}
return true;
}
private boolean isUnderscoreId(String text) {
if (text == null || text.length() < 3) {
return false;
}
int underscoreIndex = text.indexOf('_');
if (underscoreIndex <= 0 || underscoreIndex == text.length() - 1 || text.indexOf('_', underscoreIndex + 1) >= 0) {
return false;
}
for (int i = 0; i < text.length(); i++) {
if (i == underscoreIndex) {
continue;
}
if (!Character.isDigit(text.charAt(i))) {
return false;
}
}
return true;
}
private File findLocalSourceFile(String fileKey) { private File findLocalSourceFile(String fileKey) {
File baseDir = FileUtil.file(storageProperties.getLocalTempDir()); File baseDir = FileUtil.file(storageProperties.getLocalTempDir());
if (!baseDir.exists()) { if (!baseDir.exists()) {
return null; return null;
} }
List<File> matchedFiles = FileUtil.loopFiles(baseDir, pathname -> pathname.isFile() && pathname.getName().startsWith(fileKey)); File[] files = baseDir.listFiles(pathname -> pathname.isFile() && pathname.getName().startsWith(fileKey));
return matchedFiles.isEmpty() ? null : matchedFiles.getFirst(); if (files == null || files.length == 0) {
return null;
}
return files[0];
} }
private File buildNamedOutputFile(File outputDir, String filename) { private File buildNamedOutputFile(File outputDir, String filename) {
@@ -461,16 +588,102 @@ public class DedupeRunService {
if (value == null) { if (value == null) {
return ""; return "";
} }
return value.replace("", "") int start = 0;
.replace(" ", " ") int end = value.length();
.replace("\r\n", " ") while (start < end && isTrimmedWhitespace(value.charAt(start))) {
.replace("\r", " ") start++;
.replace("\n", " ") }
.replace("\t", " ") while (end > start && isTrimmedWhitespace(value.charAt(end - 1))) {
.trim() end--;
.replaceAll("\\s+", " "); }
if (start >= end) {
return "";
}
StringBuilder builder = null;
boolean previousWhitespace = false;
for (int i = start; i < end; i++) {
char ch = value.charAt(i);
if (ch == '\uFEFF') {
if (builder == null) {
builder = new StringBuilder(value.length());
builder.append(value, start, i);
}
continue;
}
if (isNormalizedWhitespace(ch)) {
if (builder == null) {
builder = new StringBuilder(value.length());
builder.append(value, start, i);
}
if (!previousWhitespace) {
builder.append(' ');
}
previousWhitespace = true;
continue;
}
if (builder != null) {
builder.append(ch);
}
previousWhitespace = false;
}
return builder == null ? value.substring(start, end) : builder.toString();
}
private boolean isTrimmedWhitespace(char ch) {
return ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n' || ch == '\u3000' || Character.isWhitespace(ch);
}
private boolean isNormalizedWhitespace(char ch) {
return ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n' || ch == '\u3000' || Character.isWhitespace(ch);
} }
private record DedupeArchiveEntry(String relativePath, String inputName, File outputFile) { private record DedupeArchiveEntry(String relativePath, String inputName, File outputFile) {
} }
private long elapsedNs(long startNs) {
return System.nanoTime() - startNs;
}
private long toMs(long nanos) {
return nanos / 1_000_000L;
}
private record DedupeReadResult(String sheetName, List<DedupeCandidateRow> rows, int scannedRows) {
}
private record DedupeCandidateRow(List<String> selectedValues, String asinValue) {
}
private static final class PendingMainIdGroup {
private String mainId;
private final List<DedupeCandidateRow> rows = new ArrayList<>();
private void add(String nextMainId, DedupeCandidateRow row) {
if (hasDifferentMainId(nextMainId)) {
rows.clear();
}
mainId = nextMainId;
rows.add(row);
}
private boolean hasDifferentMainId(String nextMainId) {
return mainId != null && (nextMainId == null || nextMainId.isBlank() || !mainId.equals(nextMainId));
}
private void discardIfSameMainId(String nextMainId) {
if (mainId != null && mainId.equals(nextMainId)) {
rows.clear();
mainId = null;
}
}
private void flush(List<DedupeCandidateRow> outputRows) {
if (!rows.isEmpty()) {
outputRows.addAll(rows);
rows.clear();
}
mainId = null;
}
}
} }

View File

@@ -38,7 +38,7 @@ import java.util.concurrent.ConcurrentHashMap;
@RequiredArgsConstructor @RequiredArgsConstructor
public class DedupeTotalDataService { public class DedupeTotalDataService {
private static final int COMPARE_BATCH_SIZE = 1000; private static final int COMPARE_BATCH_SIZE = 5000;
private final DedupeTotalDataMapper dedupeTotalDataMapper; private final DedupeTotalDataMapper dedupeTotalDataMapper;
private final Map<String, DedupeTotalDataImportProgressVo> importProgressMap = new ConcurrentHashMap<>(); private final Map<String, DedupeTotalDataImportProgressVo> importProgressMap = new ConcurrentHashMap<>();

View File

@@ -298,6 +298,8 @@ public class DeleteBrandStaleTaskService {
ShopMatchStaleCheckStats stats = new ShopMatchStaleCheckStats(); ShopMatchStaleCheckStats stats = new ShopMatchStaleCheckStats();
long minutes = Math.max(1L, deleteBrandProgressProperties.getShopMatchStaleTimeoutMinutes()); long minutes = Math.max(1L, deleteBrandProgressProperties.getShopMatchStaleTimeoutMinutes());
long initialMinutes = Math.max(minutes, deleteBrandProgressProperties.getShopMatchInitialTimeoutMinutes()); long initialMinutes = Math.max(minutes, deleteBrandProgressProperties.getShopMatchInitialTimeoutMinutes());
long staleTimeoutMillis = Duration.ofMinutes(minutes).toMillis();
long nowMillis = System.currentTimeMillis();
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
LocalDateTime threshold = now.minusMinutes(minutes); LocalDateTime threshold = now.minusMinutes(minutes);
LocalDateTime initialThreshold = now.minusMinutes(initialMinutes); LocalDateTime initialThreshold = now.minusMinutes(initialMinutes);
@@ -313,10 +315,17 @@ public class DeleteBrandStaleTaskService {
log.info("[stale-check] shop-match candidates={} threshold={} timeoutMinutes={} initialThreshold={} initialTimeoutMinutes={}", log.info("[stale-check] shop-match candidates={} threshold={} timeoutMinutes={} initialThreshold={} initialTimeoutMinutes={}",
runningTasks.size(), threshold, minutes, initialThreshold, initialMinutes); runningTasks.size(), threshold, minutes, initialThreshold, initialMinutes);
for (FileTaskEntity task : runningTasks) { for (FileTaskEntity task : runningTasks) {
long lastHeartbeatMillis = shopMatchTaskCacheService.getTaskHeartbeatMillis(task.getId());
boolean hasUploadedPayload = shopMatchTaskCacheService.hasAnyShopMergedPayload(task.getId()); boolean hasUploadedPayload = shopMatchTaskCacheService.hasAnyShopMergedPayload(task.getId());
boolean hasFinishedRows = (task.getSuccessFileCount() != null && task.getSuccessFileCount() > 0) boolean hasFinishedRows = (task.getSuccessFileCount() != null && task.getSuccessFileCount() > 0)
|| (task.getFailedFileCount() != null && task.getFailedFileCount() > 0); || (task.getFailedFileCount() != null && task.getFailedFileCount() > 0);
boolean hasStartedProgress = hasUploadedPayload || hasFinishedRows; boolean hasStartedProgress = lastHeartbeatMillis > 0L || hasUploadedPayload || hasFinishedRows;
if (lastHeartbeatMillis > 0L && nowMillis - lastHeartbeatMillis < staleTimeoutMillis) {
stats.skippedTaskCount++;
log.info("[stale-check] shop-match skip recent-task-heartbeat taskId={} lastHeartbeatMillis={} timeoutMinutes={} updatedAt={}",
task.getId(), lastHeartbeatMillis, minutes, task.getUpdatedAt());
continue;
}
if (!hasStartedProgress && task.getCreatedAt() != null && task.getCreatedAt().isAfter(initialThreshold)) { if (!hasStartedProgress && task.getCreatedAt() != null && task.getCreatedAt().isAfter(initialThreshold)) {
stats.skippedTaskCount++; stats.skippedTaskCount++;
log.info("[stale-check] shop-match skip initial-grace taskId={} createdAt={} initialThreshold={}", log.info("[stale-check] shop-match skip initial-grace taskId={} createdAt={} initialThreshold={}",

View File

@@ -37,6 +37,19 @@ public class OssStorageService {
} }
} }
public UploadedResult uploadResultFileWithFreshDownloadUrl(File file, String moduleType) {
String objectKey = String.format("result/%s/%s/%s", moduleType.toLowerCase(), UUID.randomUUID(), file.getName());
OSS ossClient = buildClient();
try {
ossClient.putObject(ossProperties.getBucket(), objectKey, file);
Date expiration = new Date(System.currentTimeMillis() + 3600_000L);
URL url = ossClient.generatePresignedUrl(ossProperties.getBucket(), objectKey, expiration);
return new UploadedResult(objectKey, url.toString());
} finally {
ossClient.shutdown();
}
}
public String uploadText(String objectKey, String content) { public String uploadText(String objectKey, String content) {
if (objectKey == null || objectKey.isBlank()) { if (objectKey == null || objectKey.isBlank()) {
throw new IllegalArgumentException("objectKey must not be blank"); throw new IllegalArgumentException("objectKey must not be blank");
@@ -160,4 +173,7 @@ public class OssStorageService {
ossProperties.getAccessKeySecret() ossProperties.getAccessKeySecret()
); );
} }
public record UploadedResult(String objectKey, String downloadUrl) {
}
} }

View File

@@ -28,6 +28,7 @@ public class PermissionMenuSchemaInitializer {
new DefaultAdminMenu("店铺密钥管理", "admin_shop_keys", "shop-keys", 40), new DefaultAdminMenu("店铺密钥管理", "admin_shop_keys", "shop-keys", 40),
new DefaultAdminMenu("店铺管理", "admin_shop_manage", "shop-manage", 50), new DefaultAdminMenu("店铺管理", "admin_shop_manage", "shop-manage", 50),
new DefaultAdminMenu("跳过跟价ASIN", "admin_skip_price_asin", "skip-price-asin", 60), new DefaultAdminMenu("跳过跟价ASIN", "admin_skip_price_asin", "skip-price-asin", 60),
new DefaultAdminMenu("查询ASIN", "admin_query_asin", "query-asin", 65),
new DefaultAdminMenu("查看生成记录", "admin_history", "history", 70), new DefaultAdminMenu("查看生成记录", "admin_history", "history", 70),
new DefaultAdminMenu("版本管理", "admin_version", "version", 80) new DefaultAdminMenu("版本管理", "admin_version", "version", 80)
); );

View File

@@ -1128,9 +1128,8 @@ public class PriceTrackTaskService {
if (targetAsin == null || targetAsin.isBlank()) { if (targetAsin == null || targetAsin.isBlank()) {
continue; continue;
} }
boolean removed = skipPriceAsinService.removeByShopCountryAndAsin(shopKey, countryCode, targetAsin); log.info("[price-track] skip asin delete signal ignored taskShop={} country={} asin={} reason={}",
log.info("[price-track] skip asin delete signal taskShop={} country={} asin={} removed={} reason={}", shopKey, countryCode, targetAsin, row.getDeleteReason());
shopKey, countryCode, targetAsin, removed, row.getDeleteReason());
} }
} }
} }

View File

@@ -1,9 +1,13 @@
package com.nanri.aiimage.modules.productrisk.model.vo; package com.nanri.aiimage.modules.productrisk.model.vo;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCountryAsinsDto;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/** /**
* 与 Python 队列约定字段对齐,供前端原样推入 enqueue_json。 * 与 Python 队列约定字段对齐,供前端原样推入 enqueue_json。
*/ */
@@ -22,7 +26,7 @@ public class ProductRiskShopQueueItemVo {
@Schema(description = "紫鸟侧店铺 ID字符串未命中时可能为空") @Schema(description = "紫鸟侧店铺 ID字符串未命中时可能为空")
private String shopId; private String shopId;
@Schema(description = "平台,如 亚马逊", example = "亚马逊") @Schema(description = "平台,如亚马逊", example = "亚马逊")
private String platform; private String platform;
@JsonProperty("companyName") @JsonProperty("companyName")
@@ -38,11 +42,14 @@ public class ProductRiskShopQueueItemVo {
private Long matchedUserId; private Long matchedUserId;
@JsonProperty("matchStatus") @JsonProperty("matchStatus")
@Schema( @Schema(description = "匹配状态码:如 MATCHED、PENDING、CONFLICT、INDEX_STALE 等,以后端为准")
description = "匹配状态码:如 MATCHED、PENDING、CONFLICT、INDEX_STALE 等,以后端为准。")
private String matchStatus; private String matchStatus;
@JsonProperty("matchMessage") @JsonProperty("matchMessage")
@Schema(description = "人类可读说明,供前端展示") @Schema(description = "人类可读说明,供前端展示")
private String matchMessage; private String matchMessage;
@JsonProperty("queryAsins")
@Schema(description = "查询 ASIN 模块返回的后台维护 ASIN 数据;按全表聚合返回,不按当前店铺过滤")
private List<QueryAsinCountryAsinsDto> queryAsins = new ArrayList<>();
} }

View File

@@ -0,0 +1,310 @@
package com.nanri.aiimage.modules.queryasin.controller;
import com.nanri.aiimage.common.api.ApiResponse;
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCandidateAddRequest;
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskMatchShopsRequest;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskCandidateVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskDashboardVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskMatchShopsVo;
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCreateTaskRequest;
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinSubmitResultRequest;
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinTaskBatchRequest;
import com.nanri.aiimage.modules.queryasin.model.vo.QueryAsinCreateTaskVo;
import com.nanri.aiimage.modules.queryasin.model.vo.QueryAsinHistoryVo;
import com.nanri.aiimage.modules.queryasin.model.vo.QueryAsinTaskBatchVo;
import com.nanri.aiimage.modules.queryasin.service.QueryAsinResolveService;
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.ExampleObject;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.io.InputStream;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/query-asin")
@Tag(
name = "查询ASIN",
description = "查询 ASIN 模块:管理备选店铺、批量匹配紫鸟店铺、创建任务、接收 Python 分片结果、自动收尾生成 Excel 并提供下载。查询和删除接口需要携带 user_id。")
public class QueryAsinTaskController {
private final QueryAsinResolveService queryAsinResolveService;
private final QueryAsinTaskService queryAsinTaskService;
@GetMapping("/candidates")
@Operation(summary = "查询备选店铺列表", description = "返回当前用户在查询 ASIN 模块中已保存的备选店铺。")
public ApiResponse<List<ProductRiskCandidateVo>> listCandidates(
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@RequestParam("user_id") Long userId) {
return ApiResponse.success(queryAsinResolveService.listCandidates(userId));
}
@PostMapping("/candidates")
@Operation(summary = "新增备选店铺", description = "向查询 ASIN 备选区新增一条店铺记录,供后续匹配和创建任务使用。")
public ApiResponse<ProductRiskCandidateVo> addCandidate(
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "新增备选店铺请求,需要传入用户 ID 和店铺名。",
required = true,
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = ProductRiskCandidateAddRequest.class),
examples = @ExampleObject(
name = "新增店铺示例",
value = """
{
"user_id": 1,
"shop_name": "郭亚芳"
}
""")))
@Valid @RequestBody ProductRiskCandidateAddRequest request) {
return ApiResponse.success(queryAsinResolveService.addCandidate(request));
}
@DeleteMapping("/candidates/{id}")
@Operation(summary = "删除备选店铺", description = "删除当前用户名下的一条备选店铺记录。")
public ApiResponse<Void> deleteCandidate(
@Parameter(description = "备选店铺记录主键", example = "10")
@PathVariable Long id,
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@RequestParam("user_id") Long userId) {
queryAsinResolveService.deleteCandidate(userId, id);
return ApiResponse.success(null);
}
@PostMapping("/match-shops")
@Operation(summary = "批量匹配店铺", description = "根据店铺名称批量匹配紫鸟店铺索引,返回是否命中、店铺 ID、平台、公司和匹配状态。")
public ApiResponse<ProductRiskMatchShopsVo> matchShops(
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "批量匹配请求,传入 user_id 和待匹配的店铺名称列表。",
required = true,
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = ProductRiskMatchShopsRequest.class),
examples = @ExampleObject(
name = "匹配店铺示例",
value = """
{
"user_id": 1,
"shop_names": [
"郭亚芳",
"示例店铺A"
]
}
""")))
@Valid @RequestBody ProductRiskMatchShopsRequest request) {
return ApiResponse.success(queryAsinResolveService.matchShops(request));
}
@GetMapping("/dashboard")
@Operation(summary = "查询统计看板", description = "返回备选店铺数、已处理任务数、成功任务数和失败任务数。")
public ApiResponse<ProductRiskDashboardVo> dashboard(
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@RequestParam("user_id") Long userId) {
return ApiResponse.success(queryAsinTaskService.dashboard(userId));
}
@GetMapping("/history")
@Operation(summary = "查询任务记录", description = "返回当前用户在查询 ASIN 模块中的当前任务和历史任务记录。")
public ApiResponse<QueryAsinHistoryVo> history(
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@RequestParam("user_id") Long userId) {
return ApiResponse.success(queryAsinTaskService.listHistory(userId));
}
@PostMapping("/tasks/progress/batch")
@Operation(summary = "批量查询查询 ASIN 任务进度", description = "仅返回任务状态和店铺结果摘要,用于前端轮询降载。")
public ApiResponse<QueryAsinTaskBatchVo> taskProgressBatch(@Valid @RequestBody QueryAsinTaskBatchRequest request) {
return ApiResponse.success(queryAsinTaskService.getTaskProgressBatch(request.getTaskIds()));
}
@PostMapping("/tasks")
@Operation(summary = "创建查询 ASIN 任务", description = "根据已匹配的店铺创建任务和占位结果记录,并把后台维护的整张 ASIN 表数据随店铺项返回给 Python 端。")
public ApiResponse<QueryAsinCreateTaskVo> createTask(
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "创建任务请求。items 中每一项代表一个待处理店铺queryAsins 是后台维护的整张 ASIN 表数据,不按店铺过滤。",
required = true,
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = QueryAsinCreateTaskRequest.class),
examples = @ExampleObject(
name = "创建任务示例",
value = """
{
"user_id": 1,
"items": [
{
"shopName": "郭亚芳",
"matched": true,
"shopId": "27730548558377",
"platform": "亚马逊",
"companyName": "示例公司",
"matchStatus": "MATCHED",
"matchMessage": "索引已命中",
"queryAsins": [
{
"country": "DE",
"asins": ["B0BRZZR3N2", "B0BRZZR3N3"]
},
{
"country": "FR",
"asins": ["B0BRZZR3N4"]
}
]
}
]
}
""")))
@Valid @RequestBody QueryAsinCreateTaskRequest request) {
return ApiResponse.success(queryAsinTaskService.createTask(request));
}
@PostMapping("/tasks/{taskId}/result")
@Operation(
summary = "提交查询 ASIN 结果",
description = "供 Python 端回传查询 ASIN 结果。业务数据包含店铺名,以及德国、英国、法国、意大利、西班牙 5 个国家下的 ASIN 和处理状态submissionId 用于日志追踪shopDone 可不传,不传时后端默认该店铺已提交完成。")
public ApiResponse<Void> submitResult(
@Parameter(description = "任务主键,必须是运行中的查询 ASIN 任务", example = "3089")
@PathVariable Long taskId,
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "查询 ASIN 结果回传请求。shops 表示店铺结果列表countryResults 对应导出表中的国家 ASIN 列和处理状态列error 用于回传单店铺失败原因。",
required = true,
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = QueryAsinSubmitResultRequest.class),
examples = @ExampleObject(
name = "回传示例",
summary = "按店铺和国家返回 ASIN 与处理状态",
value = """
{
"shops": [
{
"shopName": "郭亚芳",
"submissionId": "query-asin:3089:郭亚芳:1711111111111",
"shopDone": true,
"countryResults": [
{
"country": "DE",
"items": [
{
"asin": "B0BRZZR3N2",
"status": "正常"
},
{
"asin": "B0BRZZR3N3",
"status": "未找到"
}
]
},
{
"country": "UK",
"items": [
{
"asin": "B0BRZZR3N6",
"status": "正常"
}
]
},
{
"country": "FR",
"items": [
{
"asin": "B0BRZZR3N4",
"status": "正常"
}
]
},
{
"country": "IT",
"items": []
},
{
"country": "ES",
"items": []
}
]
}
]
}
""")))
@Valid @RequestBody QueryAsinSubmitResultRequest request,
jakarta.servlet.http.HttpServletResponse response) {
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.setContentType("application/json;charset=UTF-8");
queryAsinTaskService.submitResult(taskId, request);
return ApiResponse.success(null);
}
@GetMapping("/results/{resultId}/download")
@Operation(summary = "下载结果文件", description = "按结果记录下载服务端已生成并上传到 OSS 的查询 ASIN Excel 文件。")
public void downloadResult(
@Parameter(description = "结果记录主键", example = "4599")
@PathVariable Long resultId,
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@RequestParam("user_id") Long userId,
jakarta.servlet.http.HttpServletResponse response) {
String url = queryAsinTaskService.resolveResultDownloadUrl(resultId, userId);
String filename = queryAsinTaskService.resolveResultDownloadFilename(resultId, userId);
if (url == null || url.isBlank()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "暂无可下载结果");
}
try {
String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20");
response.setContentType("application/octet-stream");
response.setHeader(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + filename + "\"; filename*=UTF-8''" + encodedFilename);
try (InputStream in = URI.create(url).toURL().openStream()) {
byte[] buffer = new byte[65536];
int read;
while ((read = in.read(buffer)) != -1) {
response.getOutputStream().write(buffer, 0, read);
}
response.getOutputStream().flush();
}
} catch (Exception ex) {
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "下载失败");
}
}
@DeleteMapping("/tasks/{taskId}")
@Operation(summary = "删除任务", description = "删除整条查询 ASIN 任务以及该任务下关联的所有结果记录。")
public ApiResponse<Void> deleteTask(
@Parameter(description = "任务主键", example = "3089")
@PathVariable Long taskId,
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@RequestParam("user_id") Long userId) {
queryAsinTaskService.deleteTask(taskId, userId);
return ApiResponse.success(null);
}
@DeleteMapping("/history/{resultId}")
@Operation(summary = "删除单条历史记录", description = "删除一条查询 ASIN 结果记录,并同步重算其所属任务状态。")
public ApiResponse<Void> deleteHistory(
@Parameter(description = "结果记录主键", example = "4599")
@PathVariable Long resultId,
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@RequestParam("user_id") Long userId) {
queryAsinTaskService.deleteHistory(resultId, userId);
return ApiResponse.success(null);
}
}

View File

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

View File

@@ -0,0 +1,15 @@
package com.nanri.aiimage.modules.queryasin.model.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "查询 ASIN 单条结果")
public class QueryAsinAsinStatusDto {
@Schema(description = "ASIN", example = "B0BRZZR3N2")
private String asin;
@Schema(description = "Python 查询后返回的状态", example = "正常")
private String status;
}

View File

@@ -0,0 +1,18 @@
package com.nanri.aiimage.modules.queryasin.model.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(description = "查询 ASIN 国家维度 ASIN 清单")
public class QueryAsinCountryAsinsDto {
@Schema(description = "国家编码,支持 DE、UK、FR、IT、ES", example = "DE")
private String country;
@Schema(description = "后台维护的该国家 ASIN 清单,创建任务时会整体推给 Python")
private List<String> asins = new ArrayList<>();
}

View File

@@ -0,0 +1,18 @@
package com.nanri.aiimage.modules.queryasin.model.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(description = "查询 ASIN 国家维度结果")
public class QueryAsinCountryResultDto {
@Schema(description = "国家编码,支持 DE、UK、FR、IT、ES", example = "DE")
private String country;
@Schema(description = "该国家下的 ASIN 查询结果列表")
private List<QueryAsinAsinStatusDto> items = new ArrayList<>();
}

View File

@@ -0,0 +1,27 @@
package com.nanri.aiimage.modules.queryasin.model.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(description = "创建查询ASIN任务请求")
public class QueryAsinCreateTaskRequest {
@NotNull
@JsonProperty("user_id")
@Schema(description = "当前用户 ID", example = "1", requiredMode = Schema.RequiredMode.REQUIRED)
private Long userId;
@Valid
@NotEmpty
@Schema(description = "待创建的店铺任务列表,通常由已匹配成功的店铺组成", requiredMode = Schema.RequiredMode.REQUIRED)
private List<QueryAsinTaskItemDto> items = new ArrayList<>();
}

View File

@@ -0,0 +1,33 @@
package com.nanri.aiimage.modules.queryasin.model.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(description = "单个店铺的查询 ASIN 结果")
public class QueryAsinShopPayloadDto {
@JsonProperty("shopName")
@Schema(description = "店铺名称", example = "郭亚芳")
private String shopName;
@Schema(description = "店铺处理失败时的错误信息;有值时该店铺会直接标记失败", example = "紫鸟页面加载超时,未能完成 ASIN 查询")
private String error;
@JsonProperty("countryResults")
@Schema(description = "Python 回传的查询 ASIN 结果。每个国家下按 ASIN + 状态返回,对应导出表中的国家列和状态列")
private List<QueryAsinCountryResultDto> countryResults = new ArrayList<>();
@JsonProperty("shopDone")
@Schema(description = "该店铺是否已全部提交完成。不传时按已完成处理;分片提交时,中间分片传 false最后一片传 true", example = "true")
private Boolean shopDone;
@JsonProperty("submissionId")
@Schema(description = "本次店铺提交批次标识,便于问题排查与日志追踪", example = "query-asin:3089:郭亚芳:1711111111111")
private String submissionId;
}

View File

@@ -0,0 +1,19 @@
package com.nanri.aiimage.modules.queryasin.model.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(description = "查询 ASIN 结果回传请求")
public class QueryAsinSubmitResultRequest {
@Valid
@NotEmpty
@Schema(description = "本次提交的店铺结果分片列表,支持一次提交多个店铺", requiredMode = Schema.RequiredMode.REQUIRED)
private List<QueryAsinShopPayloadDto> shops = new ArrayList<>();
}

View File

@@ -0,0 +1,15 @@
package com.nanri.aiimage.modules.queryasin.model.dto;
import jakarta.validation.constraints.NotEmpty;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
public class QueryAsinTaskBatchRequest {
@NotEmpty(message = "taskIds 不能为空")
private List<Long> taskIds = new ArrayList<>();
}

View File

@@ -0,0 +1,45 @@
package com.nanri.aiimage.modules.queryasin.model.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(description = "查询 ASIN 任务中的单个店铺项")
public class QueryAsinTaskItemDto {
@JsonProperty("shopName")
@Schema(description = "店铺名称", example = "郭亚芳")
private String shopName;
@Schema(description = "该店铺是否已完成紫鸟匹配,创建任务时必须为 true", example = "true")
private boolean matched;
@JsonProperty("shopId")
@Schema(description = "匹配到的紫鸟店铺 ID", example = "27730548558377")
private String shopId;
@Schema(description = "店铺所属平台", example = "亚马逊")
private String platform;
@JsonProperty("companyName")
@Schema(description = "店铺所属公司名称", example = "示例公司")
private String companyName;
@JsonProperty("matchStatus")
@Schema(description = "匹配状态,例如 MATCHED、PENDING、CONFLICT、INDEX_STALE", example = "MATCHED")
private String matchStatus;
@JsonProperty("matchMessage")
@Schema(description = "匹配状态说明", example = "索引已命中")
private String matchMessage;
@Valid
@JsonProperty("queryAsins")
@Schema(description = "后台查询 ASIN 维护表中的国家 ASIN 清单;查询 ASIN 任务按全表返回,不按店铺过滤")
private List<QueryAsinCountryAsinsDto> queryAsins = new ArrayList<>();
}

View File

@@ -0,0 +1,20 @@
package com.nanri.aiimage.modules.queryasin.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_query_asin_shop_candidate")
public class QueryAsinShopCandidateEntity {
@TableId(type = IdType.AUTO)
private Long id;
private Long userId;
private String shopName;
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,14 @@
package com.nanri.aiimage.modules.queryasin.model.vo;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
public class QueryAsinCreateTaskVo {
private Long taskId;
private List<QueryAsinResultItemVo> items = new ArrayList<>();
}

View File

@@ -0,0 +1,13 @@
package com.nanri.aiimage.modules.queryasin.model.vo;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
public class QueryAsinHistoryVo {
private List<QueryAsinResultItemVo> items = new ArrayList<>();
}

View File

@@ -0,0 +1,52 @@
package com.nanri.aiimage.modules.queryasin.model.vo;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCountryAsinsDto;
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCountryResultDto;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Data
public class QueryAsinResultItemVo {
private Long resultId;
private Long taskId;
@JsonProperty("shopName")
private String shopName;
@JsonProperty("shopId")
private String shopId;
private String platform;
@JsonProperty("companyName")
private String companyName;
private boolean matched;
@JsonProperty("matchStatus")
private String matchStatus;
@JsonProperty("matchMessage")
private String matchMessage;
@JsonProperty("taskStatus")
private String taskStatus;
private Boolean success;
private String error;
private LocalDateTime createdAt;
private LocalDateTime finishedAt;
private String outputFilename;
private String downloadUrl;
@JsonProperty("queryAsins")
private List<QueryAsinCountryAsinsDto> queryAsins = new ArrayList<>();
@JsonProperty("countryResults")
private List<QueryAsinCountryResultDto> countryResults = new ArrayList<>();
}

View File

@@ -0,0 +1,15 @@
package com.nanri.aiimage.modules.queryasin.model.vo;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
public class QueryAsinTaskBatchVo {
private List<QueryAsinResultItemVo> items = new ArrayList<>();
private List<Long> missingTaskIds = new ArrayList<>();
}

View File

@@ -0,0 +1,196 @@
package com.nanri.aiimage.modules.queryasin.service;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinAsinStatusDto;
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCountryAsinsDto;
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCountryResultDto;
import com.nanri.aiimage.modules.queryasin.model.vo.QueryAsinResultItemVo;
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 java.io.File;
import java.io.FileOutputStream;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Service
@Slf4j
public class QueryAsinExcelAssemblyService {
private static final List<String> COUNTRIES = List.of("DE", "UK", "FR", "IT", "ES");
private static final Map<String, String> COUNTRY_NAMES = Map.of(
"DE", "德国",
"UK", "英国",
"FR", "法国",
"IT", "意大利",
"ES", "西班牙"
);
private static final String[] HEADER = {
"店铺名",
"德国", "状态",
"英国", "状态",
"法国", "状态",
"意大利", "状态",
"西班牙", "状态"
};
public void writeWorkbook(File outputXlsx, List<QueryAsinResultItemVo> items) {
SXSSFWorkbook workbook = new SXSSFWorkbook(200);
workbook.setCompressTempFiles(true);
try (FileOutputStream outputStream = new FileOutputStream(outputXlsx)) {
Sheet sheet = workbook.createSheet("查询ASIN结果");
Row headerRow = sheet.createRow(0);
for (int columnIndex = 0; columnIndex < HEADER.length; columnIndex++) {
headerRow.createCell(columnIndex).setCellValue(HEADER[columnIndex]);
sheet.setColumnWidth(columnIndex, (columnIndex == 0 ? 22 : 18) * 256);
}
int rowIndex = 1;
for (QueryAsinResultItemVo item : items) {
if (item == null || Boolean.FALSE.equals(item.getSuccess())) {
continue;
}
Map<String, List<QueryAsinAsinStatusDto>> resultsByCountry = resultsByCountry(item);
int maxRows = maxRows(resultsByCountry);
for (int i = 0; i < maxRows; i++) {
Row row = sheet.createRow(rowIndex++);
row.createCell(0).setCellValue(i == 0 ? safe(item.getShopName()) : "");
for (int c = 0; c < COUNTRIES.size(); c++) {
List<QueryAsinAsinStatusDto> rows = resultsByCountry.get(COUNTRIES.get(c));
QueryAsinAsinStatusDto result = rows != null && i < rows.size() ? rows.get(i) : null;
int asinColumn = 1 + c * 2;
row.createCell(asinColumn).setCellValue(result == null ? "" : safe(result.getAsin()));
row.createCell(asinColumn + 1).setCellValue(result == null ? "" : safe(result.getStatus()));
}
}
rowIndex++;
}
workbook.write(outputStream);
} catch (Exception ex) {
log.warn("[query-asin] write workbook failed: {}", ex.getMessage());
throw new BusinessException("生成查询 ASIN Excel 失败: " + ex.getMessage());
} finally {
try {
workbook.close();
} catch (Exception ignored) {
}
workbook.dispose();
}
}
public int countRows(List<QueryAsinResultItemVo> items) {
int count = 0;
if (items == null) {
return 0;
}
for (QueryAsinResultItemVo item : items) {
if (item != null && !Boolean.FALSE.equals(item.getSuccess())) {
count += maxRows(resultsByCountry(item));
}
}
return count;
}
private Map<String, List<QueryAsinAsinStatusDto>> resultsByCountry(QueryAsinResultItemVo item) {
Map<String, List<QueryAsinAsinStatusDto>> map = emptyCountryMap();
if (item == null) {
return map;
}
if (item.getCountryResults() != null && !item.getCountryResults().isEmpty()) {
for (QueryAsinCountryResultDto countryResult : item.getCountryResults()) {
String country = normalizeCountry(countryResult == null ? null : countryResult.getCountry());
if (!map.containsKey(country)) {
continue;
}
map.put(country, normalizeResultItems(countryResult.getItems()));
}
return map;
}
// 兼容旧链路:如果 Python 仍然只透传 queryAsins则状态列留空。
if (item.getQueryAsins() != null) {
for (QueryAsinCountryAsinsDto countryAsins : item.getQueryAsins()) {
String country = normalizeCountry(countryAsins == null ? null : countryAsins.getCountry());
if (!map.containsKey(country)) {
continue;
}
List<QueryAsinAsinStatusDto> rows = new ArrayList<>();
for (String asin : countryAsins.getAsins() == null ? List.<String>of() : countryAsins.getAsins()) {
String normalizedAsin = normalizeAsin(asin);
if (normalizedAsin.isEmpty()) {
continue;
}
QueryAsinAsinStatusDto row = new QueryAsinAsinStatusDto();
row.setAsin(normalizedAsin);
row.setStatus("");
rows.add(row);
}
map.put(country, rows);
}
}
return map;
}
private Map<String, List<QueryAsinAsinStatusDto>> emptyCountryMap() {
Map<String, List<QueryAsinAsinStatusDto>> map = new LinkedHashMap<>();
for (String country : COUNTRIES) {
map.put(country, List.of());
}
return map;
}
private List<QueryAsinAsinStatusDto> normalizeResultItems(List<QueryAsinAsinStatusDto> items) {
List<QueryAsinAsinStatusDto> out = new ArrayList<>();
if (items == null) {
return out;
}
for (QueryAsinAsinStatusDto source : items) {
String asin = normalizeAsin(source == null ? null : source.getAsin());
String status = source == null ? "" : safe(source.getStatus()).trim();
if (asin.isEmpty() && status.isEmpty()) {
continue;
}
QueryAsinAsinStatusDto item = new QueryAsinAsinStatusDto();
item.setAsin(asin);
item.setStatus(status);
out.add(item);
}
return out;
}
private int maxRows(Map<String, List<QueryAsinAsinStatusDto>> resultsByCountry) {
int max = 1;
for (List<QueryAsinAsinStatusDto> rows : resultsByCountry.values()) {
if (rows != null && rows.size() > max) {
max = rows.size();
}
}
return max;
}
private String normalizeCountry(String country) {
if (country == null) {
return "";
}
String value = country.trim().toUpperCase();
for (Map.Entry<String, String> entry : COUNTRY_NAMES.entrySet()) {
if (entry.getValue().equals(country.trim())) {
return entry.getKey();
}
}
return value;
}
private String normalizeAsin(String value) {
return value == null ? "" : value.trim().toUpperCase();
}
private String safe(String value) {
return value == null ? "" : value;
}
}

View File

@@ -0,0 +1,221 @@
package com.nanri.aiimage.modules.queryasin.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCandidateAddRequest;
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskMatchShopsRequest;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskCandidateVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskMatchShopsVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskShopQueueItemVo;
import com.nanri.aiimage.modules.queryasin.mapper.QueryAsinShopCandidateMapper;
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCountryAsinsDto;
import com.nanri.aiimage.modules.queryasin.model.entity.QueryAsinShopCandidateEntity;
import com.nanri.aiimage.modules.shopkey.mapper.QueryAsinMapper;
import com.nanri.aiimage.modules.shopkey.model.entity.QueryAsinEntity;
import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoShopMatchResultVo;
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.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@Service
@RequiredArgsConstructor
public class QueryAsinResolveService {
private final QueryAsinShopCandidateMapper candidateMapper;
private final ZiniaoShopSwitchService ziniaoShopSwitchService;
private final QueryAsinMapper queryAsinMapper;
public List<ProductRiskCandidateVo> listCandidates(Long userId) {
validateUserId(userId);
List<QueryAsinShopCandidateEntity> rows = candidateMapper.selectList(
new LambdaQueryWrapper<QueryAsinShopCandidateEntity>()
.eq(QueryAsinShopCandidateEntity::getUserId, userId)
.orderByDesc(QueryAsinShopCandidateEntity::getId));
List<ProductRiskCandidateVo> list = new ArrayList<>();
for (QueryAsinShopCandidateEntity row : rows) {
ProductRiskCandidateVo vo = new ProductRiskCandidateVo();
vo.setId(row.getId());
vo.setShopName(row.getShopName());
vo.setCreatedAt(row.getCreatedAt());
list.add(vo);
}
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);
if (indexHit == null || !indexHit.isMatched()) {
String hint = indexHit != null && indexHit.getMatchMessage() != null && !indexHit.getMatchMessage().isBlank()
? indexHit.getMatchMessage()
: "店铺索引未命中,无法加入备选区";
throw new BusinessException(hint);
}
if (ZiniaoShopIndexService.MATCH_STATUS_CONFLICT.equals(indexHit.getMatchStatus())) {
throw new BusinessException(indexHit.getMatchMessage() != null ? indexHit.getMatchMessage() : "存在多个同名店铺,请人工确认");
}
QueryAsinShopCandidateEntity existing = candidateMapper.selectOne(
new LambdaQueryWrapper<QueryAsinShopCandidateEntity>()
.eq(QueryAsinShopCandidateEntity::getUserId, request.getUserId())
.eq(QueryAsinShopCandidateEntity::getShopName, normalized)
.last("limit 1"));
if (existing != null) {
ProductRiskCandidateVo vo = new ProductRiskCandidateVo();
vo.setId(existing.getId());
vo.setShopName(existing.getShopName());
vo.setCreatedAt(existing.getCreatedAt());
return vo;
}
QueryAsinShopCandidateEntity entity = new QueryAsinShopCandidateEntity();
entity.setUserId(request.getUserId());
entity.setShopName(normalized);
entity.setCreatedAt(LocalDateTime.now());
candidateMapper.insert(entity);
ProductRiskCandidateVo vo = new ProductRiskCandidateVo();
vo.setId(entity.getId());
vo.setShopName(entity.getShopName());
vo.setCreatedAt(entity.getCreatedAt());
return vo;
}
@Transactional
public void deleteCandidate(Long userId, Long id) {
validateUserId(userId);
if (id == null || id <= 0) {
throw new BusinessException("id 不合法");
}
QueryAsinShopCandidateEntity row = candidateMapper.selectById(id);
if (row == null || !userId.equals(row.getUserId())) {
throw new BusinessException("记录不存在");
}
candidateMapper.deleteById(id);
}
public ProductRiskMatchShopsVo matchShops(ProductRiskMatchShopsRequest request) {
validateUserId(request.getUserId());
LinkedHashSet<String> ordered = new LinkedHashSet<>();
for (String raw : request.getShopNames()) {
String normalized = ziniaoShopSwitchService.normalizeShopName(raw);
if (!normalized.isBlank()) {
ordered.add(normalized);
}
}
if (ordered.isEmpty()) {
throw new BusinessException("shop_names 无有效店铺名");
}
ProductRiskMatchShopsVo vo = new ProductRiskMatchShopsVo();
for (String shopName : ordered) {
vo.getItems().add(matchOneShop(request.getUserId(), shopName));
}
return vo;
}
public List<QueryAsinCountryAsinsDto> loadQueryAsinsForShop(Long userId, String shopName) {
validateUserId(userId);
String normalizedShopName = ziniaoShopSwitchService.normalizeShopName(shopName);
if (normalizedShopName.isBlank()) {
return List.of();
}
List<QueryAsinEntity> rows = queryAsinMapper.selectList(
new LambdaQueryWrapper<QueryAsinEntity>().orderByAsc(QueryAsinEntity::getId));
return toCountryAsins(rows);
}
public long countCandidates(Long userId) {
validateUserId(userId);
Long count = candidateMapper.selectCount(new LambdaQueryWrapper<QueryAsinShopCandidateEntity>()
.eq(QueryAsinShopCandidateEntity::getUserId, userId));
return count == null ? 0L : count;
}
private ProductRiskShopQueueItemVo matchOneShop(Long userId, String shopName) {
ProductRiskShopQueueItemVo item = new ProductRiskShopQueueItemVo();
item.setShopName(shopName);
try {
ZiniaoShopMatchResultVo matched = ziniaoShopSwitchService.findIndexedStoreByName(shopName, false);
if (matched == null) {
item.setMatched(false);
item.setMatchStatus("PENDING");
item.setMatchMessage("匹配结果为空");
return item;
}
item.setMatched(matched.isMatched());
item.setShopId(matched.getShopId());
item.setPlatform(matched.getPlatform());
item.setCompanyName(matched.getCompanyName());
item.setMatchedUserId(matched.getMatchedUserId());
item.setMatchStatus(matched.getMatchStatus());
item.setMatchMessage(matched.getMatchMessage());
item.setOpenStoreUrl(matched.getOpenStoreUrl());
item.setQueryAsins(loadQueryAsinsForShop(userId, shopName));
if (item.isMatched() && item.getQueryAsins().isEmpty()) {
item.setMatchMessage("紫鸟已匹配,但后台查询 ASIN 表暂无数据");
}
} catch (BusinessException ex) {
item.setMatched(false);
item.setMatchStatus("PENDING");
item.setMatchMessage(ex.getMessage());
} catch (Exception ex) {
item.setMatched(false);
item.setMatchStatus("PENDING");
item.setMatchMessage(Objects.toString(ex.getMessage(), "匹配异常"));
}
return item;
}
private List<QueryAsinCountryAsinsDto> toCountryAsins(List<QueryAsinEntity> rows) {
Map<String, LinkedHashSet<String>> asinsByCountry = new LinkedHashMap<>();
for (String country : List.of("DE", "UK", "FR", "IT", "ES")) {
asinsByCountry.put(country, new LinkedHashSet<>());
}
if (rows != null) {
for (QueryAsinEntity row : rows) {
addAsin(asinsByCountry, "DE", row.getAsinDe());
addAsin(asinsByCountry, "UK", row.getAsinUk());
addAsin(asinsByCountry, "FR", row.getAsinFr());
addAsin(asinsByCountry, "IT", row.getAsinIt());
addAsin(asinsByCountry, "ES", row.getAsinEs());
}
}
List<QueryAsinCountryAsinsDto> out = new ArrayList<>();
for (Map.Entry<String, LinkedHashSet<String>> entry : asinsByCountry.entrySet()) {
if (entry.getValue().isEmpty()) {
continue;
}
QueryAsinCountryAsinsDto dto = new QueryAsinCountryAsinsDto();
dto.setCountry(entry.getKey());
dto.setAsins(new ArrayList<>(entry.getValue()));
out.add(dto);
}
return out;
}
private void addAsin(Map<String, LinkedHashSet<String>> map, String country, String asin) {
String normalized = asin == null ? "" : asin.trim().toUpperCase();
if (!normalized.isEmpty()) {
map.get(country).add(normalized);
}
}
private void validateUserId(Long userId) {
if (userId == null || userId <= 0) {
throw new BusinessException("user_id 不合法");
}
}
}

View File

@@ -0,0 +1,168 @@
package com.nanri.aiimage.modules.queryasin.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.config.TaskPressureProperties;
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinShopPayloadDto;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import com.nanri.aiimage.modules.task.service.TaskScopePayloadStorageService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Service
@RequiredArgsConstructor
public class QueryAsinTaskCacheService {
private static final String MODULE_TYPE = "QUERY_ASIN";
private static final long PAYLOAD_TTL_HOURS = 24;
private final StringRedisTemplate stringRedisTemplate;
private final ObjectMapper objectMapper;
private final TaskPressureProperties taskPressureProperties;
private final TaskScopePayloadStorageService taskScopePayloadStorageService;
private final ConcurrentHashMap<Long, LocalTaskEntityCacheEntry> taskEntityLocalCache = new ConcurrentHashMap<>();
public QueryAsinShopPayloadDto getShopMergedPayload(Long taskId, String shopKey) {
return taskScopePayloadStorageService.getScopePayload(taskId, MODULE_TYPE, shopKey, QueryAsinShopPayloadDto.class);
}
public void saveShopMergedPayload(Long taskId, String shopKey, QueryAsinShopPayloadDto payload) {
if (taskId == null || taskId <= 0 || shopKey == null || shopKey.isBlank() || payload == null) {
return;
}
taskScopePayloadStorageService.saveScopePayload(taskId, MODULE_TYPE, shopKey, payload);
touchTaskHeartbeat(taskId);
}
public void removeShopMergedPayload(Long taskId, String shopKey) {
taskScopePayloadStorageService.removeScopePayload(taskId, MODULE_TYPE, shopKey);
}
public Map<String, QueryAsinShopPayloadDto> getAllShopMergedPayload(Long taskId) {
return taskScopePayloadStorageService.getAllScopePayload(taskId, MODULE_TYPE, QueryAsinShopPayloadDto.class);
}
public boolean hasAnyShopMergedPayload(Long taskId) {
return taskScopePayloadStorageService.hasAnyScopePayload(taskId, MODULE_TYPE);
}
public long countShopMergedPayload(Long taskId) {
return taskScopePayloadStorageService.countScopePayload(taskId, MODULE_TYPE);
}
public long getTaskHeartbeatMillis(Long taskId) {
if (taskId == null || taskId <= 0) {
return 0L;
}
String raw = stringRedisTemplate.opsForValue().get(buildTaskHeartbeatKey(taskId));
if (raw == null || raw.isBlank()) {
return 0L;
}
try {
return Long.parseLong(raw);
} catch (NumberFormatException ignored) {
return 0L;
}
}
public void touchTaskHeartbeat(Long taskId) {
stringRedisTemplate.opsForValue().set(
buildTaskHeartbeatKey(taskId),
String.valueOf(Instant.now().toEpochMilli()),
Duration.ofHours(PAYLOAD_TTL_HOURS));
}
public void deleteTaskCache(Long taskId) {
if (taskId == null || taskId <= 0) {
return;
}
taskEntityLocalCache.remove(taskId);
stringRedisTemplate.delete(buildTaskHeartbeatKey(taskId));
stringRedisTemplate.delete(buildTaskEntityKey(taskId));
taskScopePayloadStorageService.deleteTaskScopePayloads(taskId, MODULE_TYPE);
}
public void saveTaskCache(FileTaskEntity task) {
if (task == null || task.getId() == null) {
return;
}
long now = System.currentTimeMillis();
taskEntityLocalCache.put(task.getId(), new LocalTaskEntityCacheEntry(
now,
objectMapper.convertValue(task, FileTaskEntity.class)
));
try {
stringRedisTemplate.opsForValue().set(
buildTaskEntityKey(task.getId()),
objectMapper.writeValueAsString(task),
Duration.ofHours(PAYLOAD_TTL_HOURS)
);
} catch (Exception ignored) {
}
}
public Map<Long, FileTaskEntity> getTaskCacheBatch(java.util.List<Long> taskIds) {
Map<Long, FileTaskEntity> result = new LinkedHashMap<>();
if (taskIds == null || taskIds.isEmpty()) {
return result;
}
java.util.List<Long> normalized = taskIds.stream()
.filter(id -> id != null && id > 0)
.distinct()
.toList();
if (normalized.isEmpty()) {
return result;
}
long now = System.currentTimeMillis();
java.util.List<Long> missingIds = new ArrayList<>();
for (Long taskId : normalized) {
LocalTaskEntityCacheEntry cached = taskEntityLocalCache.get(taskId);
if (isLocalCacheFresh(cached, now)) {
result.put(taskId, objectMapper.convertValue(cached.task(), FileTaskEntity.class));
} else {
missingIds.add(taskId);
}
}
if (missingIds.isEmpty()) {
return result;
}
java.util.List<String> keys = missingIds.stream().map(this::buildTaskEntityKey).toList();
java.util.List<String> values = stringRedisTemplate.opsForValue().multiGet(keys);
for (int i = 0; i < missingIds.size(); i++) {
Long taskId = missingIds.get(i);
String val = values != null && i < values.size() ? values.get(i) : null;
if (val == null || val.isBlank()) {
continue;
}
try {
FileTaskEntity task = objectMapper.readValue(val, FileTaskEntity.class);
result.put(taskId, task);
taskEntityLocalCache.put(taskId, new LocalTaskEntityCacheEntry(now, task));
} catch (Exception ignored) {
}
}
return result;
}
private String buildTaskHeartbeatKey(Long taskId) {
return "query-asin:task:heartbeat:" + taskId;
}
private String buildTaskEntityKey(Long taskId) {
return "query-asin:task:entity:" + taskId;
}
private boolean isLocalCacheFresh(LocalTaskEntityCacheEntry cached, long now) {
return cached != null
&& now - cached.cachedAtMillis() <= Math.max(0L, taskPressureProperties.getLocalTaskEntityCacheMillis());
}
private record LocalTaskEntityCacheEntry(long cachedAtMillis, FileTaskEntity task) {}
}

View File

@@ -0,0 +1,947 @@
package com.nanri.aiimage.modules.queryasin.service;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.IdUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.config.TaskPressureProperties;
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinAsinStatusDto;
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCountryAsinsDto;
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCountryResultDto;
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCreateTaskRequest;
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinShopPayloadDto;
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinSubmitResultRequest;
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinTaskItemDto;
import com.nanri.aiimage.modules.queryasin.model.vo.QueryAsinTaskBatchVo;
import com.nanri.aiimage.modules.queryasin.model.vo.QueryAsinCreateTaskVo;
import com.nanri.aiimage.modules.queryasin.model.vo.QueryAsinHistoryVo;
import com.nanri.aiimage.modules.queryasin.model.vo.QueryAsinResultItemVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskDashboardVo;
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;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.File;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@Service
@RequiredArgsConstructor
@Slf4j
public class QueryAsinTaskService {
private static final String MODULE_TYPE = "QUERY_ASIN";
private static final String CONTENT_TYPE_XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
private static final String INTERRUPTED_MESSAGE = "Python 在该店铺结果提交完成前中断";
private final FileTaskMapper fileTaskMapper;
private final FileResultMapper fileResultMapper;
private final QueryAsinResolveService queryAsinResolveService;
private final QueryAsinExcelAssemblyService excelAssemblyService;
private final QueryAsinTaskCacheService taskCacheService;
private final OssStorageService ossStorageService;
private final ZiniaoShopSwitchService ziniaoShopSwitchService;
private final ObjectMapper objectMapper;
private final TaskPressureProperties taskPressureProperties;
private FileTaskEntity loadTaskForExecution(Long taskId) {
Map<Long, FileTaskEntity> cachedTasks = taskCacheService.getTaskCacheBatch(List.of(taskId));
FileTaskEntity cached = cachedTasks.get(taskId);
if (cached != null) {
return cached;
}
FileTaskEntity dbTask = fileTaskMapper.selectById(taskId);
if (dbTask != null && MODULE_TYPE.equals(dbTask.getModuleType())) {
taskCacheService.saveTaskCache(dbTask);
}
return dbTask;
}
private Map<Long, FileTaskEntity> loadTaskMapByIds(List<Long> taskIds) {
Map<Long, FileTaskEntity> result = new LinkedHashMap<>();
if (taskIds == null || taskIds.isEmpty()) {
return result;
}
List<Long> normalizedTaskIds = taskIds.stream()
.filter(taskId -> taskId != null && taskId > 0)
.distinct()
.toList();
if (normalizedTaskIds.isEmpty()) {
return result;
}
Map<Long, FileTaskEntity> cachedTasks = taskCacheService.getTaskCacheBatch(normalizedTaskIds);
result.putAll(cachedTasks);
List<Long> missingTaskIds = normalizedTaskIds.stream()
.filter(taskId -> !cachedTasks.containsKey(taskId))
.toList();
if (!missingTaskIds.isEmpty()) {
for (FileTaskEntity dbTask : selectTasksByIdsInBatches(missingTaskIds)) {
if (dbTask == null || !MODULE_TYPE.equals(dbTask.getModuleType())) {
continue;
}
result.put(dbTask.getId(), dbTask);
if ("RUNNING".equals(dbTask.getStatus())) {
taskCacheService.saveTaskCache(dbTask);
}
}
}
return result;
}
private List<FileTaskEntity> selectTasksByIdsInBatches(List<Long> taskIds) {
List<FileTaskEntity> tasks = new ArrayList<>();
if (taskIds == null || taskIds.isEmpty()) {
return tasks;
}
int batchSize = Math.max(1, taskPressureProperties.getDbSelectBatchSize());
for (int start = 0; start < taskIds.size(); start += batchSize) {
int end = Math.min(start + batchSize, taskIds.size());
tasks.addAll(fileTaskMapper.selectBatchIds(taskIds.subList(start, end)));
}
return tasks;
}
public ProductRiskDashboardVo dashboard(Long userId) {
validateUserId(userId);
ProductRiskDashboardVo vo = new ProductRiskDashboardVo();
vo.setCandidateCount(queryAsinResolveService.countCandidates(userId));
vo.setProcessedTaskCount(countTasks(userId, List.of("SUCCESS", "FAILED")));
vo.setSuccessTaskCount(countTasks(userId, List.of("SUCCESS")));
vo.setFailedTaskCount(countTasks(userId, List.of("FAILED")));
return vo;
}
public QueryAsinHistoryVo listHistory(Long userId) {
validateUserId(userId);
QueryAsinHistoryVo vo = new QueryAsinHistoryVo();
List<FileResultEntity> entities = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
.eq(FileResultEntity::getUserId, userId)
.orderByDesc(FileResultEntity::getCreatedAt)
.last("limit 100"));
if (entities.isEmpty()) {
vo.setItems(List.of());
return vo;
}
Map<Long, FileTaskEntity> taskMap = loadTaskMap(entities);
Map<Long, Map<Long, QueryAsinResultItemVo>> snapshotMap = buildSnapshotMap(taskMap);
List<QueryAsinResultItemVo> items = new ArrayList<>();
for (FileResultEntity entity : entities) {
FileTaskEntity task = taskMap.get(entity.getTaskId());
QueryAsinResultItemVo snapshot = snapshotMap.getOrDefault(entity.getTaskId(), Map.of()).get(entity.getId());
items.add(toHistoryItem(entity, task, snapshot));
}
vo.setItems(items);
return vo;
}
public QueryAsinTaskBatchVo getTaskProgressBatch(List<Long> taskIds) {
QueryAsinTaskBatchVo batch = new QueryAsinTaskBatchVo();
if (taskIds == null || taskIds.isEmpty()) {
return batch;
}
List<Long> normalizedTaskIds = taskIds.stream()
.filter(taskId -> taskId != null && taskId > 0)
.distinct()
.limit(50)
.toList();
if (normalizedTaskIds.isEmpty()) {
return batch;
}
Map<Long, FileTaskEntity> taskMap = loadTaskMapByIds(normalizedTaskIds);
List<FileResultEntity> rows = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
.in(FileResultEntity::getTaskId, normalizedTaskIds)
.orderByAsc(FileResultEntity::getId));
Map<Long, List<FileResultEntity>> rowsByTaskId = new LinkedHashMap<>();
for (FileResultEntity row : rows) {
rowsByTaskId.computeIfAbsent(row.getTaskId(), ignored -> new ArrayList<>()).add(row);
}
for (Long taskId : normalizedTaskIds) {
FileTaskEntity task = taskMap.get(taskId);
if (task == null) {
batch.getMissingTaskIds().add(taskId);
continue;
}
List<FileResultEntity> taskRows = rowsByTaskId.get(taskId);
if (taskRows == null || taskRows.isEmpty()) {
batch.getMissingTaskIds().add(taskId);
continue;
}
List<QueryAsinResultItemVo> snapshots = buildSnapshotFromDb(task, taskRows);
batch.getItems().addAll(snapshots);
}
return batch;
}
public String resolveResultDownloadUrl(Long resultId, Long userId) {
FileResultEntity entity = fileResultMapper.selectById(resultId);
if (entity == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
throw new BusinessException("记录不存在");
}
if (blank(entity.getResultFileUrl())) {
throw new BusinessException("暂无可下载文件");
}
return ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl());
}
public String resolveResultDownloadFilename(Long resultId, Long userId) {
FileResultEntity entity = fileResultMapper.selectById(resultId);
if (entity == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
throw new BusinessException("记录不存在");
}
return !blank(entity.getResultFilename())
? entity.getResultFilename()
: safeFileStem(entity.getSourceFilename()) + ".xlsx";
}
@Transactional
public QueryAsinCreateTaskVo createTask(QueryAsinCreateTaskRequest request) {
validateUserId(request.getUserId());
if (request.getItems() == null || request.getItems().isEmpty()) {
throw new BusinessException("items 不能为空");
}
List<QueryAsinTaskItemDto> uniqueItems = dedupeItems(request.getItems());
if (uniqueItems.isEmpty()) {
throw new BusinessException("items 不能为空");
}
for (QueryAsinTaskItemDto item : uniqueItems) {
if (item == null || !item.isMatched()) {
throw new BusinessException("存在未匹配店铺,无法创建任务");
}
if (item.getQueryAsins() == null || item.getQueryAsins().isEmpty()) {
item.setQueryAsins(queryAsinResolveService.loadQueryAsinsForShop(request.getUserId(), item.getShopName()));
}
if (item.getQueryAsins() == null || item.getQueryAsins().isEmpty()) {
throw new BusinessException("后台查询 ASIN 表暂无数据,无法创建任务");
}
}
LocalDateTime now = LocalDateTime.now();
FileTaskEntity task = new FileTaskEntity();
task.setTaskNo(MODULE_TYPE + "-" + IdUtil.getSnowflakeNextIdStr());
task.setModuleType(MODULE_TYPE);
task.setTaskMode("PYTHON_QUEUE");
task.setStatus("RUNNING");
task.setSourceFileCount(uniqueItems.size());
task.setSuccessFileCount(0);
task.setFailedFileCount(0);
task.setCreatedBy("user:" + request.getUserId());
task.setUserId(request.getUserId());
task.setCreatedAt(now);
task.setUpdatedAt(now);
fileTaskMapper.insert(task);
taskCacheService.saveTaskCache(task);
taskCacheService.touchTaskHeartbeat(task.getId());
List<QueryAsinResultItemVo> snapshots = new ArrayList<>();
for (QueryAsinTaskItemDto item : uniqueItems) {
String normalizedShopName = ziniaoShopSwitchService.normalizeShopName(item.getShopName());
if (blank(normalizedShopName)) {
throw new BusinessException("任务数据已失效,请刷新后重试");
}
FileResultEntity result = new FileResultEntity();
result.setTaskId(task.getId());
result.setModuleType(MODULE_TYPE);
result.setSourceFilename(normalizedShopName);
result.setSourceFileUrl(item.getShopId());
result.setUserId(request.getUserId());
result.setSuccess(null);
result.setCreatedAt(now);
fileResultMapper.insert(result);
snapshots.add(toSnapshotVo(result, item, task.getStatus(), null));
}
persistTaskJson(task, uniqueItems, snapshots);
QueryAsinCreateTaskVo vo = new QueryAsinCreateTaskVo();
vo.setTaskId(task.getId());
vo.setItems(snapshots);
return vo;
}
@Transactional
public void submitResult(Long taskId, QueryAsinSubmitResultRequest request) {
if (taskId == null || taskId <= 0) {
throw new BusinessException("taskId 不合法");
}
if (request == null || request.getShops() == null || request.getShops().isEmpty()) {
throw new BusinessException("shops 不能为空");
}
FileTaskEntity task = loadTaskForExecution(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
throw new BusinessException("任务不存在");
}
if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) {
throw new BusinessException("任务已结束,拒绝重复提交");
}
taskCacheService.touchTaskHeartbeat(taskId);
List<FileResultEntity> resultRows = listTaskRows(taskId);
List<QueryAsinResultItemVo> snapshots = buildSnapshotFromDb(task, resultRows);
Map<Long, QueryAsinResultItemVo> snapshotByResultId = indexSnapshotByResultId(snapshots);
Map<String, QueryAsinShopPayloadDto> payloadByShop = normalizePayloadByShop(request.getShops());
for (FileResultEntity row : resultRows) {
String shopKey = row.getSourceFilename();
QueryAsinShopPayloadDto incoming = payloadByShop.get(shopKey);
if (incoming == null) {
continue;
}
QueryAsinShopPayloadDto mergedPayload = mergeShopPayload(taskId, shopKey, incoming);
QueryAsinResultItemVo snapshot = snapshotByResultId.get(row.getId());
mergePayloadIntoSnapshot(snapshot, mergedPayload);
if (!blank(mergedPayload.getError())) {
markResultFailed(row, mergedPayload.getError());
applyFailureToSnapshot(snapshot, mergedPayload.getError());
taskCacheService.removeShopMergedPayload(taskId, shopKey);
continue;
}
if (Boolean.TRUE.equals(mergedPayload.getShopDone())) {
markResultSuccess(row);
applySuccessToSnapshot(snapshot);
taskCacheService.removeShopMergedPayload(taskId, shopKey);
}
}
persistSnapshotJson(task, snapshots);
fileTaskMapper.updateById(task);
tryFinalizeTask(taskId, false);
}
@Transactional
public boolean tryFinalizeTask(Long taskId, boolean fromCompensation) {
if (taskId == null || taskId <= 0) {
return false;
}
FileTaskEntity task = loadTaskForExecution(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
return false;
}
if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) {
return true;
}
List<FileResultEntity> rows = listTaskRows(taskId);
if (rows.isEmpty()) {
return false;
}
Map<String, QueryAsinShopPayloadDto> cachedPayloads = taskCacheService.getAllShopMergedPayload(taskId);
List<QueryAsinResultItemVo> snapshots = buildSnapshotFromDb(task, rows);
Map<Long, QueryAsinResultItemVo> snapshotByResultId = indexSnapshotByResultId(snapshots);
boolean changed = false;
for (FileResultEntity row : rows) {
if (isResultFinished(row)) {
continue;
}
QueryAsinResultItemVo snapshot = snapshotByResultId.get(row.getId());
QueryAsinShopPayloadDto cached = cachedPayloads.get(row.getSourceFilename());
if (cached == null) {
if (fromCompensation) {
markResultFailed(row, INTERRUPTED_MESSAGE);
applyFailureToSnapshot(snapshot, INTERRUPTED_MESSAGE);
changed = true;
}
continue;
}
mergePayloadIntoSnapshot(snapshot, cached);
if (!blank(cached.getError())) {
markResultFailed(row, cached.getError());
applyFailureToSnapshot(snapshot, cached.getError());
taskCacheService.removeShopMergedPayload(taskId, row.getSourceFilename());
changed = true;
continue;
}
if (Boolean.TRUE.equals(cached.getShopDone()) || (fromCompensation && hasAnyPayloadData(cached))) {
markResultSuccess(row);
applySuccessToSnapshot(snapshot);
taskCacheService.removeShopMergedPayload(taskId, row.getSourceFilename());
changed = true;
continue;
}
if (fromCompensation) {
markResultFailed(row, INTERRUPTED_MESSAGE);
applyFailureToSnapshot(snapshot, INTERRUPTED_MESSAGE);
taskCacheService.removeShopMergedPayload(taskId, row.getSourceFilename());
changed = true;
}
}
List<FileResultEntity> latestRows = listTaskRows(taskId);
updateTaskStatusFromRows(task, latestRows);
if (latestRows.stream().allMatch(this::isResultFinished)) {
finalizeTaskWorkbook(task, latestRows, snapshots);
return true;
}
persistSnapshotJson(task, snapshots);
fileTaskMapper.updateById(task);
return changed;
}
@Transactional
public void deleteTask(Long taskId, Long userId) {
validateUserId(userId);
FileTaskEntity task = loadTaskForExecution(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) {
throw new BusinessException("任务不存在");
}
fileResultMapper.delete(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getTaskId, taskId)
.eq(FileResultEntity::getModuleType, MODULE_TYPE));
fileTaskMapper.deleteById(taskId);
taskCacheService.deleteTaskCache(taskId);
}
@Transactional
public void deleteHistory(Long resultId, Long userId) {
validateUserId(userId);
FileResultEntity entity = fileResultMapper.selectById(resultId);
if (entity == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
throw new BusinessException("记录不存在");
}
Long taskId = entity.getTaskId();
fileResultMapper.deleteById(resultId);
reconcileTaskAfterResultRemoval(taskId);
}
private void reconcileTaskAfterResultRemoval(Long taskId) {
if (taskId == null || taskId <= 0) {
return;
}
FileTaskEntity task = loadTaskForExecution(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
return;
}
List<FileResultEntity> rows = listTaskRows(taskId);
if (rows.isEmpty()) {
fileTaskMapper.deleteById(taskId);
taskCacheService.deleteTaskCache(taskId);
return;
}
updateTaskStatusFromRows(task, rows);
persistSnapshotJson(task, buildSnapshotFromDb(task, rows));
fileTaskMapper.updateById(task);
}
private long countTasks(Long userId, List<String> statuses) {
Long count = fileTaskMapper.selectCount(new LambdaQueryWrapper<FileTaskEntity>()
.eq(FileTaskEntity::getModuleType, MODULE_TYPE)
.eq(FileTaskEntity::getUserId, userId)
.in(FileTaskEntity::getStatus, statuses));
return count == null ? 0L : count;
}
private List<FileResultEntity> listTaskRows(Long taskId) {
return fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getTaskId, taskId)
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
.orderByAsc(FileResultEntity::getId));
}
private Map<String, QueryAsinShopPayloadDto> normalizePayloadByShop(List<QueryAsinShopPayloadDto> shops) {
Map<String, QueryAsinShopPayloadDto> payloadByShop = new LinkedHashMap<>();
for (QueryAsinShopPayloadDto item : shops) {
if (item == null) {
continue;
}
String shopKey = ziniaoShopSwitchService.normalizeShopName(item.getShopName());
if (blank(shopKey)) {
continue;
}
item.setShopName(shopKey);
payloadByShop.put(shopKey, item);
}
return payloadByShop;
}
private Map<Long, FileTaskEntity> loadTaskMap(List<FileResultEntity> entities) {
List<Long> taskIds = entities.stream()
.map(FileResultEntity::getTaskId)
.filter(id -> id != null && id > 0)
.distinct()
.toList();
Map<Long, FileTaskEntity> taskMap = new LinkedHashMap<>();
if (taskIds.isEmpty()) {
return taskMap;
}
for (FileTaskEntity task : selectTasksByIdsInBatches(taskIds)) {
if (task != null && MODULE_TYPE.equals(task.getModuleType())) {
taskMap.put(task.getId(), task);
}
}
return taskMap;
}
private Map<Long, Map<Long, QueryAsinResultItemVo>> buildSnapshotMap(Map<Long, FileTaskEntity> taskMap) {
Map<Long, Map<Long, QueryAsinResultItemVo>> out = new LinkedHashMap<>();
for (Map.Entry<Long, FileTaskEntity> entry : taskMap.entrySet()) {
out.put(entry.getKey(), indexSnapshotByResultId(parseTaskSnapshots(entry.getValue().getResultJson())));
}
return out;
}
private QueryAsinResultItemVo toHistoryItem(FileResultEntity entity, FileTaskEntity task, QueryAsinResultItemVo snapshot) {
QueryAsinResultItemVo item = snapshot != null ? snapshot : new QueryAsinResultItemVo();
item.setResultId(entity.getId());
item.setTaskId(entity.getTaskId());
item.setShopName(firstNonBlank(item.getShopName(), entity.getSourceFilename()));
item.setShopId(firstNonBlank(item.getShopId(), entity.getSourceFileUrl()));
item.setTaskStatus(task != null ? task.getStatus() : item.getTaskStatus());
item.setSuccess(entity.getSuccess() == null ? item.getSuccess() : entity.getSuccess() == 1);
item.setError(!blank(entity.getErrorMessage()) ? entity.getErrorMessage() : item.getError());
item.setCreatedAt(entity.getCreatedAt());
item.setFinishedAt(task != null ? task.getFinishedAt() : item.getFinishedAt());
item.setOutputFilename(firstNonBlank(item.getOutputFilename(), entity.getResultFilename()));
item.setDownloadUrl(blank(entity.getResultFileUrl())
? item.getDownloadUrl()
: ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl()));
if (item.getCountryResults() == null) {
item.setCountryResults(new ArrayList<>());
}
if (item.getQueryAsins() == null) {
item.setQueryAsins(new ArrayList<>());
}
return item;
}
private List<QueryAsinTaskItemDto> dedupeItems(List<QueryAsinTaskItemDto> items) {
LinkedHashMap<String, QueryAsinTaskItemDto> map = new LinkedHashMap<>();
for (QueryAsinTaskItemDto item : items) {
if (item == null) {
continue;
}
String normalizedShopName = ziniaoShopSwitchService.normalizeShopName(item.getShopName());
if (blank(normalizedShopName)) {
continue;
}
item.setShopName(normalizedShopName);
String key = normalizedShopName + "::" + Objects.toString(item.getShopId(), "");
map.put(key, item);
}
return new ArrayList<>(map.values());
}
private QueryAsinResultItemVo toSnapshotVo(FileResultEntity result, QueryAsinTaskItemDto item, String taskStatus, LocalDateTime finishedAt) {
QueryAsinResultItemVo vo = new QueryAsinResultItemVo();
vo.setResultId(result.getId());
vo.setTaskId(result.getTaskId());
vo.setShopName(item.getShopName());
vo.setShopId(item.getShopId());
vo.setPlatform(item.getPlatform());
vo.setCompanyName(item.getCompanyName());
vo.setMatched(item.isMatched());
vo.setMatchStatus(item.getMatchStatus());
vo.setMatchMessage(item.getMatchMessage());
vo.setTaskStatus(taskStatus);
vo.setSuccess(result.getSuccess() == null ? null : result.getSuccess() == 1);
vo.setError(result.getErrorMessage());
vo.setCreatedAt(result.getCreatedAt());
vo.setFinishedAt(finishedAt);
vo.setCountryResults(new ArrayList<>());
vo.setQueryAsins(copyCountryAsins(item.getQueryAsins()));
vo.setOutputFilename(result.getResultFilename());
vo.setDownloadUrl(null);
return vo;
}
private void persistTaskJson(FileTaskEntity task, List<QueryAsinTaskItemDto> requestItems, List<QueryAsinResultItemVo> snapshots) {
try {
task.setRequestJson(objectMapper.writeValueAsString(requestItems));
task.setResultJson(objectMapper.writeValueAsString(snapshots));
fileTaskMapper.updateById(task);
} catch (Exception ex) {
throw new BusinessException("查询ASIN任务快照保存失败");
}
}
private List<QueryAsinResultItemVo> buildSnapshotFromDb(FileTaskEntity task, List<FileResultEntity> rows) {
List<QueryAsinResultItemVo> existing = parseTaskSnapshots(task.getResultJson());
Map<Long, QueryAsinResultItemVo> snapshotByResultId = indexSnapshotByResultId(existing);
List<QueryAsinResultItemVo> list = new ArrayList<>();
for (FileResultEntity row : rows) {
list.add(toHistoryItem(row, task, snapshotByResultId.get(row.getId())));
}
return list;
}
private Map<Long, QueryAsinResultItemVo> indexSnapshotByResultId(List<QueryAsinResultItemVo> snapshots) {
Map<Long, QueryAsinResultItemVo> map = new LinkedHashMap<>();
if (snapshots == null) {
return map;
}
for (QueryAsinResultItemVo snapshot : snapshots) {
if (snapshot != null && snapshot.getResultId() != null) {
map.put(snapshot.getResultId(), snapshot);
}
}
return map;
}
private void updateTaskStatusFromRows(FileTaskEntity task, List<FileResultEntity> rows) {
long successCount = rows.stream().filter(row -> Integer.valueOf(1).equals(row.getSuccess())).count();
long failedCount = rows.stream().filter(row -> Integer.valueOf(0).equals(row.getSuccess())).count();
long pendingCount = rows.stream().filter(row -> !isResultFinished(row)).count();
task.setSuccessFileCount((int) successCount);
task.setFailedFileCount((int) failedCount);
task.setUpdatedAt(LocalDateTime.now());
if (pendingCount > 0) {
task.setStatus("RUNNING");
task.setFinishedAt(null);
task.setErrorMessage(null);
return;
}
task.setStatus(failedCount > 0 ? "FAILED" : "SUCCESS");
task.setFinishedAt(LocalDateTime.now());
if (failedCount > 0) {
List<String> errors = new ArrayList<>();
for (FileResultEntity row : rows) {
if (!blank(row.getErrorMessage())) {
errors.add(row.getSourceFilename() + ": " + row.getErrorMessage());
}
}
task.setErrorMessage(String.join("; ", errors));
} else {
task.setErrorMessage(null);
}
}
private QueryAsinShopPayloadDto mergeShopPayload(Long taskId, String shopKey, QueryAsinShopPayloadDto incoming) {
QueryAsinShopPayloadDto merged = taskCacheService.getShopMergedPayload(taskId, shopKey);
if (merged == null) {
merged = new QueryAsinShopPayloadDto();
merged.setShopName(shopKey);
merged.setCountryResults(new ArrayList<>());
}
merged.setShopName(firstNonBlank(incoming.getShopName(), merged.getShopName()));
merged.setSubmissionId(firstNonBlank(incoming.getSubmissionId(), merged.getSubmissionId()));
if (!blank(incoming.getError())) {
merged.setError(incoming.getError().trim());
merged.setShopDone(Boolean.TRUE);
taskCacheService.saveShopMergedPayload(taskId, shopKey, merged);
return merged;
}
merged.setCountryResults(mergeCountryResults(merged.getCountryResults(), incoming.getCountryResults()));
if (incoming.getShopDone() == null || Boolean.TRUE.equals(incoming.getShopDone())) {
merged.setShopDone(Boolean.TRUE);
}
taskCacheService.saveShopMergedPayload(taskId, shopKey, merged);
return merged;
}
private void mergePayloadIntoSnapshot(QueryAsinResultItemVo snapshot, QueryAsinShopPayloadDto payload) {
if (snapshot == null || payload == null) {
return;
}
snapshot.setShopName(firstNonBlank(payload.getShopName(), snapshot.getShopName()));
snapshot.setCountryResults(copyCountryResults(payload.getCountryResults()));
if (!blank(payload.getError())) {
snapshot.setError(payload.getError().trim());
}
}
private void applySuccessToSnapshot(QueryAsinResultItemVo snapshot) {
if (snapshot == null) {
return;
}
snapshot.setSuccess(Boolean.TRUE);
snapshot.setError(null);
}
private void applyFailureToSnapshot(QueryAsinResultItemVo snapshot, String error) {
if (snapshot == null) {
return;
}
snapshot.setSuccess(Boolean.FALSE);
snapshot.setError(blankToNull(error));
}
private List<QueryAsinCountryResultDto> mergeCountryResults(List<QueryAsinCountryResultDto> base, List<QueryAsinCountryResultDto> incoming) {
Map<String, QueryAsinCountryResultDto> map = new LinkedHashMap<>();
for (QueryAsinCountryResultDto item : copyCountryResults(base)) {
map.put(item.getCountry(), item);
}
for (QueryAsinCountryResultDto item : copyCountryResults(incoming)) {
if (blank(item.getCountry())) {
continue;
}
QueryAsinCountryResultDto existing = map.get(item.getCountry());
if (existing == null) {
map.put(item.getCountry(), item);
continue;
}
List<QueryAsinAsinStatusDto> merged = new ArrayList<>(existing.getItems() == null ? List.of() : existing.getItems());
for (QueryAsinAsinStatusDto row : item.getItems() == null ? List.<QueryAsinAsinStatusDto>of() : item.getItems()) {
String asin = normalizeAsin(row == null ? null : row.getAsin());
String status = row == null ? "" : firstNonBlank(row.getStatus(), "").trim();
if (asin.isEmpty() && status.isEmpty()) {
continue;
}
boolean exists = merged.stream().anyMatch(old ->
Objects.equals(normalizeAsin(old.getAsin()), asin)
&& Objects.equals(firstNonBlank(old.getStatus(), "").trim(), status));
if (!exists) {
QueryAsinAsinStatusDto normalized = new QueryAsinAsinStatusDto();
normalized.setAsin(asin);
normalized.setStatus(status);
merged.add(normalized);
}
}
existing.setItems(merged);
}
return new ArrayList<>(map.values());
}
private List<QueryAsinCountryAsinsDto> mergeCountryAsins(List<QueryAsinCountryAsinsDto> base, List<QueryAsinCountryAsinsDto> incoming) {
Map<String, QueryAsinCountryAsinsDto> map = new LinkedHashMap<>();
for (QueryAsinCountryAsinsDto item : copyCountryAsins(base)) {
map.put(item.getCountry(), item);
}
for (QueryAsinCountryAsinsDto item : copyCountryAsins(incoming)) {
String country = normalizeCountry(item.getCountry());
if (blank(country)) {
continue;
}
item.setCountry(country);
QueryAsinCountryAsinsDto existing = map.get(country);
if (existing == null) {
map.put(country, item);
continue;
}
List<String> merged = new ArrayList<>(existing.getAsins() == null ? List.of() : existing.getAsins());
for (String asin : item.getAsins() == null ? List.<String>of() : item.getAsins()) {
String normalized = normalizeAsin(asin);
if (!normalized.isEmpty() && !merged.contains(normalized)) {
merged.add(normalized);
}
}
existing.setAsins(merged);
}
return new ArrayList<>(map.values());
}
private boolean hasAnyPayloadData(QueryAsinShopPayloadDto payload) {
if (payload == null) {
return false;
}
if (payload.getCountryResults() != null) {
for (QueryAsinCountryResultDto countryResult : payload.getCountryResults()) {
if (countryResult == null || countryResult.getItems() == null) {
continue;
}
for (QueryAsinAsinStatusDto item : countryResult.getItems()) {
if (item != null && (!blank(item.getAsin()) || !blank(item.getStatus()))) {
return true;
}
}
}
}
return false;
}
private void finalizeTaskWorkbook(FileTaskEntity task, List<FileResultEntity> rows, List<QueryAsinResultItemVo> snapshots) {
List<QueryAsinResultItemVo> successItems = snapshots.stream()
.filter(item -> Boolean.TRUE.equals(item.getSuccess()))
.toList();
if (!successItems.isEmpty()) {
File workRoot = FileUtil.mkdir(FileUtil.file(System.getProperty("java.io.tmpdir"), "query-asin-result", String.valueOf(task.getId())));
String filename = safeFileStem("查询ASIN-" + task.getId()) + ".xlsx";
File xlsx = FileUtil.file(workRoot, filename);
try {
excelAssemblyService.writeWorkbook(xlsx, successItems);
String objectKey = ossStorageService.uploadResultFile(xlsx, MODULE_TYPE);
long fileSize = xlsx.length();
int rowCount = excelAssemblyService.countRows(successItems);
for (FileResultEntity row : rows) {
if (Integer.valueOf(1).equals(row.getSuccess())) {
row.setResultFilename(filename);
row.setResultFileUrl(objectKey);
row.setResultFileSize(fileSize);
row.setResultContentType(CONTENT_TYPE_XLSX);
row.setRowCount(rowCount);
fileResultMapper.updateById(row);
}
}
snapshots = buildSnapshotFromDb(task, rows);
} finally {
FileUtil.del(xlsx);
}
}
persistSnapshotJson(task, snapshots);
fileTaskMapper.updateById(task);
taskCacheService.deleteTaskCache(task.getId());
}
private void markResultSuccess(FileResultEntity row) {
row.setSuccess(1);
row.setErrorMessage(null);
fileResultMapper.updateById(row);
}
private void markResultFailed(FileResultEntity row, String message) {
row.setSuccess(0);
row.setErrorMessage(blankToNull(message));
row.setResultFilename(null);
row.setResultFileUrl(null);
row.setResultFileSize(0L);
row.setResultContentType(null);
row.setRowCount(0);
fileResultMapper.updateById(row);
}
private boolean isResultFinished(FileResultEntity row) {
return Integer.valueOf(1).equals(row.getSuccess()) || Integer.valueOf(0).equals(row.getSuccess());
}
private List<QueryAsinResultItemVo> parseTaskSnapshots(String json) {
if (blank(json)) {
return new ArrayList<>();
}
try {
return objectMapper.readValue(json, new TypeReference<List<QueryAsinResultItemVo>>() {});
} catch (Exception ex) {
log.warn("[query-asin] parse task snapshot failed: {}", ex.getMessage());
return new ArrayList<>();
}
}
private void persistSnapshotJson(FileTaskEntity task, List<QueryAsinResultItemVo> snapshots) {
try {
task.setResultJson(objectMapper.writeValueAsString(snapshots == null ? List.of() : snapshots));
} catch (Exception ex) {
throw new BusinessException("查询ASIN任务快照保存失败");
}
}
private List<QueryAsinCountryResultDto> copyCountryResults(List<QueryAsinCountryResultDto> results) {
List<QueryAsinCountryResultDto> copy = new ArrayList<>();
if (results == null) {
return copy;
}
for (QueryAsinCountryResultDto source : results) {
if (source == null || blank(source.getCountry())) {
continue;
}
QueryAsinCountryResultDto item = new QueryAsinCountryResultDto();
item.setCountry(normalizeCountry(source.getCountry()));
List<QueryAsinAsinStatusDto> rows = new ArrayList<>();
if (source.getItems() != null) {
for (QueryAsinAsinStatusDto sourceRow : source.getItems()) {
String asin = normalizeAsin(sourceRow == null ? null : sourceRow.getAsin());
String status = sourceRow == null ? "" : firstNonBlank(sourceRow.getStatus(), "").trim();
if (asin.isEmpty() && status.isEmpty()) {
continue;
}
QueryAsinAsinStatusDto row = new QueryAsinAsinStatusDto();
row.setAsin(asin);
row.setStatus(status);
rows.add(row);
}
}
item.setItems(rows);
copy.add(item);
}
return copy;
}
private List<QueryAsinCountryAsinsDto> copyCountryAsins(List<QueryAsinCountryAsinsDto> items) {
List<QueryAsinCountryAsinsDto> copy = new ArrayList<>();
if (items == null) {
return copy;
}
for (QueryAsinCountryAsinsDto source : items) {
if (source == null || blank(source.getCountry())) {
continue;
}
QueryAsinCountryAsinsDto item = new QueryAsinCountryAsinsDto();
item.setCountry(normalizeCountry(source.getCountry()));
List<String> asins = new ArrayList<>();
if (source.getAsins() != null) {
for (String asin : source.getAsins()) {
String normalized = normalizeAsin(asin);
if (!normalized.isEmpty() && !asins.contains(normalized)) {
asins.add(normalized);
}
}
}
item.setAsins(asins);
copy.add(item);
}
return copy;
}
private String firstNonBlank(String first, String second) {
return !blank(first) ? first : second;
}
private String normalizeCountry(String country) {
if (country == null) {
return "";
}
String value = country.trim().toUpperCase();
return switch (value) {
case "德国" -> "DE";
case "英国" -> "UK";
case "法国" -> "FR";
case "意大利" -> "IT";
case "西班牙" -> "ES";
default -> value;
};
}
private String normalizeAsin(String asin) {
return asin == null ? "" : asin.trim().toUpperCase();
}
private String safeFileStem(String value) {
String raw = value == null ? "result" : value.trim();
String safe = raw.replaceAll("[\\\\/:*?\"<>|]+", "_");
return safe.isBlank() ? "result" : safe;
}
private boolean blank(String value) {
return value == null || value.isBlank();
}
private String blankToNull(String value) {
return blank(value) ? null : value.trim();
}
private void validateUserId(Long userId) {
if (userId == null || userId <= 0) {
throw new BusinessException("user_id 不合法");
}
}
}

View File

@@ -0,0 +1,125 @@
package com.nanri.aiimage.modules.shopkey.controller;
import com.nanri.aiimage.common.api.ApiResponse;
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;
import com.nanri.aiimage.modules.shopkey.model.vo.QueryAsinImportStartVo;
import com.nanri.aiimage.modules.shopkey.model.vo.QueryAsinItemVo;
import com.nanri.aiimage.modules.shopkey.model.vo.QueryAsinPageVo;
import com.nanri.aiimage.modules.shopkey.service.QueryAsinService;
import io.swagger.v3.oas.annotations.Operation;
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.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;
import org.springframework.web.multipart.MultipartFile;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/admin/query-asins")
@Tag(name = "查询 ASIN", description = "维护店铺按国家查询的 ASIN")
public class QueryAsinController {
private final QueryAsinService queryAsinService;
@GetMapping
@Operation(summary = "分页查询 ASIN")
public ApiResponse<QueryAsinPageVo> 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) {
return ApiResponse.success(queryAsinService.page(
page,
pageSize,
groupId,
shopName,
asin,
operatorId,
Boolean.TRUE.equals(superAdmin)));
}
@PostMapping
@Operation(summary = "新增或覆盖查询 ASIN")
public ApiResponse<QueryAsinItemVo> create(
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin,
@Valid @RequestBody QueryAsinCreateRequest request) {
return ApiResponse.success("保存成功",
queryAsinService.createOrUpdate(request, operatorId, Boolean.TRUE.equals(superAdmin)));
}
@PostMapping("/import")
@Operation(summary = "导入添加查询 ASIN")
public ApiResponse<QueryAsinImportStartVo> importExcel(
@Parameter(description = "xlsx/xls 文件", required = true) @RequestParam("file") MultipartFile file,
@Parameter(description = "Excel 未提供分组时使用的分组ID") @RequestParam(required = false) Long groupId,
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) {
return ApiResponse.success("开始导入",
queryAsinService.startImport(file, groupId, operatorId, Boolean.TRUE.equals(superAdmin)));
}
@GetMapping("/import/{importId}")
@Operation(summary = "查询导入添加进度")
public ApiResponse<QueryAsinImportProgressVo> importProgress(@PathVariable String importId) {
return ApiResponse.success(queryAsinService.getImportProgress(importId));
}
@PostMapping("/delete-import")
@Operation(summary = "导入删除查询 ASIN")
public ApiResponse<QueryAsinImportStartVo> deleteImportExcel(
@Parameter(description = "xlsx/xls 文件", required = true) @RequestParam("file") MultipartFile file,
@Parameter(description = "Excel 未提供分组时使用的分组ID") @RequestParam(required = false) Long groupId,
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) {
return ApiResponse.success("开始删除",
queryAsinService.startDeleteImport(file, groupId, operatorId, Boolean.TRUE.equals(superAdmin)));
}
@GetMapping("/delete-import/{importId}")
@Operation(summary = "查询导入删除进度")
public ApiResponse<QueryAsinImportProgressVo> deleteImportProgress(@PathVariable String importId) {
return ApiResponse.success(queryAsinService.getDeleteImportProgress(importId));
}
@PutMapping("/{id}/countries/{country}")
@Operation(summary = "编辑指定国家的查询 ASIN")
public ApiResponse<QueryAsinItemVo> 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,
@Valid @RequestBody QueryAsinCountryUpdateRequest request) {
return ApiResponse.success("保存成功", queryAsinService.updateCountry(
id,
country,
request.getAsin(),
operatorId,
Boolean.TRUE.equals(superAdmin)));
}
@DeleteMapping("/{id}/countries/{country}")
@Operation(summary = "删除指定国家的查询 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) {
queryAsinService.deleteCountry(id, country, operatorId, Boolean.TRUE.equals(superAdmin));
return ApiResponse.success("删除成功", null);
}
}

View File

@@ -0,0 +1,55 @@
package com.nanri.aiimage.modules.shopkey.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.nanri.aiimage.modules.shopkey.model.entity.QueryAsinEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Insert;
import java.util.Collection;
import java.util.List;
@Mapper
public interface QueryAsinMapper extends BaseMapper<QueryAsinEntity> {
@Select("""
<script>
SELECT id, group_id, shop_name, asin_de, asin_uk, asin_fr, asin_it, asin_es, created_at, updated_at
FROM biz_query_asin
WHERE group_id IN
<foreach collection="groupIds" item="groupId" open="(" separator="," close=")">
#{groupId}
</foreach>
AND shop_name IN
<foreach collection="shopNames" item="shopName" open="(" separator="," close=")">
#{shopName}
</foreach>
</script>
""")
List<QueryAsinEntity> selectByGroupIdsAndShopNames(@Param("groupIds") Collection<Long> groupIds,
@Param("shopNames") Collection<String> shopNames);
@Select("""
<script>
SELECT id, group_id, shop_name, asin_de, asin_uk, asin_fr, asin_it, asin_es, created_at, updated_at
FROM biz_query_asin
WHERE shop_name IN
<foreach collection="shopNames" item="shopName" open="(" separator="," close=")">
#{shopName}
</foreach>
</script>
""")
List<QueryAsinEntity> selectByShopNames(@Param("shopNames") Collection<String> shopNames);
@Insert("""
<script>
INSERT INTO biz_query_asin (group_id, shop_name, asin_de, asin_uk, asin_fr, asin_it, asin_es)
VALUES
<foreach collection="rows" item="row" separator=",">
(#{row.groupId}, #{row.shopName}, #{row.asinDe}, #{row.asinUk}, #{row.asinFr}, #{row.asinIt}, #{row.asinEs})
</foreach>
</script>
""")
int insertBatch(@Param("rows") List<QueryAsinEntity> rows);
}

View File

@@ -0,0 +1,13 @@
package com.nanri.aiimage.modules.shopkey.model.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class QueryAsinCountryUpdateRequest {
@Schema(description = "ASIN", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "ASIN 不能为空")
private String asin;
}

View File

@@ -0,0 +1,32 @@
package com.nanri.aiimage.modules.shopkey.model.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.List;
import java.util.Map;
@Data
public class QueryAsinCreateRequest {
@NotNull(message = "分组不能为空")
private Long groupId;
@NotBlank(message = "店铺名不能为空")
private String shopName;
@NotEmpty(message = "请至少选择一个国家")
private List<String> countries;
/**
* 兼容旧请求:选中的国家共用同一个 ASIN。
*/
private String asin;
/**
* 新请求:每个国家对应独立 ASIN例如 {"DE":"B0...", "UK":"B0..."}。
*/
private Map<String, String> asinMappings;
}

View File

@@ -0,0 +1,44 @@
package com.nanri.aiimage.modules.shopkey.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("biz_query_asin")
public class QueryAsinEntity {
@TableId(type = IdType.AUTO)
private Long id;
@TableField("group_id")
private Long groupId;
@TableField("shop_name")
private String shopName;
@TableField("asin_de")
private String asinDe;
@TableField("asin_uk")
private String asinUk;
@TableField("asin_fr")
private String asinFr;
@TableField("asin_it")
private String asinIt;
@TableField("asin_es")
private String asinEs;
@TableField("created_at")
private LocalDateTime createdAt;
@TableField("updated_at")
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,16 @@
package com.nanri.aiimage.modules.shopkey.model.vo;
import lombok.Data;
@Data
public class QueryAsinImportProgressVo {
private String status;
private Integer totalRows;
private Integer processedRows;
private Integer asinCount;
private Integer insertedCount;
private Integer deletedCount;
private Integer skippedCount;
private String errorMessage;
}

View File

@@ -0,0 +1,9 @@
package com.nanri.aiimage.modules.shopkey.model.vo;
import lombok.Data;
@Data
public class QueryAsinImportStartVo {
private String importId;
}

View File

@@ -0,0 +1,21 @@
package com.nanri.aiimage.modules.shopkey.model.vo;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class QueryAsinItemVo {
private Long id;
private Long groupId;
private String groupName;
private String shopName;
private String asinDe;
private String asinUk;
private String asinFr;
private String asinIt;
private String asinEs;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,15 @@
package com.nanri.aiimage.modules.shopkey.model.vo;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
public class QueryAsinPageVo {
private List<QueryAsinItemVo> items = new ArrayList<>();
private Long total = 0L;
private Long page = 1L;
private Long pageSize = 15L;
}

View File

@@ -0,0 +1,828 @@
package com.nanri.aiimage.modules.shopkey.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import cn.hutool.core.util.IdUtil;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.common.util.ExcelStreamReader;
import com.nanri.aiimage.config.TaskPressureProperties;
import com.nanri.aiimage.modules.shopkey.mapper.QueryAsinMapper;
import com.nanri.aiimage.modules.shopkey.model.dto.QueryAsinCreateRequest;
import com.nanri.aiimage.modules.shopkey.model.entity.QueryAsinEntity;
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageGroupEntity;
import com.nanri.aiimage.modules.shopkey.model.vo.QueryAsinImportProgressVo;
import com.nanri.aiimage.modules.shopkey.model.vo.QueryAsinImportStartVo;
import com.nanri.aiimage.modules.shopkey.model.vo.QueryAsinItemVo;
import com.nanri.aiimage.modules.shopkey.model.vo.QueryAsinPageVo;
import com.nanri.aiimage.modules.shopkey.model.vo.ShopManageGroupItemVo;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
@Service
@RequiredArgsConstructor
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 = Character.toString(0xFEFF);
private final QueryAsinMapper queryAsinMapper;
private final ShopManageGroupService shopManageGroupService;
private final TaskPressureProperties taskPressureProperties;
private final Map<String, QueryAsinImportProgressVo> importProgressMap = new ConcurrentHashMap<>();
private final Map<String, QueryAsinImportProgressVo> deleteImportProgressMap = new ConcurrentHashMap<>();
public QueryAsinPageVo page(long page, long pageSize, Long groupId, String shopName, String asin,
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));
Map<Long, String> groupNameById = buildGroupNameMap(rows.stream()
.map(QueryAsinEntity::getGroupId)
.filter(id -> id != null && id > 0)
.distinct()
.toList());
QueryAsinPageVo vo = new QueryAsinPageVo();
vo.setItems(rows.stream().map(row -> toItemVo(row, groupNameById.get(row.getGroupId()))).toList());
vo.setTotal(total);
vo.setPage(safePage);
vo.setPageSize(safePageSize);
return vo;
}
@Transactional
public QueryAsinItemVo createOrUpdate(QueryAsinCreateRequest request, Long operatorId, boolean superAdmin) {
ShopManageGroupEntity group = shopManageGroupService.getAccessibleById(request.getGroupId(), operatorId, superAdmin);
String shopName = normalizeRequired(request.getShopName(), "店铺名不能为空");
Set<String> countries = normalizeCountries(request.getCountries());
Map<String, String> countryAsinMap = normalizeCountryAsinMap(countries, request);
QueryAsinEntity entity = newImportEntity(group.getId(), shopName);
for (Map.Entry<String, String> entry : countryAsinMap.entrySet()) {
setCountryAsin(entity, entry.getKey(), entry.getValue());
}
List<QueryAsinEntity> existingRows = queryAsinMapper.selectByGroupIdsAndShopNames(
List.of(group.getId()), List.of(shopName));
for (QueryAsinEntity existing : existingRows) {
if (rowSignature(existing).equals(rowSignature(entity))) {
return toItemVo(existing, group.getGroupName());
}
}
queryAsinMapper.insert(entity);
return toItemVo(getById(entity.getId()), group.getGroupName());
}
@Transactional
public void deleteCountry(Long id, String country, Long operatorId, boolean superAdmin) {
QueryAsinEntity entity = getById(id);
validateGroupAccess(entity, operatorId, superAdmin);
setCountryAsin(entity, normalizeCountry(country), "");
if (isEmptyRow(entity)) {
queryAsinMapper.deleteById(entity.getId());
return;
}
queryAsinMapper.updateById(entity);
}
@Transactional
public QueryAsinItemVo updateCountry(Long id, String country, String asin, Long operatorId, boolean superAdmin) {
QueryAsinEntity entity = getById(id);
validateGroupAccess(entity, operatorId, superAdmin);
setCountryAsin(entity, normalizeCountry(country), normalizeAsin(asin));
queryAsinMapper.updateById(entity);
QueryAsinEntity saved = getById(entity.getId());
String groupName = "";
try {
ShopManageGroupEntity group = shopManageGroupService.getById(saved.getGroupId());
groupName = group.getGroupName();
} catch (Exception ignored) {
groupName = "";
}
return toItemVo(saved, groupName);
}
public QueryAsinImportStartVo startImport(MultipartFile file, Long fallbackGroupId,
Long operatorId, boolean superAdmin) {
return startImportTask(file, fallbackGroupId, operatorId, superAdmin, false);
}
public QueryAsinImportProgressVo getImportProgress(String importId) {
QueryAsinImportProgressVo progress = importProgressMap.get(importId);
if (progress == null) {
throw new BusinessException("导入任务不存在");
}
return progress;
}
public QueryAsinImportStartVo startDeleteImport(MultipartFile file, Long fallbackGroupId,
Long operatorId, boolean superAdmin) {
return startImportTask(file, fallbackGroupId, operatorId, superAdmin, true);
}
public QueryAsinImportProgressVo getDeleteImportProgress(String importId) {
QueryAsinImportProgressVo progress = deleteImportProgressMap.get(importId);
if (progress == null) {
throw new BusinessException("删除任务不存在");
}
return progress;
}
private QueryAsinImportStartVo startImportTask(MultipartFile file, Long fallbackGroupId, Long operatorId,
boolean superAdmin, boolean deleteMode) {
if (file == null || file.isEmpty()) {
throw new BusinessException("请上传 xlsx 或 xls 文件");
}
String filename = file.getOriginalFilename();
validateExcelFilename(filename);
if (fallbackGroupId != null && fallbackGroupId > 0) {
shopManageGroupService.getAccessibleById(fallbackGroupId, operatorId, superAdmin);
}
String importId = IdUtil.fastSimpleUUID();
QueryAsinImportProgressVo progress = newImportProgress();
Map<String, QueryAsinImportProgressVo> progressMap = deleteMode ? deleteImportProgressMap : importProgressMap;
progressMap.put(importId, progress);
try {
File tempFile = saveMultipartToTempFile(file, deleteMode ? "query-asin-delete-" : "query-asin-import-");
Thread.ofVirtual().start(() -> runImportTask(importId, tempFile, filename, fallbackGroupId,
operatorId, superAdmin, deleteMode));
} catch (Exception ex) {
progressMap.remove(importId);
throw new BusinessException("读取上传文件失败");
}
QueryAsinImportStartVo vo = new QueryAsinImportStartVo();
vo.setImportId(importId);
return vo;
}
private QueryAsinImportProgressVo newImportProgress() {
QueryAsinImportProgressVo progress = new QueryAsinImportProgressVo();
progress.setStatus("pending");
progress.setTotalRows(0);
progress.setProcessedRows(0);
progress.setAsinCount(0);
progress.setInsertedCount(0);
progress.setDeletedCount(0);
progress.setSkippedCount(0);
return progress;
}
private void runImportTask(String importId, File tempFile, String filename, Long fallbackGroupId,
Long operatorId, boolean superAdmin, boolean deleteMode) {
Map<String, QueryAsinImportProgressVo> progressMap = deleteMode ? deleteImportProgressMap : importProgressMap;
QueryAsinImportProgressVo progress = progressMap.get(importId);
if (progress == null) {
deleteQuietly(tempFile);
return;
}
progress.setStatus("running");
progress.setErrorMessage(null);
try {
processImportFile(tempFile, filename, fallbackGroupId, operatorId, superAdmin, deleteMode, progress);
progress.setStatus("success");
} catch (Exception ex) {
progress.setStatus("failed");
progress.setErrorMessage(ex instanceof BusinessException ? ex.getMessage()
: (deleteMode ? "导入删除 Excel 失败" : "导入添加 Excel 失败"));
} finally {
deleteQuietly(tempFile);
}
}
private void processImportFile(File tempFile, String filename, Long fallbackGroupId, Long operatorId,
boolean superAdmin, boolean deleteMode, QueryAsinImportProgressVo progress) throws Exception {
validateExcelFilename(filename);
ImportContext context = new ImportContext(fallbackGroupId, operatorId, superAdmin, deleteMode, progress);
ExcelStreamReader.readFirstSheet(tempFile, new ExcelStreamReader.SheetRowHandler() {
@Override
public void onHeader(String sheetName, Integer sheetNo, Map<Integer, String> headerMap) {
context.setHeaderMap(headerMap);
}
@Override
public void onRow(String sheetName, Integer sheetNo, int rowIndex,
Map<Integer, String> headerMap, Map<Integer, String> rowMap) {
context.consumeRow(rowIndex, headerMap, rowMap);
}
});
context.flush();
if (!context.hasHeader()) {
throw new BusinessException("Excel 表头为空");
}
}
private File saveMultipartToTempFile(MultipartFile file, String prefix) throws Exception {
String suffix = ".xlsx";
String name = file.getOriginalFilename();
if (name != null) {
int idx = name.lastIndexOf('.');
if (idx >= 0) {
suffix = name.substring(idx);
}
}
File tempFile = File.createTempFile(prefix, suffix);
file.transferTo(tempFile);
return tempFile;
}
private void deleteQuietly(File file) {
if (file == null) {
return;
}
try {
Files.deleteIfExists(file.toPath());
} catch (Exception ignored) {
}
}
private void validateExcelFilename(String filename) {
String lowerFilename = filename == null ? "" : filename.toLowerCase(Locale.ROOT);
if (!(lowerFilename.endsWith(".xlsx") || lowerFilename.endsWith(".xls"))) {
throw new BusinessException("仅支持 .xlsx 或 .xls 文件");
}
}
private QueryAsinEntity getById(Long id) {
QueryAsinEntity entity = queryAsinMapper.selectById(id);
if (entity == null) {
throw new BusinessException("记录不存在");
}
return entity;
}
private void validateGroupAccess(QueryAsinEntity entity, Long operatorId, boolean superAdmin) {
shopManageGroupService.getAccessibleById(entity.getGroupId(), operatorId, superAdmin);
}
private QueryAsinItemVo toItemVo(QueryAsinEntity entity, String groupName) {
QueryAsinItemVo vo = new QueryAsinItemVo();
vo.setId(entity.getId());
vo.setGroupId(entity.getGroupId());
vo.setGroupName(groupName == null ? "" : groupName);
vo.setShopName(entity.getShopName());
vo.setAsinDe(blankToEmpty(entity.getAsinDe()));
vo.setAsinUk(blankToEmpty(entity.getAsinUk()));
vo.setAsinFr(blankToEmpty(entity.getAsinFr()));
vo.setAsinIt(blankToEmpty(entity.getAsinIt()));
vo.setAsinEs(blankToEmpty(entity.getAsinEs()));
vo.setCreatedAt(entity.getCreatedAt());
vo.setUpdatedAt(entity.getUpdatedAt());
return vo;
}
private Map<Long, String> buildGroupNameMap(List<Long> groupIds) {
if (groupIds == null || groupIds.isEmpty()) {
return Map.of();
}
LinkedHashMap<Long, String> map = new LinkedHashMap<>();
for (Long groupId : groupIds) {
try {
ShopManageGroupEntity group = shopManageGroupService.getById(groupId);
map.put(groupId, group.getGroupName());
} catch (Exception ignored) {
map.put(groupId, "");
}
}
return map;
}
private Set<String> normalizeCountries(List<String> countries) {
LinkedHashSet<String> normalized = new LinkedHashSet<>();
if (countries != null) {
for (String country : countries) {
normalized.add(normalizeCountry(country));
}
}
if (normalized.isEmpty()) {
throw new BusinessException("请至少选择一个国家");
}
return normalized;
}
private Map<String, String> normalizeCountryAsinMap(Set<String> countries, QueryAsinCreateRequest request) {
LinkedHashMap<String, String> normalized = new LinkedHashMap<>();
Map<String, String> requestMappings = request.getAsinMappings();
if (requestMappings != null && !requestMappings.isEmpty()) {
for (String country : countries) {
String asin = requestMappings.get(country);
if (asin == null) {
asin = requestMappings.get(country.toLowerCase(Locale.ROOT));
}
normalized.put(country, normalizeAsin(asin));
}
return normalized;
}
String asin = normalizeAsin(request.getAsin());
for (String country : countries) {
normalized.put(country, asin);
}
return normalized;
}
private String normalizeCountry(String country) {
String normalized = normalizeBlank(country).toUpperCase(Locale.ROOT);
if (!SUPPORTED_COUNTRIES.contains(normalized)) {
throw new BusinessException("国家参数不支持: " + country);
}
return normalized;
}
private String normalizeAsin(String asin) {
String normalized = normalizeRequired(asin, "ASIN 不能为空").toUpperCase(Locale.ROOT);
if (normalized.length() > 64) {
throw new BusinessException("ASIN 长度不能超过 64");
}
return normalized;
}
private String normalizeRequired(String value, String message) {
String normalized = normalizeBlank(value);
if (normalized.isEmpty()) {
throw new BusinessException(message);
}
return normalized;
}
private String normalizeBlank(String value) {
return value == null ? "" : value.trim();
}
private String blankToEmpty(String value) {
return value == null ? "" : value;
}
private void setCountryAsin(QueryAsinEntity entity, String country, String asin) {
String value = normalizeBlank(asin);
switch (country) {
case "DE" -> entity.setAsinDe(value);
case "UK" -> entity.setAsinUk(value);
case "FR" -> entity.setAsinFr(value);
case "IT" -> entity.setAsinIt(value);
case "ES" -> entity.setAsinEs(value);
default -> throw new BusinessException("国家参数不支持: " + country);
}
}
private boolean isEmptyRow(QueryAsinEntity entity) {
return normalizeBlank(entity.getAsinDe()).isEmpty()
&& normalizeBlank(entity.getAsinUk()).isEmpty()
&& normalizeBlank(entity.getAsinFr()).isEmpty()
&& normalizeBlank(entity.getAsinIt()).isEmpty()
&& normalizeBlank(entity.getAsinEs()).isEmpty();
}
private String getCountryAsin(QueryAsinEntity entity, String country) {
return switch (country) {
case "DE" -> normalizeBlank(entity.getAsinDe());
case "UK" -> normalizeBlank(entity.getAsinUk());
case "FR" -> normalizeBlank(entity.getAsinFr());
case "IT" -> normalizeBlank(entity.getAsinIt());
case "ES" -> normalizeBlank(entity.getAsinEs());
default -> "";
};
}
private QueryAsinEntity newImportEntity(Long groupId, String shopName) {
QueryAsinEntity entity = new QueryAsinEntity();
entity.setGroupId(groupId);
entity.setShopName(shopName);
entity.setAsinDe("");
entity.setAsinUk("");
entity.setAsinFr("");
entity.setAsinIt("");
entity.setAsinEs("");
return entity;
}
private String importKey(Long groupId, String shopName) {
return groupId + KEY_SEPARATOR + shopName;
}
private String rowSignature(QueryAsinEntity entity) {
return importKey(entity.getGroupId(), normalizeBlank(entity.getShopName()))
+ KEY_SEPARATOR + normalizeBlank(entity.getAsinDe()).toUpperCase(Locale.ROOT)
+ KEY_SEPARATOR + normalizeBlank(entity.getAsinUk()).toUpperCase(Locale.ROOT)
+ KEY_SEPARATOR + normalizeBlank(entity.getAsinFr()).toUpperCase(Locale.ROOT)
+ KEY_SEPARATOR + normalizeBlank(entity.getAsinIt()).toUpperCase(Locale.ROOT)
+ KEY_SEPARATOR + normalizeBlank(entity.getAsinEs()).toUpperCase(Locale.ROOT);
}
private String normalizeExcelText(String value) {
return value == null ? "" : value.replace(UTF8_BOM, "").trim();
}
private String normalizeHeaderKey(String value) {
return normalizeExcelText(value).toLowerCase(Locale.ROOT)
.replace(" ", "")
.replace("_", "")
.replace("-", "");
}
private final class ImportContext {
private final Long fallbackGroupId;
private final Long operatorId;
private final boolean superAdmin;
private final boolean deleteMode;
private final QueryAsinImportProgressVo progress;
private final int batchSize = Math.max(1, taskPressureProperties.getDbSelectBatchSize());
private final List<QueryAsinEntity> pendingRows = new ArrayList<>();
private final LinkedHashSet<String> acceptedRowSignatures = new LinkedHashSet<>();
private final Map<String, Long> groupIdByNameOrId = new HashMap<>();
private final Map<String, Integer> headerIndexByKey = new HashMap<>();
private boolean groupsLoaded;
private boolean headerLoaded;
private int pendingCellCount;
private int asinCount;
private int insertedCount;
private int deletedCount;
private int skippedCount;
private ImportContext(Long fallbackGroupId, Long operatorId, boolean superAdmin,
boolean deleteMode, QueryAsinImportProgressVo progress) {
this.fallbackGroupId = fallbackGroupId;
this.operatorId = operatorId;
this.superAdmin = superAdmin;
this.deleteMode = deleteMode;
this.progress = progress;
}
private boolean hasHeader() {
return headerLoaded;
}
private void setHeaderMap(Map<Integer, String> headerMap) {
headerIndexByKey.clear();
if (headerMap != null) {
for (Map.Entry<Integer, String> entry : headerMap.entrySet()) {
String key = normalizeHeaderKey(entry.getValue());
if (!key.isEmpty()) {
headerIndexByKey.putIfAbsent(key, entry.getKey());
}
}
}
headerLoaded = true;
if (findGroupIndex() == null && (fallbackGroupId == null || fallbackGroupId <= 0)) {
throw new BusinessException("Excel 缺少分组列,请在页面选择分组或提供分组列");
}
if (findShopIndex() == null) {
throw new BusinessException("Excel 缺少店铺名列");
}
if (SUPPORTED_COUNTRIES.stream().noneMatch(country -> findCountryIndex(country) != null)) {
throw new BusinessException("Excel 至少需要包含一个国家 ASIN 列");
}
}
private void consumeRow(int rowIndex, Map<Integer, String> headerMap, Map<Integer, String> rowMap) {
if (!headerLoaded) {
setHeaderMap(headerMap);
}
progress.setTotalRows(Math.max(progress.getTotalRows() == null ? 0 : progress.getTotalRows(), rowIndex));
progress.setProcessedRows(rowIndex);
Long groupId = resolveGroupId(rowMap);
String shopName = cell(rowMap, findShopIndex());
if (groupId == null || shopName.isEmpty()) {
skippedCount++;
updateProgress();
return;
}
QueryAsinEntity row = newImportEntity(groupId, shopName);
int rowAsinCount = 0;
int rowAcceptedCount = 0;
for (String country : SUPPORTED_COUNTRIES) {
Integer index = findCountryIndex(country);
if (index == null) {
continue;
}
String asin = normalizeBlank(cell(rowMap, index)).toUpperCase(Locale.ROOT);
if (asin.isEmpty()) {
continue;
}
if (asin.length() > 64) {
skippedCount++;
continue;
}
asinCount++;
rowAsinCount++;
setCountryAsin(row, country, asin);
rowAcceptedCount++;
}
if (rowAsinCount == 0) {
skippedCount++;
}
if (rowAcceptedCount > 0) {
String signature = rowSignature(row);
if (acceptedRowSignatures.add(signature)) {
pendingRows.add(row);
pendingCellCount += rowAcceptedCount;
} else {
skippedCount += rowAcceptedCount;
}
}
if (pendingRows.size() >= batchSize) {
flush();
}
updateProgress();
}
private void flush() {
if (pendingRows.isEmpty()) {
return;
}
if (deleteMode) {
flushDelete();
} else {
flushInsert();
}
pendingRows.clear();
pendingCellCount = 0;
updateProgress();
}
private void flushInsert() {
List<QueryAsinEntity> targets = new ArrayList<>(pendingRows);
LinkedHashSet<Long> groupIds = new LinkedHashSet<>();
LinkedHashSet<String> shopNames = new LinkedHashSet<>();
for (QueryAsinEntity target : targets) {
groupIds.add(target.getGroupId());
shopNames.add(target.getShopName());
}
Map<String, List<QueryAsinEntity>> existingByKey = new LinkedHashMap<>();
LinkedHashSet<String> existingSignatures = new LinkedHashSet<>();
if (!groupIds.isEmpty() && !shopNames.isEmpty()) {
for (QueryAsinEntity existing : queryAsinMapper.selectByGroupIdsAndShopNames(groupIds, shopNames)) {
existingByKey.computeIfAbsent(importKey(existing.getGroupId(), existing.getShopName()),
ignored -> new ArrayList<>()).add(existing);
existingSignatures.add(rowSignature(existing));
}
}
List<QueryAsinEntity> insertRows = new ArrayList<>();
for (QueryAsinEntity target : targets) {
int cellCount = countCountryCells(target);
String signature = rowSignature(target);
if (existingSignatures.contains(signature)) {
skippedCount += cellCount;
continue;
}
QueryAsinEntity compatible = findCompatibleInsertCandidate(
target,
existingByKey.get(importKey(target.getGroupId(), target.getShopName())));
if (compatible != null) {
int addedCells = mergeMissingCountryAsins(compatible, target);
if (addedCells > 0) {
queryAsinMapper.updateById(compatible);
insertedCount += addedCells;
}
skippedCount += cellCount - addedCells;
existingSignatures.add(rowSignature(compatible));
continue;
}
insertRows.add(target);
insertedCount += cellCount;
existingSignatures.add(signature);
existingByKey.computeIfAbsent(importKey(target.getGroupId(), target.getShopName()),
ignored -> new ArrayList<>()).add(target);
}
if (!insertRows.isEmpty()) {
queryAsinMapper.insertBatch(insertRows);
}
}
private QueryAsinEntity findCompatibleInsertCandidate(QueryAsinEntity target, List<QueryAsinEntity> candidates) {
if (candidates == null || candidates.isEmpty()) {
return null;
}
QueryAsinEntity best = null;
int bestOverlap = -1;
for (QueryAsinEntity candidate : candidates) {
int overlap = 0;
boolean conflict = false;
for (String country : SUPPORTED_COUNTRIES) {
String targetAsin = getCountryAsin(target, country);
String candidateAsin = getCountryAsin(candidate, country);
if (targetAsin.isEmpty() || candidateAsin.isEmpty()) {
continue;
}
if (!targetAsin.equals(candidateAsin)) {
conflict = true;
break;
}
overlap++;
}
if (!conflict && overlap > bestOverlap) {
best = candidate;
bestOverlap = overlap;
}
}
return bestOverlap > 0 ? best : null;
}
private int mergeMissingCountryAsins(QueryAsinEntity existing, QueryAsinEntity target) {
int addedCells = 0;
for (String country : SUPPORTED_COUNTRIES) {
String targetAsin = getCountryAsin(target, country);
if (targetAsin.isEmpty() || !getCountryAsin(existing, country).isEmpty()) {
continue;
}
setCountryAsin(existing, country, targetAsin);
addedCells++;
}
return addedCells;
}
private void flushDelete() {
List<QueryAsinEntity> targets = new ArrayList<>(pendingRows);
LinkedHashSet<Long> groupIds = new LinkedHashSet<>();
LinkedHashSet<String> shopNames = new LinkedHashSet<>();
for (QueryAsinEntity target : targets) {
groupIds.add(target.getGroupId());
shopNames.add(target.getShopName());
}
Map<String, List<QueryAsinEntity>> existingByKey = new LinkedHashMap<>();
if (!groupIds.isEmpty() && !shopNames.isEmpty()) {
for (QueryAsinEntity existing : queryAsinMapper.selectByGroupIdsAndShopNames(groupIds, shopNames)) {
existingByKey.computeIfAbsent(importKey(existing.getGroupId(), existing.getShopName()),
ignored -> new ArrayList<>()).add(existing);
}
}
for (QueryAsinEntity target : targets) {
List<QueryAsinEntity> candidates = existingByKey.get(importKey(target.getGroupId(), target.getShopName()));
if (candidates == null || candidates.isEmpty()) {
skippedCount += countCountryCells(target);
continue;
}
QueryAsinEntity existing = findBestDeleteCandidate(target, candidates);
if (existing == null) {
skippedCount += countCountryCells(target);
continue;
}
boolean changed = false;
for (String country : SUPPORTED_COUNTRIES) {
String asin = getCountryAsin(target, country);
if (asin.isEmpty()) {
continue;
}
if (asin.equals(getCountryAsin(existing, country))) {
setCountryAsin(existing, country, "");
deletedCount++;
changed = true;
} else {
skippedCount++;
}
}
if (!changed) {
continue;
}
if (isEmptyRow(existing)) {
queryAsinMapper.deleteById(existing.getId());
} else {
queryAsinMapper.updateById(existing);
}
}
}
private QueryAsinEntity findBestDeleteCandidate(QueryAsinEntity target, List<QueryAsinEntity> candidates) {
for (QueryAsinEntity candidate : candidates) {
boolean allMatch = true;
for (String country : SUPPORTED_COUNTRIES) {
String asin = getCountryAsin(target, country);
if (!asin.isEmpty() && !asin.equals(getCountryAsin(candidate, country))) {
allMatch = false;
break;
}
}
if (allMatch) {
return candidate;
}
}
return null;
}
private int countCountryCells(QueryAsinEntity entity) {
int count = 0;
for (String country : SUPPORTED_COUNTRIES) {
if (!getCountryAsin(entity, country).isEmpty()) {
count++;
}
}
return count;
}
private Long resolveGroupId(Map<Integer, String> rowMap) {
Integer groupIndex = findGroupIndex();
String groupText = cell(rowMap, groupIndex);
if (groupText.isEmpty()) {
return fallbackGroupId != null && fallbackGroupId > 0 ? fallbackGroupId : null;
}
loadGroups();
Long id = groupIdByNameOrId.get(groupText);
if (id == null) {
id = groupIdByNameOrId.get(normalizeHeaderKey(groupText));
}
return id;
}
private void loadGroups() {
if (groupsLoaded) {
return;
}
List<ShopManageGroupItemVo> groups = shopManageGroupService.listAccessible(operatorId, superAdmin);
for (ShopManageGroupItemVo group : groups) {
if (group.getId() == null || group.getId() <= 0) {
continue;
}
groupIdByNameOrId.put(String.valueOf(group.getId()), group.getId());
groupIdByNameOrId.put(normalizeHeaderKey(group.getGroupName()), group.getId());
}
groupsLoaded = true;
}
private Integer findGroupIndex() {
return firstHeader("分组id", "分组", "groupid", "group");
}
private Integer findShopIndex() {
return firstHeader("店铺名", "店铺名称", "shopname", "shop");
}
private Integer findCountryIndex(String country) {
return switch (country) {
case "DE" -> firstHeader("德国", "", "de", "asinde");
case "UK" -> firstHeader("英国", "", "uk", "asinuk");
case "FR" -> firstHeader("法国", "", "fr", "asinfr");
case "IT" -> firstHeader("意大利", "", "it", "asinit");
case "ES" -> firstHeader("西班牙", "西", "es", "asines");
default -> null;
};
}
private Integer firstHeader(String... names) {
for (String name : names) {
Integer index = headerIndexByKey.get(normalizeHeaderKey(name));
if (index != null) {
return index;
}
}
return null;
}
private String cell(Map<Integer, String> rowMap, Integer index) {
if (rowMap == null || index == null) {
return "";
}
return normalizeExcelText(rowMap.get(index));
}
private void updateProgress() {
progress.setAsinCount(asinCount);
progress.setInsertedCount(insertedCount);
progress.setDeletedCount(deletedCount);
progress.setSkippedCount(skippedCount);
}
}
}

View File

@@ -4,12 +4,14 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.nanri.aiimage.common.exception.BusinessException; import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper; import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity; import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
import com.nanri.aiimage.modules.shopkey.mapper.QueryAsinMapper;
import com.nanri.aiimage.modules.shopkey.mapper.ShopManageGroupMapper; import com.nanri.aiimage.modules.shopkey.mapper.ShopManageGroupMapper;
import com.nanri.aiimage.modules.shopkey.mapper.ShopManageGroupMemberMapper; import com.nanri.aiimage.modules.shopkey.mapper.ShopManageGroupMemberMapper;
import com.nanri.aiimage.modules.shopkey.mapper.ShopManageMapper; import com.nanri.aiimage.modules.shopkey.mapper.ShopManageMapper;
import com.nanri.aiimage.modules.shopkey.mapper.SkipPriceAsinMapper; import com.nanri.aiimage.modules.shopkey.mapper.SkipPriceAsinMapper;
import com.nanri.aiimage.modules.shopkey.model.dto.ShopManageGroupCreateRequest; import com.nanri.aiimage.modules.shopkey.model.dto.ShopManageGroupCreateRequest;
import com.nanri.aiimage.modules.shopkey.model.dto.ShopManageGroupUpdateRequest; import com.nanri.aiimage.modules.shopkey.model.dto.ShopManageGroupUpdateRequest;
import com.nanri.aiimage.modules.shopkey.model.entity.QueryAsinEntity;
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageEntity; 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.ShopManageGroupEntity;
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageGroupMemberEntity; import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageGroupMemberEntity;
@@ -35,6 +37,7 @@ public class ShopManageGroupService {
private final ShopManageGroupMapper groupMapper; private final ShopManageGroupMapper groupMapper;
private final ShopManageGroupMemberMapper groupMemberMapper; private final ShopManageGroupMemberMapper groupMemberMapper;
private final ShopManageMapper shopManageMapper; private final ShopManageMapper shopManageMapper;
private final QueryAsinMapper queryAsinMapper;
private final SkipPriceAsinMapper skipPriceAsinMapper; private final SkipPriceAsinMapper skipPriceAsinMapper;
private final AdminUserMapper adminUserMapper; private final AdminUserMapper adminUserMapper;
@@ -128,6 +131,11 @@ public class ShopManageGroupService {
if (skipPriceAsinCount != null && skipPriceAsinCount > 0) { if (skipPriceAsinCount != null && skipPriceAsinCount > 0) {
throw new BusinessException("该分组下存在跳过跟价 ASIN无法删除"); throw new BusinessException("该分组下存在跳过跟价 ASIN无法删除");
} }
Long queryAsinCount = queryAsinMapper.selectCount(new LambdaQueryWrapper<QueryAsinEntity>()
.eq(QueryAsinEntity::getGroupId, entity.getId()));
if (queryAsinCount != null && queryAsinCount > 0) {
throw new BusinessException("该分组下存在查询 ASIN无法删除");
}
groupMemberMapper.delete(new LambdaQueryWrapper<ShopManageGroupMemberEntity>() groupMemberMapper.delete(new LambdaQueryWrapper<ShopManageGroupMemberEntity>()
.eq(ShopManageGroupMemberEntity::getGroupId, entity.getId())); .eq(ShopManageGroupMemberEntity::getGroupId, entity.getId()));
groupMapper.deleteById(entity.getId()); groupMapper.deleteById(entity.getId());

View File

@@ -7,6 +7,7 @@ import com.nanri.aiimage.modules.task.service.TaskScopePayloadStorageService;
import com.nanri.aiimage.config.TaskPressureProperties; import com.nanri.aiimage.config.TaskPressureProperties;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.io.IOException; import java.io.IOException;
@@ -14,6 +15,7 @@ import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.StandardOpenOption; import java.nio.file.StandardOpenOption;
import java.time.Duration; import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.Map; import java.util.Map;
@@ -26,11 +28,37 @@ public class ShopMatchTaskCacheService {
private static final String MODULE_TYPE = "SHOP_MATCH"; private static final String MODULE_TYPE = "SHOP_MATCH";
private static final long PAYLOAD_TTL_HOURS = 24; private static final long PAYLOAD_TTL_HOURS = 24;
private final StringRedisTemplate stringRedisTemplate;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final TaskPressureProperties taskPressureProperties; private final TaskPressureProperties taskPressureProperties;
private final TaskScopePayloadStorageService taskScopePayloadStorageService; private final TaskScopePayloadStorageService taskScopePayloadStorageService;
private final ConcurrentHashMap<Long, LocalTaskEntityCacheEntry> taskEntityLocalCache = new ConcurrentHashMap<>(); private final ConcurrentHashMap<Long, LocalTaskEntityCacheEntry> taskEntityLocalCache = new ConcurrentHashMap<>();
public void touchTaskHeartbeat(Long taskId) {
if (taskId == null || taskId <= 0) {
return;
}
stringRedisTemplate.opsForValue().set(
buildTaskHeartbeatKey(taskId),
String.valueOf(Instant.now().toEpochMilli()),
Duration.ofHours(PAYLOAD_TTL_HOURS));
}
public long getTaskHeartbeatMillis(Long taskId) {
if (taskId == null || taskId <= 0) {
return 0L;
}
String raw = stringRedisTemplate.opsForValue().get(buildTaskHeartbeatKey(taskId));
if (raw == null || raw.isBlank()) {
return 0L;
}
try {
return Long.parseLong(raw);
} catch (NumberFormatException ignored) {
return 0L;
}
}
public ShopMatchShopPayloadDto getShopMergedPayload(Long taskId, String shopKey) { public ShopMatchShopPayloadDto getShopMergedPayload(Long taskId, String shopKey) {
return taskScopePayloadStorageService.getScopePayload(taskId, MODULE_TYPE, shopKey, ShopMatchShopPayloadDto.class); return taskScopePayloadStorageService.getScopePayload(taskId, MODULE_TYPE, shopKey, ShopMatchShopPayloadDto.class);
} }
@@ -40,6 +68,7 @@ public class ShopMatchTaskCacheService {
return; return;
} }
taskScopePayloadStorageService.saveScopePayload(taskId, MODULE_TYPE, shopKey, payload); taskScopePayloadStorageService.saveScopePayload(taskId, MODULE_TYPE, shopKey, payload);
touchTaskHeartbeat(taskId);
} }
public void removeShopMergedPayload(Long taskId, String shopKey) { public void removeShopMergedPayload(Long taskId, String shopKey) {
@@ -59,6 +88,7 @@ public class ShopMatchTaskCacheService {
return; return;
} }
taskEntityLocalCache.remove(taskId); taskEntityLocalCache.remove(taskId);
stringRedisTemplate.delete(buildTaskHeartbeatKey(taskId));
taskScopePayloadStorageService.deleteTaskScopePayloads(taskId, MODULE_TYPE); taskScopePayloadStorageService.deleteTaskScopePayloads(taskId, MODULE_TYPE);
try { try {
Path taskDir = buildTaskDir(taskId); Path taskDir = buildTaskDir(taskId);
@@ -156,6 +186,10 @@ public class ShopMatchTaskCacheService {
return buildTaskDir(taskId).resolve("_task-entity.json"); return buildTaskDir(taskId).resolve("_task-entity.json");
} }
private String buildTaskHeartbeatKey(Long taskId) {
return "shop-match:task:heartbeat:" + taskId;
}
private boolean isLocalCacheFresh(LocalTaskEntityCacheEntry cached, long now) { private boolean isLocalCacheFresh(LocalTaskEntityCacheEntry cached, long now) {
return cached != null return cached != null
&& now - cached.cachedAtMillis() <= Math.max(0L, taskPressureProperties.getLocalTaskEntityCacheMillis()); && now - cached.cachedAtMillis() <= Math.max(0L, taskPressureProperties.getLocalTaskEntityCacheMillis());

View File

@@ -364,6 +364,9 @@ public class ShopMatchTaskService {
throw new BusinessException("task 不存在"); throw new BusinessException("task 不存在");
} }
updateTaskAndRefreshCache(task); updateTaskAndRefreshCache(task);
if ("RUNNING".equals(task.getStatus())) {
shopMatchTaskCacheService.touchTaskHeartbeat(task.getId());
}
ProductRiskCreateTaskVo vo = new ProductRiskCreateTaskVo(); ProductRiskCreateTaskVo vo = new ProductRiskCreateTaskVo();
vo.setTaskId(task.getId()); vo.setTaskId(task.getId());
@@ -411,6 +414,7 @@ public class ShopMatchTaskService {
task.setUpdatedAt(now); task.setUpdatedAt(now);
persistTaskRequest(task, state); persistTaskRequest(task, state);
updateTaskAndRefreshCache(task); updateTaskAndRefreshCache(task);
shopMatchTaskCacheService.touchTaskHeartbeat(taskId);
} }
@Transactional @Transactional
@@ -467,6 +471,7 @@ public class ShopMatchTaskService {
throw new BusinessException(40901, "任务已结束,拒绝重复提交"); throw new BusinessException(40901, "任务已结束,拒绝重复提交");
} }
shopMatchTaskCacheService.touchTaskHeartbeat(taskId);
List<FileResultEntity> resultRows = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>() List<FileResultEntity> resultRows = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getTaskId, taskId) .eq(FileResultEntity::getTaskId, taskId)
.eq(FileResultEntity::getModuleType, MODULE_TYPE) .eq(FileResultEntity::getModuleType, MODULE_TYPE)

View File

@@ -34,7 +34,8 @@ public class TaskScopePayloadStorageService {
"SHOP_MATCH", "SHOP_MATCH",
"PRODUCT_RISK_RESOLVE", "PRODUCT_RISK_RESOLVE",
"PRICE_TRACK", "PRICE_TRACK",
"PATROL_DELETE" "PATROL_DELETE",
"QUERY_ASIN"
)); ));
private static final String OSS_POINTER_PREFIX = "oss:"; private static final String OSS_POINTER_PREFIX = "oss:";
private static final String LOCAL_BUFFER_DIR = "task-scope-buffer"; private static final String LOCAL_BUFFER_DIR = "task-scope-buffer";

View File

@@ -0,0 +1,28 @@
CREATE TABLE IF NOT EXISTS biz_query_asin (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
group_id BIGINT NOT NULL COMMENT '分组ID',
shop_name VARCHAR(128) NOT NULL COMMENT '店铺名',
asin_de VARCHAR(64) NOT NULL DEFAULT '' COMMENT '德国 ASIN',
asin_uk VARCHAR(64) NOT NULL DEFAULT '' COMMENT '英国 ASIN',
asin_fr VARCHAR(64) NOT NULL DEFAULT '' COMMENT '法国 ASIN',
asin_it VARCHAR(64) NOT NULL DEFAULT '' COMMENT '意大利 ASIN',
asin_es VARCHAR(64) NOT NULL DEFAULT '' COMMENT '西班牙 ASIN',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
KEY idx_group_shop_name (group_id, shop_name),
KEY idx_shop_name (shop_name),
KEY idx_created_at (created_at)
) COMMENT='查询 ASIN';
INSERT INTO `columns` (`name`, `column_key`, `menu_type`, `route_path`, `sort_order`)
SELECT '查询ASIN', 'admin_query_asin', 'admin', 'query-asin', 65
WHERE NOT EXISTS (
SELECT 1 FROM `columns` WHERE `column_key` = 'admin_query_asin'
);
UPDATE `columns`
SET `name` = '查询ASIN',
`menu_type` = 'admin',
`route_path` = 'query-asin',
`sort_order` = 65
WHERE `column_key` = 'admin_query_asin';

View File

@@ -0,0 +1,32 @@
SET @schema_name = DATABASE();
SET @sql = IF(
EXISTS (
SELECT 1
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = @schema_name
AND TABLE_NAME = 'biz_query_asin'
AND INDEX_NAME = 'uk_group_shop_name'
AND NON_UNIQUE = 0
),
'ALTER TABLE biz_query_asin DROP INDEX uk_group_shop_name',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql = IF(
EXISTS (
SELECT 1
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = @schema_name
AND TABLE_NAME = 'biz_query_asin'
AND INDEX_NAME = 'idx_group_shop_name'
),
'SELECT 1',
'ALTER TABLE biz_query_asin ADD KEY idx_group_shop_name (group_id, shop_name)'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS biz_query_asin_shop_candidate (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
user_id BIGINT NOT NULL COMMENT '用户ID',
shop_name VARCHAR(128) NOT NULL COMMENT '店铺名',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
UNIQUE KEY uk_user_shop_name (user_id, shop_name),
KEY idx_user_id (user_id)
) COMMENT='查询 ASIN 前台备选店铺';

View File

@@ -6,7 +6,7 @@ import os
import re import re
import requests import requests
from flask import Blueprint, request, jsonify, session from flask import Blueprint, request, jsonify, session, current_app
import pymysql import pymysql
from werkzeug.security import generate_password_hash from werkzeug.security import generate_password_hash
@@ -60,6 +60,11 @@ ADMIN_MENU_ACCESS_CONFIG = {
'route_path': 'skip-price-asin', 'route_path': 'skip-price-asin',
'error': '无权访问跳过跟价 ASIN 模块', 'error': '无权访问跳过跟价 ASIN 模块',
}, },
'query-asin': {
'column_key': 'admin_query_asin',
'route_path': 'query-asin',
'error': '无权访问查询 ASIN 模块',
},
} }
ADMIN_MENU_ACCESS_CONFIG.update({ ADMIN_MENU_ACCESS_CONFIG.update({
@@ -98,10 +103,10 @@ def _safe_version_key(version):
return s or 'unknown' return s or 'unknown'
def _proxy_backend_java(method, path, *, params=None, json_data=None, files=None, data=None): def _proxy_backend_java(method, path, *, params=None, json_data=None, files=None, data=None, timeout=10):
url = f"{backend_java_base_url}{path}" url = f"{backend_java_base_url}{path}"
try: try:
resp = requests.request(method=method, url=url, params=params, json=json_data, files=files, data=data, timeout=10) resp = requests.request(method=method, url=url, params=params, json=json_data, files=files, data=data, timeout=timeout)
except requests.RequestException: except requests.RequestException:
return None, jsonify({'success': False, 'error': 'backend-java 服务不可用'}), 502 return None, jsonify({'success': False, 'error': 'backend-java 服务不可用'}), 502
try: try:
@@ -456,7 +461,10 @@ def get_admin_current_user_menus():
@login_required @login_required
def admin_logout(): def admin_logout():
session.clear() session.clear()
return jsonify({'success': True, 'msg': '退出成功', 'redirect': '/login'}) response = jsonify({'success': True, 'msg': '退出成功', 'redirect': '/login?logout=1'})
response.delete_cookie(current_app.config.get('SESSION_COOKIE_NAME', 'session'))
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
return response
# ---------- 用户管理 ---------- # ---------- 用户管理 ----------
@@ -469,7 +477,7 @@ def list_users():
if not role: if not role:
return jsonify({'success': False, 'error': '需要登录'}), 403 return jsonify({'success': False, 'error': '需要登录'}), 403
page = max(1, int(request.args.get('page', 1))) page = max(1, int(request.args.get('page', 1)))
_, _, denied = _ensure_backend_menu_access('users', 'history', 'shop-manage', 'skip-price-asin') _, _, denied = _ensure_backend_menu_access('users', 'history', 'shop-manage', 'skip-price-asin', 'query-asin')
if denied: if denied:
return denied return denied
page_size = min(999, max(5, int(request.args.get('page_size', 15)))) page_size = min(999, max(5, int(request.args.get('page_size', 15))))
@@ -1436,7 +1444,7 @@ def delete_dedupe_total_data(item_id):
@admin_api.route('/shop-manages') @admin_api.route('/shop-manages')
@login_required @login_required
def list_shop_manages(): def list_shop_manages():
role, current_row, denied = _ensure_backend_menu_access('shop-manage') role, current_row, denied = _ensure_backend_menu_access('shop-manage', 'skip-price-asin', 'query-asin')
if denied: if denied:
return denied return denied
page = max(1, int(request.args.get('page', 1))) page = max(1, int(request.args.get('page', 1)))
@@ -1600,7 +1608,7 @@ def delete_shop_manage(item_id):
@admin_api.route('/shop-manage-groups') @admin_api.route('/shop-manage-groups')
@login_required @login_required
def list_shop_manage_groups(): def list_shop_manage_groups():
role, current_row, denied = _ensure_backend_menu_access('shop-manage', 'skip-price-asin') role, current_row, denied = _ensure_backend_menu_access('shop-manage', 'skip-price-asin', 'query-asin')
if denied: if denied:
return denied return denied
params = {} params = {}
@@ -1638,7 +1646,7 @@ def list_shop_manage_groups():
@admin_api.route('/shop-manage-group', methods=['POST']) @admin_api.route('/shop-manage-group', methods=['POST'])
@login_required @login_required
def create_shop_manage_group(): def create_shop_manage_group():
role, current_row, denied = _ensure_backend_menu_access('shop-manage', 'skip-price-asin') role, current_row, denied = _ensure_backend_menu_access('shop-manage', 'skip-price-asin', 'query-asin')
if denied: if denied:
return denied return denied
data = request.get_json() or {} data = request.get_json() or {}
@@ -1683,7 +1691,7 @@ def create_shop_manage_group():
@admin_api.route('/shop-manage-group/<int:item_id>', methods=['PUT']) @admin_api.route('/shop-manage-group/<int:item_id>', methods=['PUT'])
@login_required @login_required
def update_shop_manage_group(item_id): def update_shop_manage_group(item_id):
role, current_row, denied = _ensure_backend_menu_access('shop-manage', 'skip-price-asin') role, current_row, denied = _ensure_backend_menu_access('shop-manage', 'skip-price-asin', 'query-asin')
if denied: if denied:
return denied return denied
data = request.get_json() or {} data = request.get_json() or {}
@@ -1876,7 +1884,7 @@ def delete_skip_price_asin_country(item_id, country):
@admin_api.route('/shop-manage-group/<int:item_id>', methods=['DELETE']) @admin_api.route('/shop-manage-group/<int:item_id>', methods=['DELETE'])
@login_required @login_required
def delete_shop_manage_group(item_id): def delete_shop_manage_group(item_id):
role, current_row, denied = _ensure_backend_menu_access('shop-manage', 'skip-price-asin') role, current_row, denied = _ensure_backend_menu_access('shop-manage', 'skip-price-asin', 'query-asin')
if denied: if denied:
return denied return denied
result, error_response, status = _proxy_backend_java( result, error_response, status = _proxy_backend_java(
@@ -1937,3 +1945,269 @@ def update_skip_price_asin_country(item_id, country):
'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16], 'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16],
}, },
}) })
def _format_query_asin_item(item):
return {
'id': item.get('id'),
'group_id': item.get('groupId'),
'group_name': item.get('groupName') or '',
'shop_name': item.get('shopName') or '',
'asin_de': item.get('asinDe') or '',
'asin_uk': item.get('asinUk') or '',
'asin_fr': item.get('asinFr') or '',
'asin_it': item.get('asinIt') or '',
'asin_es': item.get('asinEs') or '',
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16],
}
@admin_api.route('/query-asins')
@login_required
def list_query_asins():
role, current_row, denied = _ensure_backend_menu_access('query-asin')
if denied:
return denied
page = max(1, int(request.args.get('page', 1)))
page_size = min(100, max(1, int(request.args.get('page_size', 15))))
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'
if group_id_raw:
try:
group_id = int(group_id_raw)
if group_id > 0:
params['groupId'] = group_id
except (TypeError, ValueError):
pass
if shop_name:
params['shopName'] = shop_name
if asin:
params['asin'] = asin
result, error_response, status = _proxy_backend_java(
'GET',
'/api/admin/query-asins',
params=params,
)
if error_response is not None:
return error_response, status
payload = result.get('data') or {}
return jsonify({
'success': True,
'items': [_format_query_asin_item(item) 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,
})
def _format_query_asin_import_progress(progress):
return {
'status': progress.get('status') or 'pending',
'total_rows': progress.get('totalRows') or 0,
'processed_rows': progress.get('processedRows') or 0,
'asin_count': progress.get('asinCount') or 0,
'inserted_count': progress.get('insertedCount') or 0,
'deleted_count': progress.get('deletedCount') or 0,
'skipped_count': progress.get('skippedCount') or 0,
'error_message': progress.get('errorMessage') or '',
}
@admin_api.route('/query-asins/import/<import_id>')
@login_required
def query_asin_import_progress(import_id):
role, current_row, denied = _ensure_backend_menu_access('query-asin')
if denied:
return denied
result, error_response, status = _proxy_backend_java(
'GET',
f'/api/admin/query-asins/import/{import_id}',
)
if error_response is not None:
return error_response, status
return jsonify({
'success': True,
'progress': _format_query_asin_import_progress(result.get('data') or {}),
})
@admin_api.route('/query-asins/import', methods=['POST'])
@login_required
def import_query_asins():
role, current_row, denied = _ensure_backend_menu_access('query-asin')
if denied:
return denied
file_storage = request.files.get('file')
if not file_storage or file_storage.filename == '':
return jsonify({'success': False, 'error': '请选择 Excel 文件'})
group_id_raw = (request.form.get('group_id') or '').strip()
params = {
'operatorId': current_row.get('id') if current_row else None,
'superAdmin': 'true' if role == 'super_admin' else 'false',
}
if group_id_raw:
params['groupId'] = group_id_raw
files = {
'file': (file_storage.filename, file_storage.stream, file_storage.mimetype or 'application/octet-stream')
}
result, error_response, status = _proxy_backend_java(
'POST',
'/api/admin/query-asins/import',
params=params,
files=files,
timeout=60,
)
if error_response is not None:
return error_response, status
summary = result.get('data') or {}
return jsonify({
'success': True,
'msg': result.get('message') or '开始导入',
'import_id': summary.get('importId') or '',
})
@admin_api.route('/query-asins/delete-import/<import_id>')
@login_required
def query_asin_delete_import_progress(import_id):
role, current_row, denied = _ensure_backend_menu_access('query-asin')
if denied:
return denied
result, error_response, status = _proxy_backend_java(
'GET',
f'/api/admin/query-asins/delete-import/{import_id}',
)
if error_response is not None:
return error_response, status
return jsonify({
'success': True,
'progress': _format_query_asin_import_progress(result.get('data') or {}),
})
@admin_api.route('/query-asins/delete-import', methods=['POST'])
@login_required
def delete_import_query_asins():
role, current_row, denied = _ensure_backend_menu_access('query-asin')
if denied:
return denied
file_storage = request.files.get('file')
if not file_storage or file_storage.filename == '':
return jsonify({'success': False, 'error': '请选择 Excel 文件'})
group_id_raw = (request.form.get('group_id') or '').strip()
params = {
'operatorId': current_row.get('id') if current_row else None,
'superAdmin': 'true' if role == 'super_admin' else 'false',
}
if group_id_raw:
params['groupId'] = group_id_raw
files = {
'file': (file_storage.filename, file_storage.stream, file_storage.mimetype or 'application/octet-stream')
}
result, error_response, status = _proxy_backend_java(
'POST',
'/api/admin/query-asins/delete-import',
params=params,
files=files,
timeout=60,
)
if error_response is not None:
return error_response, status
summary = result.get('data') or {}
return jsonify({
'success': True,
'msg': result.get('message') or '开始删除',
'import_id': summary.get('importId') or '',
})
@admin_api.route('/query-asin', methods=['POST'])
@login_required
def create_query_asin():
role, current_row, denied = _ensure_backend_menu_access('query-asin')
if denied:
return denied
data = request.get_json() or {}
asin_mappings = data.get('asin_mappings') or {}
fallback_asin = (data.get('asin') or '').strip()
if not fallback_asin and isinstance(asin_mappings, dict):
for value in asin_mappings.values():
fallback_asin = (value or '').strip()
if fallback_asin:
break
payload = {
'groupId': data.get('group_id'),
'shopName': (data.get('shop_name') or '').strip(),
'countries': data.get('countries') or [],
'asin': fallback_asin,
'asinMappings': asin_mappings,
}
result, error_response, status = _proxy_backend_java(
'POST',
'/api/admin/query-asins',
params={
'operatorId': current_row.get('id') if current_row else None,
'superAdmin': 'true' if role == 'super_admin' else 'false',
},
json_data=payload,
)
if error_response is not None:
return error_response, status
return jsonify({
'success': True,
'msg': result.get('message') or '保存成功',
'item': _format_query_asin_item(result.get('data') or {}),
})
@admin_api.route('/query-asin/<int:item_id>/country/<country>', methods=['DELETE'])
@login_required
def delete_query_asin_country(item_id, country):
role, current_row, denied = _ensure_backend_menu_access('query-asin')
if denied:
return denied
result, error_response, status = _proxy_backend_java(
'DELETE',
f'/api/admin/query-asins/{item_id}/countries/{country}',
params={
'operatorId': current_row.get('id') if current_row else None,
'superAdmin': 'true' if role == 'super_admin' else 'false',
},
)
if error_response is not None:
return error_response, status
return jsonify({'success': True, 'msg': result.get('message') or '删除成功'})
@admin_api.route('/query-asin/<int:item_id>/country/<country>', methods=['PUT'])
@login_required
def update_query_asin_country(item_id, country):
role, current_row, denied = _ensure_backend_menu_access('query-asin')
if denied:
return denied
data = request.get_json() or {}
payload = {
'asin': (data.get('asin') or '').strip(),
}
result, error_response, status = _proxy_backend_java(
'PUT',
f'/api/admin/query-asins/{item_id}/countries/{country}',
params={
'operatorId': current_row.get('id') if current_row else None,
'superAdmin': 'true' if role == 'super_admin' else 'false',
},
json_data=payload,
)
if error_response is not None:
return error_response, status
return jsonify({
'success': True,
'msg': result.get('message') or '保存成功',
'item': _format_query_asin_item(result.get('data') or {}),
})

View File

@@ -1,7 +1,7 @@
""" """
认证蓝图:登录、登出、登录状态校验 认证蓝图:登录、登出、登录状态校验
""" """
from flask import Blueprint, request, redirect, url_for, session, jsonify from flask import Blueprint, request, redirect, url_for, session, jsonify, make_response, current_app
from werkzeug.security import check_password_hash from werkzeug.security import check_password_hash
from utils.db import get_db from utils.db import get_db
@@ -13,14 +13,23 @@ auth = Blueprint('auth', __name__, url_prefix='')
@auth.route('/login', methods=['GET', 'POST']) @auth.route('/login', methods=['GET', 'POST'])
def login(): def login():
if session.get('user_id') and is_session_user_valid(): force_relogin = request.args.get('logout') == '1' or request.args.get('switch') == '1'
if request.method == 'GET' and force_relogin:
session.clear()
response = make_response(render_html('login.html'))
response.delete_cookie(current_app.config.get('SESSION_COOKIE_NAME', 'session'))
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
return response
if request.method == 'GET' and session.get('user_id') and is_session_user_valid():
return redirect(url_for('main.admin_page')) return redirect(url_for('main.admin_page'))
if request.method == 'POST': if request.method == 'POST':
session.clear()
wants_json = request.is_json or request.headers.get('X-Requested-With') == 'XMLHttpRequest'
data = request.get_json() if request.is_json else request.form data = request.get_json() if request.is_json else request.form
username = (data.get('username') or '').strip() username = (data.get('username') or '').strip()
password = data.get('password') or '' password = data.get('password') or ''
if not username or not password: if not username or not password:
if request.is_json: if wants_json:
return jsonify({'success': False, 'error': '请输入用户名和密码'}) return jsonify({'success': False, 'error': '请输入用户名和密码'})
return render_html('login.html', error='请输入用户名和密码') return render_html('login.html', error='请输入用户名和密码')
try: try:
@@ -36,14 +45,14 @@ def login():
session.permanent = True session.permanent = True
session['user_id'] = row['id'] session['user_id'] = row['id']
session['username'] = username session['username'] = username
if request.is_json: if wants_json:
return jsonify({'success': True, 'redirect': url_for('main.admin_page')}) return jsonify({'success': True, 'redirect': url_for('main.admin_page')})
return redirect(url_for('main.admin_page')) return redirect(url_for('main.admin_page'))
except Exception as exc: except Exception as exc:
if request.is_json: if wants_json:
return jsonify({'success': False, 'error': str(exc)}) return jsonify({'success': False, 'error': str(exc)})
return render_html('login.html', error='登录失败,请稍后重试') return render_html('login.html', error='登录失败,请稍后重试')
if request.is_json: if wants_json:
return jsonify({'success': False, 'error': '用户名或密码错误'}) return jsonify({'success': False, 'error': '用户名或密码错误'})
return render_html('login.html', error='用户名或密码错误') return render_html('login.html', error='用户名或密码错误')
return render_html('login.html') return render_html('login.html')
@@ -71,4 +80,7 @@ def api_auth_check():
@auth.route('/logout') @auth.route('/logout')
def logout(): def logout():
session.clear() session.clear()
return redirect(url_for('auth.login')) response = redirect(url_for('auth.login', logout='1'))
response.delete_cookie(current_app.config.get('SESSION_COOKIE_NAME', 'session'))
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
return response

View File

@@ -23,8 +23,8 @@ bucket_path = "nanri-image/"
file_url_pre = f"https://{bucket}.oss-cn-hangzhou.aliyuncs.com/" file_url_pre = f"https://{bucket}.oss-cn-hangzhou.aliyuncs.com/"
import os 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://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://8.136.19.173:18080').rstrip('/')
os.environ['OSS_ACCESS_KEY_ID'] = accessKeyId os.environ['OSS_ACCESS_KEY_ID'] = accessKeyId
os.environ['OSS_ACCESS_KEY_SECRET'] = accessKeySecret os.environ['OSS_ACCESS_KEY_SECRET'] = accessKeySecret
os.environ['SECRET_KEY'] = "ddffc7c1d02121d9554d7b080b2511b6" os.environ['SECRET_KEY'] = "ddffc7c1d02121d9554d7b080b2511b6"

View File

@@ -413,6 +413,7 @@
<div class="tab" data-tab="shop-keys">店铺密钥管理</div> <div class="tab" data-tab="shop-keys">店铺密钥管理</div>
<div class="tab" data-tab="shop-manage">店铺管理</div> <div class="tab" data-tab="shop-manage">店铺管理</div>
<div class="tab" data-tab="skip-price-asin">跳过跟价 ASIN</div> <div class="tab" data-tab="skip-price-asin">跳过跟价 ASIN</div>
<div class="tab" data-tab="query-asin">查询 ASIN</div>
<div class="tab" data-tab="history">查看生成记录</div> <div class="tab" data-tab="history">查看生成记录</div>
<div class="tab" data-tab="version">版本管理</div> <div class="tab" data-tab="version">版本管理</div>
</div> </div>
@@ -817,6 +818,120 @@
</div> </div>
</div> </div>
<div id="panel-query-asin" class="tab-panel">
<div class="form-box">
<h3 style="margin-bottom:16px;font-size:15px;">新增查询 ASIN</h3>
<div class="form-row">
<div class="form-group" style="min-width:220px;">
<label>分组</label>
<div style="display:flex;gap:8px;align-items:center;">
<select id="queryAsinGroupSelect" style="min-width:150px;">
<option value="">请选择分组</option>
</select>
<button class="btn btn-secondary" id="btnManageQueryAsinGroups" type="button">管理分组</button>
</div>
</div>
<div class="form-group" style="min-width:340px;">
<label>店铺名</label>
<div style="display:flex;gap:8px;align-items:center;">
<input type="text" id="queryAsinShopName" placeholder="请输入店铺名称" style="flex:1;min-width:180px;">
<button class="btn btn-secondary" id="btnChooseQueryAsinShop" type="button">选择店铺</button>
</div>
</div>
<div class="form-group" style="min-width:220px;">
<label>国家</label>
<select id="queryAsinCountries" multiple size="5" style="min-height:98px;">
<option value="DE">德国</option>
<option value="UK">英国</option>
<option value="FR">法国</option>
<option value="IT">意大利</option>
<option value="ES">西班牙</option>
</select>
</div>
<div class="form-group" style="min-width:320px;flex:1;">
<label>ASIN</label>
<div id="queryAsinInputs" style="display:flex;flex-direction:column;gap:8px;">
<div style="color:#999;font-size:13px;">请选择国家后输入对应 ASIN</div>
</div>
</div>
<button class="btn" id="btnCreateQueryAsin" style="align-self:flex-end;margin-bottom:40px;">新增 ASIN</button>
</div>
<p class="msg" id="msgQueryAsin"></p>
<div class="form-row" style="align-items:flex-start;gap:16px;margin-top:18px;border-top:1px solid #f0f0f0;padding-top:16px;">
<div style="flex:1;min-width:320px;">
<h3 style="margin-bottom:12px;font-size:14px;">导入添加</h3>
<div class="form-row">
<div class="form-group" style="min-width:320px;">
<label>上传 Excel分组、店铺名、德国/英国/法国/意大利/西班牙)</label>
<input type="file" id="queryAsinImportFile" accept=".xlsx,.xls">
</div>
<button class="btn" id="btnImportQueryAsin" type="button">上传并添加</button>
</div>
<p class="msg" id="msgQueryAsinImport"></p>
<div class="progress-wrap" id="queryAsinImportProgressWrap" style="display:none;">
<div class="progress-bar">
<div class="progress-fill" id="queryAsinImportProgressFill"></div>
</div>
<div class="progress-text" id="queryAsinImportProgressText"></div>
</div>
</div>
<div style="flex:1;min-width:320px;">
<h3 style="margin-bottom:12px;font-size:14px;">导入删除</h3>
<div class="form-row">
<div class="form-group" style="min-width:320px;">
<label>上传 Excel匹配分组 + 店铺名 + 国家 ASIN 后删除)</label>
<input type="file" id="queryAsinDeleteImportFile" accept=".xlsx,.xls">
</div>
<button class="btn btn-danger" id="btnDeleteImportQueryAsin" type="button">上传并删除</button>
</div>
<p class="msg" id="msgQueryAsinDeleteImport"></p>
<div class="progress-wrap" id="queryAsinDeleteImportProgressWrap" style="display:none;">
<div class="progress-bar">
<div class="progress-fill" id="queryAsinDeleteImportProgressFill"></div>
</div>
<div class="progress-text" id="queryAsinDeleteImportProgressText"></div>
</div>
</div>
</div>
</div>
<div class="panel-box">
<h3 style="margin-bottom:16px;font-size:15px;">店铺列表</h3>
<div class="form-row" style="margin-bottom:12px;">
<div class="form-group" style="min-width:180px;">
<label>分组</label>
<select id="queryAsinFilterGroupId">
<option value="">全部分组</option>
</select>
</div>
<div class="form-group" style="min-width:220px;">
<label>店铺名</label>
<input type="text" id="queryAsinFilterShopName" placeholder="请输入店铺名">
</div>
<div class="form-group" style="min-width:220px;">
<label>ASIN</label>
<input type="text" id="queryAsinFilterAsin" placeholder="请输入 ASIN">
</div>
<button class="btn" id="btnSearchQueryAsin">查询</button>
</div>
<table>
<thead>
<tr>
<th>序号</th>
<th>分组</th>
<th>店铺名</th>
<th>德国</th>
<th>英国</th>
<th>法国</th>
<th>意大利</th>
<th>西班牙</th>
</tr>
</thead>
<tbody id="queryAsinListBody"></tbody>
</table>
<div class="pagination" id="queryAsinPagination"></div>
</div>
</div>
<div id="panel-history" class="tab-panel"> <div id="panel-history" class="tab-panel">
<div class="form-box"> <div class="form-box">
<h3 style="margin-bottom:16px;font-size:15px;">筛选条件</h3> <h3 style="margin-bottom:16px;font-size:15px;">筛选条件</h3>
@@ -1063,6 +1178,69 @@
</div> </div>
</div> </div>
<div class="modal-mask" id="chooseQueryAsinShopModal">
<div class="modal" style="max-width:900px;width:90%;max-height:80vh;display:flex;flex-direction:column;">
<h3>选择店铺</h3>
<div class="form-row">
<div class="form-group" style="min-width:180px;">
<label>分组</label>
<select id="chooseQueryAsinShopGroupId">
<option value="">全部分组</option>
</select>
</div>
<div class="form-group" style="min-width:220px;">
<label>店铺名</label>
<input type="text" id="chooseQueryAsinShopKeyword" placeholder="请输入店铺名">
</div>
<button class="btn" id="btnSearchChooseQueryAsinShop" type="button">查询</button>
</div>
<div style="flex:1;min-height:0;overflow:auto;">
<table>
<thead>
<tr>
<th>序号</th>
<th>分组</th>
<th>店铺名</th>
<th>店铺商城名</th>
<th>账号</th>
<th>操作</th>
</tr>
</thead>
<tbody id="chooseQueryAsinShopListBody"></tbody>
</table>
</div>
<div class="pagination" id="chooseQueryAsinShopPagination"></div>
<div style="margin-top:16px;display:flex;gap:8px;justify-content:flex-end;">
<button class="btn btn-secondary" id="btnCloseChooseQueryAsinShopModal" type="button">关闭</button>
</div>
</div>
</div>
<div class="modal-mask" id="editQueryAsinModal">
<div class="modal">
<h3>编辑查询 ASIN</h3>
<input type="hidden" id="editQueryAsinId">
<input type="hidden" id="editQueryAsinCountry">
<div class="form-group">
<label>店铺名</label>
<input type="text" id="editQueryAsinShopName" readonly style="background:#f5f5f5;">
</div>
<div class="form-group">
<label>国家</label>
<input type="text" id="editQueryAsinCountryLabel" readonly style="background:#f5f5f5;">
</div>
<div class="form-group">
<label>ASIN</label>
<input type="text" id="editQueryAsinValue" placeholder="请输入 ASIN">
</div>
<p class="msg" id="msgEditQueryAsin"></p>
<div style="margin-top:16px;display:flex;gap:8px;">
<button class="btn" id="btnSaveQueryAsin">保存</button>
<button class="btn btn-secondary" id="btnCloseEditQueryAsin">取消</button>
</div>
</div>
</div>
<div class="modal-mask" id="editShopManageModal"> <div class="modal-mask" id="editShopManageModal">
<div class="modal"> <div class="modal">
<h3>编辑店铺</h3> <h3>编辑店铺</h3>
@@ -1134,6 +1312,7 @@
'shop-keys': 'panel-shop-keys', 'shop-keys': 'panel-shop-keys',
'shop-manage': 'panel-shop-manage', 'shop-manage': 'panel-shop-manage',
'skip-price-asin': 'panel-skip-price-asin', 'skip-price-asin': 'panel-skip-price-asin',
'query-asin': 'panel-query-asin',
'history': 'panel-history', 'history': 'panel-history',
'version': 'panel-version' 'version': 'panel-version'
}; };
@@ -1144,6 +1323,7 @@
else if (tabName === 'shop-keys') loadShopKeys(1); else if (tabName === 'shop-keys') loadShopKeys(1);
else if (tabName === 'shop-manage') loadShopManage(1); else if (tabName === 'shop-manage') loadShopManage(1);
else if (tabName === 'skip-price-asin') loadSkipPriceAsin(1); else if (tabName === 'skip-price-asin') loadSkipPriceAsin(1);
else if (tabName === 'query-asin') loadQueryAsin(1);
else if (tabName === 'history') loadHistory(1); else if (tabName === 'history') loadHistory(1);
else if (tabName === 'version') loadVersions(); else if (tabName === 'version') loadVersions();
} }
@@ -2304,10 +2484,16 @@
var skipCreateSel = document.getElementById('skipPriceAsinGroupSelect'); var skipCreateSel = document.getElementById('skipPriceAsinGroupSelect');
var skipFilterSel = document.getElementById('skipPriceAsinFilterGroupId'); var skipFilterSel = document.getElementById('skipPriceAsinFilterGroupId');
var chooseSkipShopSel = document.getElementById('chooseSkipPriceAsinShopGroupId'); var chooseSkipShopSel = document.getElementById('chooseSkipPriceAsinShopGroupId');
var queryCreateSel = document.getElementById('queryAsinGroupSelect');
var queryFilterSel = document.getElementById('queryAsinFilterGroupId');
var chooseQueryShopSel = document.getElementById('chooseQueryAsinShopGroupId');
var selectedFilterId = filterSel ? filterSel.value : ''; var selectedFilterId = filterSel ? filterSel.value : '';
var selectedSkipCreateId = skipCreateSel ? skipCreateSel.value : ''; var selectedSkipCreateId = skipCreateSel ? skipCreateSel.value : '';
var selectedSkipFilterId = skipFilterSel ? skipFilterSel.value : ''; var selectedSkipFilterId = skipFilterSel ? skipFilterSel.value : '';
var selectedChooseSkipShopId = chooseSkipShopSel ? chooseSkipShopSel.value : ''; var selectedChooseSkipShopId = chooseSkipShopSel ? chooseSkipShopSel.value : '';
var selectedQueryCreateId = queryCreateSel ? queryCreateSel.value : '';
var selectedQueryFilterId = queryFilterSel ? queryFilterSel.value : '';
var selectedChooseQueryShopId = chooseQueryShopSel ? chooseQueryShopSel.value : '';
var createOpts = ['<option value="">请选择分组</option>']; var createOpts = ['<option value="">请选择分组</option>'];
var filterOpts = ['<option value="">全部分组</option>']; var filterOpts = ['<option value="">全部分组</option>'];
shopManageGroups.forEach(function (g) { shopManageGroups.forEach(function (g) {
@@ -2318,15 +2504,21 @@
createSel.innerHTML = createOpts.join(''); createSel.innerHTML = createOpts.join('');
editSel.innerHTML = createOpts.join(''); editSel.innerHTML = createOpts.join('');
if (skipCreateSel) skipCreateSel.innerHTML = createOpts.join(''); if (skipCreateSel) skipCreateSel.innerHTML = createOpts.join('');
if (queryCreateSel) queryCreateSel.innerHTML = createOpts.join('');
if (filterSel) filterSel.innerHTML = filterOpts.join(''); if (filterSel) filterSel.innerHTML = filterOpts.join('');
if (skipFilterSel) skipFilterSel.innerHTML = filterOpts.join(''); if (skipFilterSel) skipFilterSel.innerHTML = filterOpts.join('');
if (chooseSkipShopSel) chooseSkipShopSel.innerHTML = filterOpts.join(''); if (chooseSkipShopSel) chooseSkipShopSel.innerHTML = filterOpts.join('');
if (queryFilterSel) queryFilterSel.innerHTML = filterOpts.join('');
if (chooseQueryShopSel) chooseQueryShopSel.innerHTML = filterOpts.join('');
if (selectedCreateId != null) createSel.value = String(selectedCreateId); if (selectedCreateId != null) createSel.value = String(selectedCreateId);
if (selectedEditId != null) editSel.value = String(selectedEditId); if (selectedEditId != null) editSel.value = String(selectedEditId);
if (filterSel && selectedFilterId) filterSel.value = selectedFilterId; if (filterSel && selectedFilterId) filterSel.value = selectedFilterId;
if (skipCreateSel && selectedSkipCreateId) skipCreateSel.value = selectedSkipCreateId; if (skipCreateSel && selectedSkipCreateId) skipCreateSel.value = selectedSkipCreateId;
if (skipFilterSel && selectedSkipFilterId) skipFilterSel.value = selectedSkipFilterId; if (skipFilterSel && selectedSkipFilterId) skipFilterSel.value = selectedSkipFilterId;
if (chooseSkipShopSel && selectedChooseSkipShopId) chooseSkipShopSel.value = selectedChooseSkipShopId; if (chooseSkipShopSel && selectedChooseSkipShopId) chooseSkipShopSel.value = selectedChooseSkipShopId;
if (queryCreateSel && selectedQueryCreateId) queryCreateSel.value = selectedQueryCreateId;
if (queryFilterSel && selectedQueryFilterId) queryFilterSel.value = selectedQueryFilterId;
if (chooseQueryShopSel && selectedChooseQueryShopId) chooseQueryShopSel.value = selectedChooseQueryShopId;
} }
function loadShopManageGroups(selectedCreateId, selectedEditId) { function loadShopManageGroups(selectedCreateId, selectedEditId) {
@@ -2361,7 +2553,7 @@
function updateShopManageGroupButtonsAccess() { function updateShopManageGroupButtonsAccess() {
var canManageGroups = !!currentUserId; var canManageGroups = !!currentUserId;
['btnManageShopGroups', 'btnManageShopGroupsFromEdit', 'btnManageSkipPriceAsinGroups'].forEach(function (id) { ['btnManageShopGroups', 'btnManageShopGroupsFromEdit', 'btnManageSkipPriceAsinGroups', 'btnManageQueryAsinGroups'].forEach(function (id) {
var btn = document.getElementById(id); var btn = document.getElementById(id);
if (btn) btn.style.display = canManageGroups ? '' : 'none'; if (btn) btn.style.display = canManageGroups ? '' : 'none';
}); });
@@ -2447,6 +2639,10 @@
setShopManageGroupGrantRoutes(['skip-price-asin']); setShopManageGroupGrantRoutes(['skip-price-asin']);
openShopManageGroupModal(); openShopManageGroupModal();
}; };
document.getElementById('btnManageQueryAsinGroups').onclick = function () {
setShopManageGroupGrantRoutes(['query-asin']);
openShopManageGroupModal();
};
document.getElementById('btnSearchShopManage').onclick = function () { document.getElementById('btnSearchShopManage').onclick = function () {
loadShopManage(1); loadShopManage(1);
}; };
@@ -2990,6 +3186,515 @@
}; };
setupSkipPriceAsinShopPicker(); setupSkipPriceAsinShopPicker();
renderSkipPriceAsinInputs(); renderSkipPriceAsinInputs();
// ========== 查询 ASIN ==========
var queryAsinPage = 1, queryAsinPageSize = 15;
var chooseQueryAsinShopPage = 1, chooseQueryAsinShopPageSize = 10;
var queryAsinCountryColumns = [
{ code: 'DE', field: 'asin_de', label: '德国' },
{ code: 'UK', field: 'asin_uk', label: '英国' },
{ code: 'FR', field: 'asin_fr', label: '法国' },
{ code: 'IT', field: 'asin_it', label: '意大利' },
{ code: 'ES', field: 'asin_es', label: '西班牙' }
];
function getQueryAsinCountryLabel(countryCode) {
var country = queryAsinCountryColumns.find(function (item) { return item.code === countryCode; });
return country ? country.label : countryCode;
}
function getSelectedQueryAsinCountries() {
return Array.from(document.getElementById('queryAsinCountries').selectedOptions).map(function (option) {
return option.value;
});
}
function renderQueryAsinInputs() {
var container = document.getElementById('queryAsinInputs');
var selectedCountries = getSelectedQueryAsinCountries();
var existingValues = {};
container.querySelectorAll('[data-query-asin-country-input]').forEach(function (input) {
existingValues[input.getAttribute('data-query-asin-country-input')] = input.value;
});
if (!selectedCountries.length) {
container.innerHTML = '<div style="color:#999;font-size:13px;">请选择国家后输入 ASIN</div>';
return;
}
container.innerHTML = selectedCountries.map(function (countryCode) {
var label = getQueryAsinCountryLabel(countryCode);
var value = existingValues[countryCode] || '';
return '<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">' +
'<span style="min-width:56px;color:#555;">' + label + '</span>' +
'<input type="text" data-query-asin-country-input="' + countryCode + '" value="' + value.replace(/"/g, '&quot;') + '" placeholder="请输入' + label + ' ASIN" style="flex:1;min-width:180px;">' +
'</div>';
}).join('');
}
function collectQueryAsinMappings(countries) {
var asinMappings = {};
for (var i = 0; i < countries.length; i++) {
var countryCode = countries[i];
var input = document.querySelector('[data-query-asin-country-input="' + countryCode + '"]');
var asin = input ? (input.value || '').trim().toUpperCase() : '';
if (!asin) {
throw new Error(getQueryAsinCountryLabel(countryCode) + ' ASIN 不能为空');
}
asinMappings[countryCode] = asin;
}
return asinMappings;
}
function buildChooseQueryAsinShopQuery(page) {
var query = 'page=' + (page || 1) + '&page_size=' + chooseQueryAsinShopPageSize;
var groupId = (document.getElementById('chooseQueryAsinShopGroupId').value || '').trim();
var shopName = (document.getElementById('chooseQueryAsinShopKeyword').value || '').trim();
if (groupId) query += '&group_id=' + encodeURIComponent(groupId);
if (shopName) query += '&shop_name=' + encodeURIComponent(shopName);
return query;
}
function loadChooseQueryAsinShops(page) {
chooseQueryAsinShopPage = page || 1;
fetch('/api/admin/shop-manages?' + buildChooseQueryAsinShopQuery(chooseQueryAsinShopPage))
.then(function (r) { return r.json(); })
.then(function (res) {
var tbody = document.getElementById('chooseQueryAsinShopListBody');
if (!res.success) {
tbody.innerHTML = '<tr><td colspan="6" class="empty-tip">加载失败: ' + (res.error || '') + '</td></tr>';
return;
}
var items = res.items || [];
if (items.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="empty-tip">暂无店铺</td></tr>';
} else {
tbody.innerHTML = items.map(function (item, index) {
var rowNo = (chooseQueryAsinShopPage - 1) * chooseQueryAsinShopPageSize + index + 1;
return '<tr><td>' + rowNo + '</td><td>' + (item.group_name || '') + '</td><td>' + (item.shop_name || '') + '</td><td>' + (item.mall_name || '') + '</td><td>' + (item.username || '') + '</td><td>' +
'<button class="btn btn-sm" type="button" data-choose-query-asin-shop="' + item.id + '" data-shop-name="' + (item.shop_name || '').replace(/"/g, '&quot;') + '" data-group-id="' + (item.group_id || '') + '">选择</button>' +
'</td></tr>';
}).join('');
}
renderPagination('chooseQueryAsinShopPagination', res.total, res.page, res.page_size, loadChooseQueryAsinShops);
bindChooseQueryAsinShopActions();
})
.catch(function () {
document.getElementById('chooseQueryAsinShopListBody').innerHTML = '<tr><td colspan="6" class="empty-tip">请求失败</td></tr>';
});
}
function bindChooseQueryAsinShopActions() {
document.querySelectorAll('[data-choose-query-asin-shop]').forEach(function (btn) {
btn.onclick = function () {
document.getElementById('queryAsinShopName').value = (btn.dataset.shopName || '').replace(/&quot;/g, '"');
if (btn.dataset.groupId) {
document.getElementById('queryAsinGroupSelect').value = btn.dataset.groupId;
}
document.getElementById('chooseQueryAsinShopModal').classList.remove('show');
};
});
}
function openChooseQueryAsinShopModal() {
var currentGroupId = (document.getElementById('queryAsinGroupSelect').value || '').trim();
if (currentGroupId) {
document.getElementById('chooseQueryAsinShopGroupId').value = currentGroupId;
}
document.getElementById('chooseQueryAsinShopKeyword').value = (document.getElementById('queryAsinShopName').value || '').trim();
document.getElementById('chooseQueryAsinShopModal').classList.add('show');
loadChooseQueryAsinShops(1);
}
function buildQueryAsinQuery(page) {
var query = 'page=' + (page || 1) + '&page_size=' + queryAsinPageSize;
var groupId = (document.getElementById('queryAsinFilterGroupId').value || '').trim();
var shopName = (document.getElementById('queryAsinFilterShopName').value || '').trim();
var asin = (document.getElementById('queryAsinFilterAsin').value || '').trim();
if (groupId) query += '&group_id=' + encodeURIComponent(groupId);
if (shopName) query += '&shop_name=' + encodeURIComponent(shopName);
if (asin) query += '&asin=' + encodeURIComponent(asin);
return query;
}
function renderQueryAsinCell(item, country) {
var asinValue = item[country.field] || '';
var infoHtml = asinValue ? '<span>' + asinValue + '</span>' : '<span style="color:#999;">-</span>';
var deleteHtml = asinValue
? ('<button class="btn btn-sm btn-danger" data-query-asin-delete="' + item.id + '" data-country="' + country.code + '" data-shop-name="' + (item.shop_name || '').replace(/"/g, '&quot;') + '">删除</button>')
: '';
return '<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">' +
infoHtml +
'<button class="btn btn-sm" data-query-asin-edit="' + item.id + '" data-country="' + country.code + '" data-asin="' + asinValue.replace(/"/g, '&quot;') + '" data-shop-name="' + (item.shop_name || '').replace(/"/g, '&quot;') + '">编辑</button>' +
deleteHtml +
'</div>';
}
function openEditQueryAsinModal(itemId, countryCode, shopName, asinValue) {
document.getElementById('editQueryAsinId').value = itemId || '';
document.getElementById('editQueryAsinCountry').value = countryCode || '';
document.getElementById('editQueryAsinShopName').value = shopName || '';
document.getElementById('editQueryAsinCountryLabel').value = getQueryAsinCountryLabel(countryCode || '');
document.getElementById('editQueryAsinValue').value = asinValue || '';
document.getElementById('msgEditQueryAsin').textContent = '';
document.getElementById('msgEditQueryAsin').className = 'msg';
document.getElementById('editQueryAsinModal').classList.add('show');
}
function bindQueryAsinActions() {
document.querySelectorAll('[data-query-asin-edit]').forEach(function (btn) {
btn.onclick = function () {
openEditQueryAsinModal(
btn.dataset.queryAsinEdit,
btn.dataset.country || '',
(btn.dataset.shopName || '').replace(/&quot;/g, '"'),
(btn.dataset.asin || '').replace(/&quot;/g, '"')
);
};
});
document.querySelectorAll('[data-query-asin-delete]').forEach(function (btn) {
btn.onclick = function () {
var shopName = (btn.dataset.shopName || '').replace(/&quot;/g, '"');
var countryCode = btn.dataset.country || '';
if (!confirm('确定删除店铺“' + shopName + '”在 ' + countryCode + ' 的 ASIN 吗?')) return;
fetch('/api/admin/query-asin/' + btn.dataset.queryAsinDelete + '/country/' + countryCode, {
method: 'DELETE'
})
.then(function (r) { return r.json(); })
.then(function (res) {
if (res.success) loadQueryAsin(queryAsinPage);
else alert(res.error || '删除失败');
});
};
});
}
function loadQueryAsin(page) {
queryAsinPage = page || 1;
fetch('/api/admin/query-asins?' + buildQueryAsinQuery(queryAsinPage))
.then(function (r) { return r.json(); })
.then(function (res) {
var tbody = document.getElementById('queryAsinListBody');
if (!res.success) {
tbody.innerHTML = '<tr><td colspan="8" class="empty-tip">加载失败: ' + (res.error || '') + '</td></tr>';
return;
}
var items = res.items || [];
if (items.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" class="empty-tip">暂无数据</td></tr>';
} else {
tbody.innerHTML = items.map(function (item, index) {
var rowNo = (queryAsinPage - 1) * queryAsinPageSize + index + 1;
return '<tr><td>' + rowNo + '</td><td>' + (item.group_name || '') + '</td><td>' + (item.shop_name || '') + '</td>' +
queryAsinCountryColumns.map(function (country) {
return '<td>' + renderQueryAsinCell(item, country) + '</td>';
}).join('') +
'</tr>';
}).join('');
}
renderPagination('queryAsinPagination', res.total, res.page, res.page_size, loadQueryAsin);
bindQueryAsinActions();
})
.catch(function () {
document.getElementById('queryAsinListBody').innerHTML = '<tr><td colspan="8" class="empty-tip">请求失败</td></tr>';
});
}
var queryAsinImportPollTimer = null;
var queryAsinDeleteImportPollTimer = null;
function stopQueryAsinImportProgress() {
if (queryAsinImportPollTimer) {
clearInterval(queryAsinImportPollTimer);
queryAsinImportPollTimer = null;
}
}
function stopQueryAsinDeleteImportProgress() {
if (queryAsinDeleteImportPollTimer) {
clearInterval(queryAsinDeleteImportPollTimer);
queryAsinDeleteImportPollTimer = null;
}
}
function setQueryAsinImportProgress(percent, text) {
var wrap = document.getElementById('queryAsinImportProgressWrap');
var fill = document.getElementById('queryAsinImportProgressFill');
var textEl = document.getElementById('queryAsinImportProgressText');
wrap.style.display = 'block';
fill.style.width = Math.max(0, Math.min(100, percent || 0)) + '%';
textEl.textContent = text || '';
}
function setQueryAsinDeleteImportProgress(percent, text) {
var wrap = document.getElementById('queryAsinDeleteImportProgressWrap');
var fill = document.getElementById('queryAsinDeleteImportProgressFill');
var textEl = document.getElementById('queryAsinDeleteImportProgressText');
wrap.style.display = 'block';
fill.style.width = Math.max(0, Math.min(100, percent || 0)) + '%';
textEl.textContent = text || '';
}
function pollQueryAsinImport(importId) {
stopQueryAsinImportProgress();
function tick() {
fetch('/api/admin/query-asins/import/' + encodeURIComponent(importId))
.then(function (r) { return r.json(); })
.then(function (res) {
if (!res.success) {
stopQueryAsinImportProgress();
document.getElementById('msgQueryAsinImport').textContent = res.error || '查询导入进度失败';
document.getElementById('msgQueryAsinImport').className = 'msg err';
return;
}
var progress = res.progress || {};
var totalRows = progress.total_rows || 0;
var processedRows = progress.processed_rows || 0;
var percent = totalRows > 0 ? Math.round(processedRows * 100 / totalRows) : 0;
setQueryAsinImportProgress(percent, '处理中:已处理 ' + processedRows + ' / ' + totalRows + ',添加 ' + (progress.inserted_count || 0) + ',跳过 ' + (progress.skipped_count || 0));
if (progress.status === 'success') {
stopQueryAsinImportProgress();
document.getElementById('msgQueryAsinImport').textContent = '导入成功:总行数 ' + totalRows + 'ASIN 数量 ' + (progress.asin_count || 0) + ',添加 ' + (progress.inserted_count || 0) + ',跳过 ' + (progress.skipped_count || 0);
document.getElementById('msgQueryAsinImport').className = 'msg ok';
loadQueryAsin(1);
} else if (progress.status === 'failed') {
stopQueryAsinImportProgress();
document.getElementById('msgQueryAsinImport').textContent = progress.error_message || '导入失败';
document.getElementById('msgQueryAsinImport').className = 'msg err';
}
})
.catch(function () {
stopQueryAsinImportProgress();
document.getElementById('msgQueryAsinImport').textContent = '查询导入进度失败';
document.getElementById('msgQueryAsinImport').className = 'msg err';
});
}
tick();
queryAsinImportPollTimer = setInterval(tick, 1000);
}
function pollQueryAsinDeleteImport(importId) {
stopQueryAsinDeleteImportProgress();
function tick() {
fetch('/api/admin/query-asins/delete-import/' + encodeURIComponent(importId))
.then(function (r) { return r.json(); })
.then(function (res) {
if (!res.success) {
stopQueryAsinDeleteImportProgress();
document.getElementById('msgQueryAsinDeleteImport').textContent = res.error || '查询删除进度失败';
document.getElementById('msgQueryAsinDeleteImport').className = 'msg err';
return;
}
var progress = res.progress || {};
var totalRows = progress.total_rows || 0;
var processedRows = progress.processed_rows || 0;
var percent = totalRows > 0 ? Math.round(processedRows * 100 / totalRows) : 0;
setQueryAsinDeleteImportProgress(percent, '处理中:已处理 ' + processedRows + ' / ' + totalRows + ',删除 ' + (progress.deleted_count || 0) + ',跳过 ' + (progress.skipped_count || 0));
if (progress.status === 'success') {
stopQueryAsinDeleteImportProgress();
document.getElementById('msgQueryAsinDeleteImport').textContent = '删除成功:总行数 ' + totalRows + 'ASIN 数量 ' + (progress.asin_count || 0) + ',删除 ' + (progress.deleted_count || 0) + ',跳过 ' + (progress.skipped_count || 0);
document.getElementById('msgQueryAsinDeleteImport').className = 'msg ok';
loadQueryAsin(1);
} else if (progress.status === 'failed') {
stopQueryAsinDeleteImportProgress();
document.getElementById('msgQueryAsinDeleteImport').textContent = progress.error_message || '删除失败';
document.getElementById('msgQueryAsinDeleteImport').className = 'msg err';
}
})
.catch(function () {
stopQueryAsinDeleteImportProgress();
document.getElementById('msgQueryAsinDeleteImport').textContent = '查询删除进度失败';
document.getElementById('msgQueryAsinDeleteImport').className = 'msg err';
});
}
tick();
queryAsinDeleteImportPollTimer = setInterval(tick, 1000);
}
function uploadQueryAsinImport(deleteMode) {
var fileInput = document.getElementById(deleteMode ? 'queryAsinDeleteImportFile' : 'queryAsinImportFile');
var msgEl = document.getElementById(deleteMode ? 'msgQueryAsinDeleteImport' : 'msgQueryAsinImport');
msgEl.textContent = '';
msgEl.className = 'msg';
if (deleteMode) {
stopQueryAsinDeleteImportProgress();
document.getElementById('queryAsinDeleteImportProgressWrap').style.display = 'none';
} else {
stopQueryAsinImportProgress();
document.getElementById('queryAsinImportProgressWrap').style.display = 'none';
}
if (!fileInput.files || fileInput.files.length === 0) {
msgEl.textContent = '请选择 Excel 文件';
msgEl.className = 'msg err';
return;
}
var file = fileInput.files[0];
var lowerName = (file.name || '').toLowerCase();
if (!(lowerName.endsWith('.xlsx') || lowerName.endsWith('.xls'))) {
msgEl.textContent = '仅支持 .xlsx 或 .xls 文件';
msgEl.className = 'msg err';
return;
}
if (deleteMode && !confirm('确定按 Excel 中的分组、店铺名和国家 ASIN 批量删除吗?')) {
return;
}
var formData = new FormData();
formData.append('file', file);
var groupId = (document.getElementById('queryAsinGroupSelect').value || '').trim();
if (groupId) formData.append('group_id', groupId);
var xhr = new XMLHttpRequest();
xhr.open('POST', deleteMode ? '/api/admin/query-asins/delete-import' : '/api/admin/query-asins/import', true);
xhr.upload.onprogress = function (event) {
if (event.lengthComputable) {
var percent = Math.round(event.loaded * 100 / event.total);
if (deleteMode) setQueryAsinDeleteImportProgress(percent, '上传中:' + percent + '%');
else setQueryAsinImportProgress(percent, '上传中:' + percent + '%');
}
};
xhr.onreadystatechange = function () {
if (xhr.readyState !== 4) return;
if (xhr.status < 200 || xhr.status >= 300) {
msgEl.textContent = '请求失败';
msgEl.className = 'msg err';
return;
}
var res;
try { res = JSON.parse(xhr.responseText || '{}'); } catch (e) { res = { success: false, error: '返回格式错误' }; }
if (!res.success) {
msgEl.textContent = res.error || (deleteMode ? '删除失败' : '导入失败');
msgEl.className = 'msg err';
return;
}
if (deleteMode) {
setQueryAsinDeleteImportProgress(100, '上传完成,后端处理中...');
pollQueryAsinDeleteImport(res.import_id);
} else {
setQueryAsinImportProgress(100, '上传完成,后端处理中...');
pollQueryAsinImport(res.import_id);
}
fileInput.value = '';
};
xhr.onerror = function () {
msgEl.textContent = '请求失败';
msgEl.className = 'msg err';
};
xhr.send(formData);
}
document.getElementById('btnChooseQueryAsinShop').onclick = openChooseQueryAsinShopModal;
document.getElementById('btnSearchChooseQueryAsinShop').onclick = function () {
loadChooseQueryAsinShops(1);
};
document.getElementById('chooseQueryAsinShopGroupId').onchange = function () {
loadChooseQueryAsinShops(1);
};
document.getElementById('chooseQueryAsinShopKeyword').addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
loadChooseQueryAsinShops(1);
}
});
document.getElementById('btnCloseChooseQueryAsinShopModal').onclick = function () {
document.getElementById('chooseQueryAsinShopModal').classList.remove('show');
};
document.getElementById('btnCloseEditQueryAsin').onclick = function () {
document.getElementById('editQueryAsinModal').classList.remove('show');
};
document.getElementById('btnSaveQueryAsin').onclick = function () {
var itemId = (document.getElementById('editQueryAsinId').value || '').trim();
var countryCode = (document.getElementById('editQueryAsinCountry').value || '').trim();
var asin = (document.getElementById('editQueryAsinValue').value || '').trim().toUpperCase();
var msgEl = document.getElementById('msgEditQueryAsin');
msgEl.textContent = '';
msgEl.className = 'msg';
if (!itemId || !countryCode) {
msgEl.textContent = '缺少编辑记录';
msgEl.className = 'msg err';
return;
}
if (!asin) {
msgEl.textContent = 'ASIN 不能为空';
msgEl.className = 'msg err';
return;
}
fetch('/api/admin/query-asin/' + itemId + '/country/' + countryCode, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ asin: asin })
})
.then(function (r) { return r.json(); })
.then(function (res) {
if (!res.success) {
msgEl.textContent = res.error || '保存失败';
msgEl.className = 'msg err';
return;
}
document.getElementById('editQueryAsinModal').classList.remove('show');
loadQueryAsin(queryAsinPage);
})
.catch(function () {
msgEl.textContent = '请求失败';
msgEl.className = 'msg err';
});
};
document.getElementById('queryAsinCountries').addEventListener('change', renderQueryAsinInputs);
document.getElementById('btnSearchQueryAsin').onclick = function () {
loadQueryAsin(1);
};
document.getElementById('queryAsinFilterShopName').addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
loadQueryAsin(1);
}
});
document.getElementById('queryAsinFilterAsin').addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
loadQueryAsin(1);
}
});
document.getElementById('btnCreateQueryAsin').onclick = function () {
var groupId = (document.getElementById('queryAsinGroupSelect').value || '').trim();
var shopName = (document.getElementById('queryAsinShopName').value || '').trim();
var countries = getSelectedQueryAsinCountries();
var msgEl = document.getElementById('msgQueryAsin');
msgEl.textContent = '';
msgEl.className = 'msg';
if (!groupId || !shopName || !countries.length) {
msgEl.textContent = '请完整填写分组、店铺名、国家和 ASIN';
msgEl.className = 'msg err';
return;
}
var asinMappings = {};
try {
asinMappings = collectQueryAsinMappings(countries);
} catch (err) {
msgEl.textContent = err.message || '请输入 ASIN';
msgEl.className = 'msg err';
return;
}
var fallbackAsin = '';
Object.keys(asinMappings).some(function (countryCode) {
fallbackAsin = asinMappings[countryCode] || '';
return !!fallbackAsin;
});
fetch('/api/admin/query-asin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
group_id: Number(groupId),
shop_name: shopName,
countries: countries,
asin: fallbackAsin,
asin_mappings: asinMappings
})
})
.then(function (r) { return r.json(); })
.then(function (res) {
if (!res.success) {
msgEl.textContent = res.error || '保存失败';
msgEl.className = 'msg err';
return;
}
document.getElementById('queryAsinGroupSelect').value = '';
document.getElementById('queryAsinShopName').value = '';
Array.from(document.getElementById('queryAsinCountries').options).forEach(function (option) {
option.selected = false;
});
renderQueryAsinInputs();
msgEl.textContent = res.msg || '保存成功';
msgEl.className = 'msg ok';
loadQueryAsin(1);
})
.catch(function () {
msgEl.textContent = '请求失败';
msgEl.className = 'msg err';
});
};
document.getElementById('btnImportQueryAsin').onclick = function () {
uploadQueryAsinImport(false);
};
document.getElementById('btnDeleteImportQueryAsin').onclick = function () {
uploadQueryAsinImport(true);
};
renderQueryAsinInputs();
function loadVersions() { function loadVersions() {
fetch('/api/admin/versions') fetch('/api/admin/versions')
.then(function (r) { return r.json(); }) .then(function (r) { return r.json(); })
@@ -3457,10 +4162,10 @@
alert(res.error || '退出失败'); alert(res.error || '退出失败');
return; return;
} }
window.location.href = res.redirect || '/login'; window.location.replace(res.redirect || '/login?logout=1');
}) })
.catch(function () { .catch(function () {
window.location.href = '/logout'; window.location.replace('/logout');
}); });
}; };

View File

@@ -118,6 +118,10 @@
</div> </div>
<script> <script>
(function() { (function() {
var params = new URLSearchParams(window.location.search || '');
if (params.get('logout') === '1' || params.get('switch') === '1') {
return;
}
fetch('/api/auth/check', { credentials: 'same-origin' }) fetch('/api/auth/check', { credentials: 'same-origin' })
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(res) { .then(function(res) {

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>巡店删除 - 数富AI</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/query-asin-main.ts"></script>
</body>
</html>

View File

@@ -620,6 +620,11 @@ function clearActiveQueueTask() {
saveQueueState(); saveQueueState();
} }
function isRecordMissingError(error: unknown) {
const message = error instanceof Error ? error.message : String(error || "");
return /记录不存在|任务不存在|不存在|已删除|not\s*found|404/i.test(message);
}
function removeMatchedRowLocally(row: PatrolDeleteShopQueueItem) { function removeMatchedRowLocally(row: PatrolDeleteShopQueueItem) {
matchedItems.value = matchedItems.value.filter( matchedItems.value = matchedItems.value.filter(
(item) => rowKeyForMatch(item) !== rowKeyForMatch(row), (item) => rowKeyForMatch(item) !== rowKeyForMatch(row),
@@ -627,6 +632,14 @@ function removeMatchedRowLocally(row: PatrolDeleteShopQueueItem) {
saveMatchedItems(); saveMatchedItems();
} }
function removeHistoryItemLocally(item: PatrolDeleteHistoryItem) {
historyItems.value = historyItems.value.filter((row) => {
if (item.resultId != null && row.resultId === item.resultId) return false;
if (item.taskId != null && row.taskId === item.taskId) return false;
return true;
});
}
function mergeQueueItems( function mergeQueueItems(
base: PatrolDeleteShopQueueItem[], base: PatrolDeleteShopQueueItem[],
incoming: PatrolDeleteShopQueueItem[], incoming: PatrolDeleteShopQueueItem[],
@@ -1013,6 +1026,15 @@ async function deleteTaskRecord(item: PatrolDeleteHistoryItem) {
await Promise.all([loadCandidates(), refreshTaskViews()]); await Promise.all([loadCandidates(), refreshTaskViews()]);
ElMessage.success("已删除"); ElMessage.success("已删除");
} catch (error) { } catch (error) {
if (isRecordMissingError(error)) {
if (item.taskId && activeTaskId.value === item.taskId) {
clearActiveQueueTask();
}
removeHistoryItemLocally(item);
await loadCandidates();
ElMessage.success("后端记录已不存在,已同步移除本地记录");
return;
}
ElMessage.error(error instanceof Error ? error.message : "删除失败"); ElMessage.error(error instanceof Error ? error.message : "删除失败");
} }
} }

View File

@@ -818,6 +818,11 @@ function rowKeyForMatch(row: { shopName?: string; shopId?: number | string | nul
return `${(row.shopName || '').trim()}\u0001${row.shopId ?? ''}` return `${(row.shopName || '').trim()}\u0001${row.shopId ?? ''}`
} }
function isRecordMissingError(error: unknown) {
const message = error instanceof Error ? error.message : String(error || '')
return /记录不存在|不存在|已删除|not\s*found|404/i.test(message)
}
function buildSkipAsinDeletePolicy() { function buildSkipAsinDeletePolicy() {
const enabled = statusModeEnabled.value && !asinModeEnabled.value const enabled = statusModeEnabled.value && !asinModeEnabled.value
return { return {
@@ -843,6 +848,14 @@ function removeMatchedRowsLocally(rows: Array<{ shopName?: string; shopId?: numb
saveMatchedItemsToStorage() saveMatchedItemsToStorage()
} }
function removeHistoryItemLocally(item: PriceTrackHistoryItem) {
historyItems.value = historyItems.value.filter((row) => {
if (item.resultId != null && row.resultId === item.resultId) return false
if (item.taskId != null && row.taskId === item.taskId) return false
return true
})
}
async function removeMatchedRow(row: PriceTrackShopQueueItem) { async function removeMatchedRow(row: PriceTrackShopQueueItem) {
const backup = [...matchedItems.value] const backup = [...matchedItems.value]
removeMatchedRowsLocally([row]) removeMatchedRowsLocally([row])
@@ -860,6 +873,13 @@ async function removeMatchedRow(row: PriceTrackShopQueueItem) {
} }
ElMessage.success('已从匹配结果中移除') ElMessage.success('已从匹配结果中移除')
} catch (e) { } catch (e) {
if (isRecordMissingError(e)) {
await loadHistory()
await loadDashboard()
syncPollingIdsWithHistory()
ElMessage.success('后端记录已不存在,已同步移除本地记录')
return
}
matchedItems.value = backup matchedItems.value = backup
saveMatchedItemsToStorage() saveMatchedItemsToStorage()
ElMessage.error(e instanceof Error ? e.message : '同步后端失败') ElMessage.error(e instanceof Error ? e.message : '同步后端失败')
@@ -1587,6 +1607,15 @@ async function deleteTaskRecord(item: PriceTrackHistoryItem) {
syncPollingIdsWithHistory() syncPollingIdsWithHistory()
ElMessage.success('已删除') ElMessage.success('已删除')
} catch (e) { } catch (e) {
if (isRecordMissingError(e)) {
if (item.taskId != null) removePollingTask(item.taskId)
removeMatchedRowsLocally([item])
removeHistoryItemLocally(item)
await loadDashboard()
syncPollingIdsWithHistory()
ElMessage.success('后端记录已不存在,已同步移除本地记录')
return
}
ElMessage.error(e instanceof Error ? e.message : '删除失败') ElMessage.error(e instanceof Error ? e.message : '删除失败')
} }
} }

View File

@@ -469,6 +469,11 @@ function rowKeyForMatch(row: { shopName?: string; shopId?: number | string | nul
return `${name}\u0001${id}` return `${name}\u0001${id}`
} }
function isRecordMissingError(error: unknown) {
const message = error instanceof Error ? error.message : String(error || '')
return /记录不存在|任务不存在|不存在|已删除|not\s*found|404/i.test(message)
}
function removeMatchedRowsLocally(rows: Array<{ shopName?: string; shopId?: number | string | null }>) { function removeMatchedRowsLocally(rows: Array<{ shopName?: string; shopId?: number | string | null }>) {
if (!rows.length) return if (!rows.length) return
const keys = new Set(rows.map((row) => rowKeyForMatch(row))) const keys = new Set(rows.map((row) => rowKeyForMatch(row)))
@@ -476,6 +481,14 @@ function removeMatchedRowsLocally(rows: Array<{ shopName?: string; shopId?: numb
saveMatchedItemsToStorage() saveMatchedItemsToStorage()
} }
function removeHistoryItemLocally(item: ProductRiskHistoryItem) {
historyItems.value = historyItems.value.filter((row) => {
if (item.resultId != null && row.resultId === item.resultId) return false
if (item.taskId != null && row.taskId === item.taskId) return false
return true
})
}
async function removeMatchedRow(row: ProductRiskShopQueueItem) { async function removeMatchedRow(row: ProductRiskShopQueueItem) {
const key = rowKeyForMatch(row) const key = rowKeyForMatch(row)
const backup = [...matchedItems.value] const backup = [...matchedItems.value]
@@ -495,6 +508,13 @@ async function removeMatchedRow(row: ProductRiskShopQueueItem) {
} }
ElMessage.success('已从匹配结果中移除') ElMessage.success('已从匹配结果中移除')
} catch (e) { } catch (e) {
if (isRecordMissingError(e)) {
await loadHistory()
await loadDashboard()
syncPollingIdsWithHistory()
ElMessage.success('后端记录已不存在,已同步移除本地记录')
return
}
matchedItems.value = backup matchedItems.value = backup
saveMatchedItemsToStorage() saveMatchedItemsToStorage()
ElMessage.error(e instanceof Error ? e.message : '同步后端失败') ElMessage.error(e instanceof Error ? e.message : '同步后端失败')
@@ -530,6 +550,17 @@ async function deleteTaskRecord(item: ProductRiskHistoryItem) {
syncPollingIdsWithHistory() syncPollingIdsWithHistory()
ElMessage.success('已删除') ElMessage.success('已删除')
} catch (e) { } catch (e) {
if (isRecordMissingError(e)) {
if (item.taskId != null && item.taskId > 0) {
removePollingTask(item.taskId)
}
removeMatchedRowsLocally([item])
removeHistoryItemLocally(item)
await loadDashboard()
syncPollingIdsWithHistory()
ElMessage.success('后端记录已不存在,已同步移除本地记录')
return
}
ElMessage.error(e instanceof Error ? e.message : '删除失败') ElMessage.error(e instanceof Error ? e.message : '删除失败')
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -191,7 +191,7 @@ function loadTaskSnapshotsFromStorage() { try { const raw = window.localStorage.
function saveTaskSnapshotsToStorage() { setStorageJson(taskSnapshotsStorageKey(), taskSnapshots.value, Object.keys(taskSnapshots.value).length === 0) } function saveTaskSnapshotsToStorage() { setStorageJson(taskSnapshotsStorageKey(), taskSnapshots.value, Object.keys(taskSnapshots.value).length === 0) }
function loadMatchedItemsFromStorage() { try { const raw = window.localStorage.getItem(matchedItemsStorageKey()); matchedItems.value = raw ? JSON.parse(raw) : [] } catch { matchedItems.value = [] } } function loadMatchedItemsFromStorage() { try { const raw = window.localStorage.getItem(matchedItemsStorageKey()); matchedItems.value = raw ? JSON.parse(raw) : [] } catch { matchedItems.value = [] } }
function saveMatchedItemsToStorage() { setStorageJson(matchedItemsStorageKey(), matchedItems.value, matchedItems.value.length === 0) } function saveMatchedItemsToStorage() { setStorageJson(matchedItemsStorageKey(), matchedItems.value, matchedItems.value.length === 0) }
function isTaskMissingError(error: unknown) { const message = error instanceof Error ? error.message : String(error || ''); return message.includes('任务不存在') } function isTaskMissingError(error: unknown) { const message = error instanceof Error ? error.message : String(error || ''); return /记录不存在|任务不存在|不存在|已删除|not\s*found|404/i.test(message) }
function syncPollingIdsWithTaskState() { const nextIds = pollingTaskIds.value.filter((taskId) => (taskDetails.value[taskId] || taskSnapshots.value[taskId]?.task?.status) === 'RUNNING'); if (nextIds.length === pollingTaskIds.value.length) return; pollingTaskIds.value = nextIds; savePollingIds() } function syncPollingIdsWithTaskState() { const nextIds = pollingTaskIds.value.filter((taskId) => (taskDetails.value[taskId] || taskSnapshots.value[taskId]?.task?.status) === 'RUNNING'); if (nextIds.length === pollingTaskIds.value.length) return; pollingTaskIds.value = nextIds; savePollingIds() }
function addPollingTask(taskId: number) { if (!pollingTaskIds.value.includes(taskId)) { pollingTaskIds.value = [...pollingTaskIds.value, taskId]; savePollingIds() } } function addPollingTask(taskId: number) { if (!pollingTaskIds.value.includes(taskId)) { pollingTaskIds.value = [...pollingTaskIds.value, taskId]; savePollingIds() } }
function clearDispatchTimer(taskId: number) { const timer = dispatchTimers.get(taskId); if (timer) { window.clearTimeout(timer); dispatchTimers.delete(taskId) } } function clearDispatchTimer(taskId: number) { const timer = dispatchTimers.get(taskId); if (timer) { window.clearTimeout(timer); dispatchTimers.delete(taskId) } }
@@ -334,10 +334,15 @@ async function deleteTaskRecord(item: ShopMatchHistoryItem) {
await Promise.allSettled([loadHistory(), loadCandidates(), loadDashboard()]) await Promise.allSettled([loadHistory(), loadCandidates(), loadDashboard()])
ElMessage.success('删除成功') ElMessage.success('删除成功')
} catch (error) { } catch (error) {
if (taskId > 0 && isTaskMissingError(error)) { if (isTaskMissingError(error)) {
removeTaskLocally(taskId) if (taskId > 0) {
removeTaskLocally(taskId)
} else if (resultId > 0) {
historyItems.value = historyItems.value.filter((row) => Number(row.resultId || 0) !== resultId)
}
removeMatchedRowsLocally([item])
await Promise.allSettled([loadHistory(), loadCandidates(), loadDashboard()]) await Promise.allSettled([loadHistory(), loadCandidates(), loadDashboard()])
ElMessage.success('任务已在服务端删除,本地记录已同步清理') ElMessage.success('后端记录已不存在,本地记录已同步清理')
return return
} }
ElMessage.error(error instanceof Error ? error.message : '删除失败') ElMessage.error(error instanceof Error ? error.message : '删除失败')

View File

@@ -53,6 +53,7 @@ type ActiveNavKey =
| 'shop-match' | 'shop-match'
| 'pricing' | 'pricing'
| 'patrol-delete' | 'patrol-delete'
| 'query-asin'
type NavItem = { type NavItem = {
key: string key: string
@@ -95,6 +96,7 @@ const navGroups: ReadonlyArray<NavGroup> = [
{ key: 'shop-match', label: '定时匹配', href: '/new_web_source/shop-match.html' }, { key: 'shop-match', label: '定时匹配', href: '/new_web_source/shop-match.html' },
{ key: 'pricing', label: '跟价', href: '/new_web_source/price-track.html' }, { key: 'pricing', label: '跟价', href: '/new_web_source/price-track.html' },
{ key: 'patrol-delete', label: '巡店删除', href: '/new_web_source/patrol-delete.html' }, { key: 'patrol-delete', label: '巡店删除', href: '/new_web_source/patrol-delete.html' },
{ key: 'query-asin', label: '查询ASIN', href: '/new_web_source/query-asin.html' },
{ key: 'withdraw', label: '取款' }, { key: 'withdraw', label: '取款' },
{ key: 'shop-status', label: '店铺状态查询' }, { key: 'shop-status', label: '店铺状态查询' },
], ],

View File

@@ -0,0 +1,13 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
import 'element-plus/dist/index.css'
import '@/styles/main.css'
import BrandQueryAsinTab from '@/pages/brand/components/BrandQueryAsinTab.vue'
dayjs.locale('zh-cn')
createApp(BrandQueryAsinTab).use(ElementPlus, { locale: zhCn }).mount('#app')

View File

@@ -581,6 +581,7 @@ export interface ProductRiskShopQueueItem {
matchedUserId?: number; matchedUserId?: number;
matchStatus?: string; matchStatus?: string;
matchMessage?: string; matchMessage?: string;
queryAsins?: QueryAsinCountryAsins[];
} }
export interface ProductRiskMatchShopsVo { export interface ProductRiskMatchShopsVo {
@@ -1189,6 +1190,174 @@ export function deletePatrolDeleteHistory(resultId: number) {
); );
} }
export type QueryAsinCandidateVo = ProductRiskCandidateVo;
export type QueryAsinDashboardVo = ProductRiskDashboardVo;
export type QueryAsinShopQueueItem = ProductRiskShopQueueItem;
export interface QueryAsinCountryAsins {
country: string;
asins: string[];
}
export interface QueryAsinStatusItem {
asin?: string;
status?: string;
}
export interface QueryAsinCountryResult {
country: string;
items: QueryAsinStatusItem[];
}
export interface QueryAsinTaskItem {
shopName?: string;
matched?: boolean;
shopId?: string;
platform?: string;
companyName?: string;
matchStatus?: string;
matchMessage?: string;
queryAsins: QueryAsinCountryAsins[];
}
export interface QueryAsinHistoryItem {
resultId?: number;
taskId?: number;
shopName?: string;
shopId?: string;
platform?: string;
companyName?: string;
matched?: boolean;
matchStatus?: string;
matchMessage?: string;
taskStatus?: string;
success?: boolean;
error?: string;
createdAt?: string;
finishedAt?: string;
outputFilename?: string;
downloadUrl?: string;
queryAsins: QueryAsinCountryAsins[];
countryResults?: QueryAsinCountryResult[];
}
export interface QueryAsinHistoryVo {
items: QueryAsinHistoryItem[];
}
export interface QueryAsinTaskBatchVo {
items: QueryAsinHistoryItem[];
missingTaskIds: number[];
}
export interface QueryAsinCreateTaskVo {
taskId: number;
items: QueryAsinHistoryItem[];
}
export function listQueryAsinCandidates() {
return unwrapJavaResponse(
get<JavaApiResponse<QueryAsinCandidateVo[]>>(`${JAVA_API_PREFIX}/query-asin/candidates`, {
params: { user_id: getCurrentUserId() },
}),
);
}
export function addQueryAsinCandidate(shopName: string) {
return unwrapJavaResponse(
post<JavaApiResponse<QueryAsinCandidateVo>, { user_id: number; shop_name: string }>(
`${JAVA_API_PREFIX}/query-asin/candidates`,
{ user_id: getCurrentUserId(), shop_name: shopName },
),
);
}
export function deleteQueryAsinCandidate(id: number) {
return unwrapJavaResponse(
del<JavaApiResponse<null>>(`${JAVA_API_PREFIX}/query-asin/candidates/${id}`, {
params: { user_id: getCurrentUserId() },
}),
);
}
export function matchQueryAsinShops(shopNames: string[]) {
return unwrapJavaResponse(
post<JavaApiResponse<ProductRiskMatchShopsVo>, { user_id: number; shop_names: string[] }>(
`${JAVA_API_PREFIX}/query-asin/match-shops`,
{ user_id: getCurrentUserId(), shop_names: shopNames },
),
);
}
export function getQueryAsinDashboard() {
return unwrapJavaResponse(
get<JavaApiResponse<QueryAsinDashboardVo>>(`${JAVA_API_PREFIX}/query-asin/dashboard`, {
params: { user_id: getCurrentUserId() },
}),
);
}
export function getQueryAsinHistory() {
return unwrapJavaResponse(
get<JavaApiResponse<QueryAsinHistoryVo>>(`${JAVA_API_PREFIX}/query-asin/history`, {
params: { user_id: getCurrentUserId() },
}),
);
}
export function getQueryAsinTaskProgressBatch(taskIds: number[]) {
return postTaskProgressBatch<QueryAsinTaskBatchVo>(
`${JAVA_API_PREFIX}/query-asin/tasks/progress/batch`,
taskIds,
);
}
export function createQueryAsinTask(items: QueryAsinTaskItem[]) {
return unwrapJavaResponse(
post<JavaApiResponse<QueryAsinCreateTaskVo>, { user_id: number; items: QueryAsinTaskItem[] }>(
`${JAVA_API_PREFIX}/query-asin/tasks`,
{ user_id: getCurrentUserId(), items },
),
);
}
export function submitQueryAsinTaskResult(
taskId: number,
payload: {
shops: Array<{
shopName: string;
error?: string;
countryResults?: QueryAsinCountryResult[];
shopDone?: boolean;
submissionId?: string;
}>;
},
) {
return unwrapJavaResponse(
post<JavaApiResponse<null>, typeof payload>(`${JAVA_API_PREFIX}/query-asin/tasks/${taskId}/result`, payload),
);
}
export function getQueryAsinResultDownloadUrl(resultId: number) {
return getJavaDownloadUrl(`/query-asin/results/${resultId}/download`);
}
export function deleteQueryAsinTask(taskId: number) {
return unwrapJavaResponse(
del<JavaApiResponse<null>>(`${JAVA_API_PREFIX}/query-asin/tasks/${taskId}`, {
params: { user_id: getCurrentUserId() },
}),
);
}
export function deleteQueryAsinHistory(resultId: number) {
return unwrapJavaResponse(
del<JavaApiResponse<null>>(`${JAVA_API_PREFIX}/query-asin/history/${resultId}`, {
params: { user_id: getCurrentUserId() },
}),
);
}
// ========== 跟价 ========== // ========== 跟价 ==========
export interface PriceTrackCandidateVo { export interface PriceTrackCandidateVo {

View File

@@ -50,6 +50,7 @@ export default defineConfig({
'shop-match': resolve(__dirname, 'shop-match.html'), 'shop-match': resolve(__dirname, 'shop-match.html'),
'price-track': resolve(__dirname, 'price-track.html'), 'price-track': resolve(__dirname, 'price-track.html'),
'patrol-delete': resolve(__dirname, 'patrol-delete.html'), 'patrol-delete': resolve(__dirname, 'patrol-delete.html'),
'query-asin': resolve(__dirname, 'query-asin.html'),
}, },
output: { output: {
entryFileNames: 'assets/[name].js', entryFileNames: 'assets/[name].js',

View File

@@ -0,0 +1 @@
import{aO as r}from"./pywebview-CeWJDVeG.js";const n="";function a(e){return r(`${n}/api/brand/expand-folder-recursive`,{folder:e})}export{a as e};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
const e=[{value:"SearchSuppressed",label:"在搜索结果中禁止显示"},{value:"ApprovalRequired",label:"需要批准"},{value:"Active",label:"在售"},{value:"DetailPageRemoved",label:"详情页面已删除"}];export{e as L};

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