Merge origin master into local master
This commit is contained in:
17535
2026_04_30.log
Normal file
17535
2026_04_30.log
Normal file
File diff suppressed because one or more lines are too long
4
app/.env
4
app/.env
@@ -11,8 +11,8 @@ zn_username=%E8%87%AA%E5%8A%A8%E5%8C%96_Robot
|
||||
client_name=ShuFuAI
|
||||
|
||||
|
||||
java_api_base=http://47.111.163.154:18080
|
||||
# java_api_base=http://127.0.0.1:18080
|
||||
# java_api_base=http://47.111.163.154:18080
|
||||
java_api_base=http://127.0.0.1:18080
|
||||
# java_api_base=http://8.136.19.173:18080
|
||||
|
||||
|
||||
|
||||
BIN
app/amazon/__pycache__/approve.cpython-39.pyc
Normal file
BIN
app/amazon/__pycache__/approve.cpython-39.pyc
Normal file
Binary file not shown.
BIN
app/amazon/__pycache__/asin_status.cpython-39.pyc
Normal file
BIN
app/amazon/__pycache__/asin_status.cpython-39.pyc
Normal file
Binary file not shown.
BIN
app/amazon/__pycache__/del_brand.cpython-39.pyc
Normal file
BIN
app/amazon/__pycache__/del_brand.cpython-39.pyc
Normal file
Binary file not shown.
BIN
app/amazon/__pycache__/match_action.cpython-39.pyc
Normal file
BIN
app/amazon/__pycache__/match_action.cpython-39.pyc
Normal file
Binary file not shown.
BIN
app/amazon/__pycache__/price_match.cpython-39.pyc
Normal file
BIN
app/amazon/__pycache__/price_match.cpython-39.pyc
Normal file
Binary file not shown.
@@ -1081,7 +1081,69 @@ class ApproveTask:
|
||||
if shop_name in runing_shop:
|
||||
del runing_shop[shop_name]
|
||||
self.log(f"店铺 {shop_name} 已从执行列表中移除")
|
||||
|
||||
|
||||
def action_init(self,driver,country_name,shop_name,risk_listing_filter):
|
||||
"""切换到指定国际 -> 页面 -》 筛选好"""
|
||||
max_retries = 5
|
||||
switch_success = False
|
||||
switch_success_pg = False
|
||||
search_success = False
|
||||
sku_ls = []
|
||||
for retry in range(max_retries):
|
||||
try:
|
||||
self.log(f"尝试切换到国家 {country_name} (第 {retry + 1}/{max_retries} 次)")
|
||||
if retry > 2:
|
||||
# 刷新不行就重新打开店铺
|
||||
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}")
|
||||
|
||||
# 切换到库存管理页面
|
||||
driver.SwitchPage()
|
||||
self.log(f"已切换到库存管理页面")
|
||||
time.sleep(3)
|
||||
switch_success_pg = True
|
||||
|
||||
sku_ls = driver.search(filter_type=risk_listing_filter)
|
||||
search_success = True
|
||||
self.log(f"国家 {country_name} 搜索出 {len(sku_ls)} 商品,开始处理...")
|
||||
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 or not switch_success_pg or not search_success:
|
||||
# error_message = f"切换到国家({switch_success})/库存页面({switch_success_pg})/搜索筛选到指定选项({search_success}) {country_name} 失败,已重试 {max_retries} 次,跳过该国家"
|
||||
# self.log(error_message, "ERROR")
|
||||
# if task_id in runing_task:
|
||||
# runing_task[task_id]["processed_countries"] += 1
|
||||
return switch_success,switch_success_pg,search_success,sku_ls
|
||||
|
||||
def process_country(self, driver: AmzoneApprove, country_code: str, task_id: int, shop_name: str, risk_listing_filter: str):
|
||||
"""处理单个国家的审批任务
|
||||
|
||||
@@ -1108,87 +1170,98 @@ class ApproveTask:
|
||||
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)
|
||||
|
||||
# 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
|
||||
#
|
||||
# # 切换到库存管理页面
|
||||
# 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 task_id in runing_task:
|
||||
# runing_task[task_id]["processed_countries"] += 1
|
||||
# return
|
||||
#
|
||||
# # 搜索需要审批的商品,最多重试3次
|
||||
# sku_ls = []
|
||||
# for retry in range(max_retries):
|
||||
# try:
|
||||
# self.log(f"尝试搜索需要审批的商品 (第 {retry + 1}/{max_retries} 次)")
|
||||
# sku_ls = driver.search(filter_type=risk_listing_filter)
|
||||
# break
|
||||
# except Exception as e:
|
||||
# self.log(f"搜索商品异常: {str(e)}", "ERROR")
|
||||
# if retry < max_retries - 1:
|
||||
# try:
|
||||
# driver.tab.refresh()
|
||||
# time.sleep(3)
|
||||
# except Exception as refresh_error:
|
||||
# self.log(f"刷新页面失败: {str(refresh_error)}", "WARNING")
|
||||
#
|
||||
max_retries = 5
|
||||
switch_success, switch_success_pg, search_success,sku_ls = self.action_init(driver, country_name, shop_name,
|
||||
risk_listing_filter)
|
||||
# 如果切换失败,直接返回
|
||||
if not switch_success:
|
||||
error_message = f"切换到国家 {country_name} 失败,已重试 {max_retries} 次,跳过该国家"
|
||||
if not switch_success or not switch_success_pg or not search_success:
|
||||
error_message = f"切换到国家({switch_success})/库存页面({switch_success_pg})/搜索筛选到指定选项({search_success}) {country_name} 失败,已重试 {max_retries} 次,跳过该国家"
|
||||
self.log(error_message, "ERROR")
|
||||
if task_id in runing_task:
|
||||
runing_task[task_id]["processed_countries"] += 1
|
||||
return
|
||||
|
||||
# 切换到库存管理页面
|
||||
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 task_id in runing_task:
|
||||
runing_task[task_id]["processed_countries"] += 1
|
||||
return
|
||||
|
||||
# 搜索需要审批的商品,最多重试3次
|
||||
sku_ls = []
|
||||
for retry in range(max_retries):
|
||||
try:
|
||||
self.log(f"尝试搜索需要审批的商品 (第 {retry + 1}/{max_retries} 次)")
|
||||
sku_ls = driver.search(filter_type=risk_listing_filter)
|
||||
break
|
||||
except Exception as e:
|
||||
self.log(f"搜索商品异常: {str(e)}", "ERROR")
|
||||
if retry < max_retries - 1:
|
||||
try:
|
||||
driver.tab.refresh()
|
||||
time.sleep(3)
|
||||
except Exception as refresh_error:
|
||||
self.log(f"刷新页面失败: {str(refresh_error)}", "WARNING")
|
||||
|
||||
# 如果没有需要审批的商品,直接返回
|
||||
|
||||
# # 如果没有需要审批的商品,直接返回
|
||||
if len(sku_ls) == 0:
|
||||
self.log(f"国家 {country_name} 没有需要审批的商品")
|
||||
if task_id in runing_task:
|
||||
runing_task[task_id]["processed_countries"] += 1
|
||||
return
|
||||
|
||||
|
||||
self.log(f"国家 {country_name} 有 {len(sku_ls)} 个需要审批的商品,开始处理...")
|
||||
|
||||
# 处理所有需要审批的商品(通过yield获取结果)
|
||||
|
||||
@@ -537,8 +537,6 @@ class StatusTask:
|
||||
self.log(f"切换页面失败重试退出", "ERROR")
|
||||
return
|
||||
|
||||
|
||||
|
||||
# 处理所有需要审批的商品(通过yield获取结果)
|
||||
|
||||
# 指定 asin
|
||||
|
||||
@@ -8,11 +8,12 @@ import json
|
||||
import os
|
||||
|
||||
from typing import Literal
|
||||
from DrissionPage import Chromium
|
||||
from DrissionPage import Chromium,ChromiumOptions
|
||||
from DrissionPage.common import By
|
||||
from DrissionPage._pages.chromium_tab import ChromiumTab
|
||||
|
||||
|
||||
|
||||
def kill_process(version: Literal["v5", "v6"]):
|
||||
"""杀紫鸟客户端进程(独立函数版本)"""
|
||||
driver = ZiniaoDriver({})
|
||||
@@ -205,7 +206,11 @@ class ZiniaoDriver:
|
||||
Returns:
|
||||
Chromium: DrissionPage浏览器实例
|
||||
"""
|
||||
browser = Chromium(port)
|
||||
co = ChromiumOptions()
|
||||
# 设置不加载图片、静音
|
||||
co.set_local_port(port)
|
||||
co.no_imgs(True).mute(True)
|
||||
browser = Chromium(co)
|
||||
return browser
|
||||
|
||||
def start_client(self):
|
||||
|
||||
@@ -468,6 +468,70 @@ class MatchTak:
|
||||
del runing_shop[shop_name]
|
||||
self.log(f"店铺 {shop_name} 已从执行列表中移除")
|
||||
|
||||
def action_init(self,driver,country_name,shop_name,risk_listing_filter):
|
||||
"""切换到指定国际 -> 页面 -》 筛选好"""
|
||||
max_retries = 5
|
||||
switch_success = False
|
||||
switch_success_pg = False
|
||||
search_success = False
|
||||
sku_ls = []
|
||||
for retry in range(max_retries):
|
||||
try:
|
||||
self.log(f"尝试切换到国家 {country_name} (第 {retry + 1}/{max_retries} 次)")
|
||||
if retry > 2:
|
||||
# 刷新不行就重新打开店铺
|
||||
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}")
|
||||
|
||||
# 切换到库存管理页面
|
||||
driver.SwitchPage()
|
||||
self.log(f"已切换到库存管理页面")
|
||||
time.sleep(3)
|
||||
switch_success_pg = True
|
||||
|
||||
sku_ls = driver.search(filter_type=risk_listing_filter)
|
||||
search_success = True
|
||||
self.log(f"国家 {country_name} 搜索出 {len(sku_ls)} 商品,开始处理...")
|
||||
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 or not switch_success_pg or not search_success:
|
||||
# error_message = f"切换到国家({switch_success})/库存页面({switch_success_pg})/搜索筛选到指定选项({search_success}) {country_name} 失败,已重试 {max_retries} 次,跳过该国家"
|
||||
# self.log(error_message, "ERROR")
|
||||
# if task_id in runing_task:
|
||||
# runing_task[task_id]["processed_countries"] += 1
|
||||
return switch_success,switch_success_pg,search_success,sku_ls
|
||||
|
||||
|
||||
|
||||
def process_country(self, driver: AmzoneMatchAction, country_code: str, task_id: int, shop_name: str, risk_listing_filter: str,limit:str=None):
|
||||
"""处理单个国家的审批任务
|
||||
|
||||
@@ -491,90 +555,108 @@ class MatchTak:
|
||||
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)
|
||||
|
||||
# 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
|
||||
#
|
||||
# # 切换到库存管理页面
|
||||
# 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 task_id in runing_task:
|
||||
# runing_task[task_id]["processed_countries"] += 1
|
||||
# return
|
||||
#
|
||||
# # 搜索需要审批的商品,最多重试3次
|
||||
# sku_ls = []
|
||||
# for retry in range(max_retries):
|
||||
# try:
|
||||
# self.log(f"尝试搜索匹配操作商品 (第 {retry + 1}/{max_retries} 次)")
|
||||
# sku_ls = driver.search(filter_type=risk_listing_filter)
|
||||
# break
|
||||
# except Exception as e:
|
||||
# self.log(f"搜索商品异常: {str(e)}", "ERROR")
|
||||
# if retry < max_retries - 1:
|
||||
# try:
|
||||
# driver.tab.refresh()
|
||||
# time.sleep(3)
|
||||
# except Exception as refresh_error:
|
||||
# self.log(f"刷新页面失败: {str(refresh_error)}", "WARNING")
|
||||
#
|
||||
# # 如果没有需要审批的商品,直接返回
|
||||
# if len(sku_ls) == 0:
|
||||
# self.log(f"国家 {country_name} 没有搜索出的商品")
|
||||
# if task_id in runing_task:
|
||||
# runing_task[task_id]["processed_countries"] += 1
|
||||
# return
|
||||
#
|
||||
max_retries = 5
|
||||
switch_success, switch_success_pg, search_success, sku_ls = self.action_init(driver, country_name, shop_name,
|
||||
risk_listing_filter)
|
||||
# 如果切换失败,直接返回
|
||||
if not switch_success:
|
||||
error_message = f"切换到国家 {country_name} 失败,已重试 {max_retries} 次,跳过该国家"
|
||||
if not switch_success or not switch_success_pg or not search_success:
|
||||
error_message = f"切换到国家({switch_success})/库存页面({switch_success_pg})/搜索筛选到指定选项({search_success}) {country_name} 失败,已重试 {max_retries} 次,跳过该国家"
|
||||
self.log(error_message, "ERROR")
|
||||
if task_id in runing_task:
|
||||
runing_task[task_id]["processed_countries"] += 1
|
||||
return
|
||||
|
||||
# 切换到库存管理页面
|
||||
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 task_id in runing_task:
|
||||
runing_task[task_id]["processed_countries"] += 1
|
||||
return
|
||||
|
||||
# 搜索需要审批的商品,最多重试3次
|
||||
sku_ls = []
|
||||
for retry in range(max_retries):
|
||||
try:
|
||||
self.log(f"尝试搜索匹配操作商品 (第 {retry + 1}/{max_retries} 次)")
|
||||
sku_ls = driver.search(filter_type=risk_listing_filter)
|
||||
break
|
||||
except Exception as e:
|
||||
self.log(f"搜索商品异常: {str(e)}", "ERROR")
|
||||
if retry < max_retries - 1:
|
||||
try:
|
||||
driver.tab.refresh()
|
||||
time.sleep(3)
|
||||
except Exception as refresh_error:
|
||||
self.log(f"刷新页面失败: {str(refresh_error)}", "WARNING")
|
||||
|
||||
# 如果没有需要审批的商品,直接返回
|
||||
|
||||
# # 如果没有需要审批的商品,直接返回
|
||||
if len(sku_ls) == 0:
|
||||
self.log(f"国家 {country_name} 没有搜索出的商品")
|
||||
self.log(f"国家 {country_name} 没有需要审批的商品")
|
||||
if task_id in runing_task:
|
||||
runing_task[task_id]["processed_countries"] += 1
|
||||
return
|
||||
|
||||
|
||||
self.log(f"国家 {country_name} 搜索出 {len(sku_ls)} 商品,开始处理...")
|
||||
|
||||
# 处理所有需要审批的商品(通过yield获取结果)
|
||||
|
||||
@@ -16,28 +16,32 @@ from amazon.tool import show_notification,get_shop_info,remove_special_character
|
||||
|
||||
from config import runing_task, runing_shop,base_dir,DELETE_BRAND_API_BASE
|
||||
|
||||
|
||||
class RepricingLogic:
|
||||
|
||||
@staticmethod
|
||||
def situation_1_general_decrease(my_price, rank1_price, is_first_time=False):
|
||||
"""
|
||||
情况1:常规递减跟价 (最左侧独立长图块)
|
||||
当不是自己购物车时,按总价当作第一名递减。
|
||||
"""
|
||||
if is_first_time:
|
||||
return rank1_price - 0.3
|
||||
|
||||
diff = my_price - rank1_price
|
||||
# 这里的 diff 算出的是一个正数,代表“我比第一名低多少”
|
||||
diff = rank1_price - my_price
|
||||
|
||||
# 精简了原图中长串的阶梯逻辑
|
||||
# 如果当前价格高于或等于第一名,默认按照第一名减0.3处理
|
||||
if diff <= 0:
|
||||
return rank1_price - 0.3
|
||||
|
||||
# 逻辑更清晰的写法:第一名价格 - (差价 + 对应阶梯值)
|
||||
# 例如差价0.7,则相当于 第一名价格 - (0.7 + 0.5) = 第一名价格 - 1.2
|
||||
if diff <= 0.3:
|
||||
return rank1_price - 0.5
|
||||
return rank1_price - (diff + 0.5)
|
||||
elif diff <= 0.8:
|
||||
return rank1_price - 0.7
|
||||
return rank1_price - (diff + 0.7)
|
||||
elif diff <= 10.5:
|
||||
# 0.8 到 10.5 之间,原图逻辑全部是“减1”
|
||||
return rank1_price - 1.0
|
||||
# 0.8 到 10.5 之间全部是在减1
|
||||
return rank1_price - (diff + 1.0)
|
||||
else:
|
||||
return None # 相差10.5以上,跳过
|
||||
|
||||
@@ -45,55 +49,56 @@ class RepricingLogic:
|
||||
def situation_2_own_buybox(my_price, rank2_price):
|
||||
"""
|
||||
情况2:已经是自己购物车
|
||||
逻辑:判断与第二名的差价,如果拉开足够差距,则稍微降一点点(减0.5)保持优势,防止卖太便宜。
|
||||
"""
|
||||
diff = rank2_price - my_price
|
||||
|
||||
# 1. 绝对差价2欧以上
|
||||
if diff >= 2.0:
|
||||
return rank2_price - 0.3
|
||||
|
||||
# 2. 产品售价区间判定 (是否要提高价格)
|
||||
if (20 <= my_price < 30 and diff >= 4) or \
|
||||
(30 <= my_price < 60 and diff >= 8) or \
|
||||
(60 <= my_price <= 150 and diff >= 15):
|
||||
return rank2_price - 0.3 # 满足区间大差价,提价至第二名之下
|
||||
return rank2_price - 0.3
|
||||
|
||||
return my_price # 不满足条件则保持原价
|
||||
return my_price
|
||||
|
||||
@staticmethod
|
||||
def situation_3_not_own_buybox(my_price, rank1_price):
|
||||
"""
|
||||
情况3:不是自己购物车 (常规情况)
|
||||
情况3:不是自己购物车 (除Amazon US外)
|
||||
"""
|
||||
diff = my_price - rank1_price
|
||||
diff = rank1_price - my_price
|
||||
|
||||
if diff <= 0.3:
|
||||
return rank1_price - 0.5
|
||||
elif 0.5 <= diff <= 0.8:
|
||||
return rank1_price - 0.7
|
||||
elif 0.8 < diff <= 2.5:
|
||||
return rank1_price - 1.0
|
||||
elif diff > 2.5: # 2.5-3.5以上跳过
|
||||
return None
|
||||
if diff <= 0:
|
||||
return rank1_price - 0.3
|
||||
|
||||
# 逻辑更清晰的写法:第一名价格 - (差价 + 对应阶梯值)
|
||||
if diff <= 0.3:
|
||||
return rank1_price - (diff + 0.5)
|
||||
elif diff <= 0.8:
|
||||
return rank1_price - (diff + 0.7)
|
||||
elif diff <= 2.5:
|
||||
return rank1_price - (diff + 1.0)
|
||||
else:
|
||||
return None # 2.5-3.5以上跳过
|
||||
|
||||
return rank1_price - 0.3 # 默认按第一名减0.3
|
||||
|
||||
@staticmethod
|
||||
def situation_4_encounter_amz_us_1st(my_price, amz_us_price):
|
||||
"""
|
||||
情况4:第一名是 Amazon US 卖家
|
||||
情况4:遇到第一名是 Amazon US 卖家
|
||||
"""
|
||||
diff = my_price - amz_us_price
|
||||
diff = amz_us_price - my_price
|
||||
|
||||
if diff <= 3:
|
||||
return amz_us_price - 2
|
||||
elif 4 <= diff <= 6:
|
||||
if diff <= 0:
|
||||
return amz_us_price - 3
|
||||
elif 8 <= diff <= 12:
|
||||
return None # 跳过不跟价
|
||||
|
||||
return amz_us_price - 3 # 默认直接按照 Amazon US 减去3
|
||||
# 逻辑更清晰的写法:第一名价格 - (差价 + 对应阶梯值)
|
||||
if diff <= 3:
|
||||
return amz_us_price - (diff + 2)
|
||||
elif diff <= 6:
|
||||
return amz_us_price - (diff + 3)
|
||||
else:
|
||||
return None # 超过6直接跳过
|
||||
|
||||
@staticmethod
|
||||
def situation_5_im_1st_amz_us_2nd(my_price, amz_us_price):
|
||||
@@ -103,7 +108,7 @@ class RepricingLogic:
|
||||
diff = amz_us_price - my_price
|
||||
|
||||
if diff <= 7:
|
||||
return None # 相差5以内跳过 (保持原价)
|
||||
return None # 相差7以内跳过 (保持原价)
|
||||
else:
|
||||
return amz_us_price - 5
|
||||
|
||||
@@ -133,16 +138,6 @@ def calculate_target_price(
|
||||
price_col = recommended_price + recommended_shipping
|
||||
print(f"【紫鸟后台价格】{is_my_buybox} 推荐价格: {recommended_price},推荐运费: {recommended_shipping},总和: {price_col}")
|
||||
return RepricingLogic.situation_1_general_decrease(my_price,price_col,is_my_buybox)
|
||||
# backend_base_price = recommended_price
|
||||
# backend_shipping = recommended_shipping
|
||||
# total_price = backend_base_price + backend_shipping
|
||||
#
|
||||
# if is_my_buybox:
|
||||
# # 🌟【逻辑更新】:如果是自己的购物车,直接跳过,不做任何价格调整
|
||||
# return None
|
||||
# else:
|
||||
# # 不是自己的购物车:直接将紫鸟的总和作为"第一名"价格,强制抛入阶梯跟价逻辑
|
||||
# return calculate_standard_competitor_pricing(my_price, total_price)
|
||||
|
||||
# # 第一名是自己, 第二名 Amazon US 卖家 的情况
|
||||
shop_name_1 = front_end_data.get("top_sellers")[0].get("shop_name")
|
||||
@@ -152,13 +147,11 @@ def calculate_target_price(
|
||||
return RepricingLogic.situation_5_im_1st_amz_us_2nd(my_price,price_2)
|
||||
|
||||
# --- Step 3: 常规核心分流逻辑 (如果没有紫鸟后台数据) ---
|
||||
|
||||
# 分支 A:第一名是 Amazon US 卖家 (特殊强敌优先)
|
||||
price_1 = float(front_end_data.get("top_sellers")[0].get("price")) # 实际第一名价格
|
||||
if "Amazon" in shop_name_1:
|
||||
return RepricingLogic.situation_4_encounter_amz_us_1st(my_price,price_1)
|
||||
|
||||
|
||||
# 分支 B:不是 Amazon US,且目前是自己的购物车
|
||||
elif is_my_buybox:
|
||||
price_2 = float(front_end_data.get("top_sellers")[1].get("price")) # 实际第二名价格
|
||||
@@ -650,6 +643,13 @@ class AmzonePriceMatch(AmamzonBase):
|
||||
print(f"【{self.mark_name}】获取到 {len(sku_ls)}")
|
||||
|
||||
if len(sku_ls) == 0:
|
||||
if mode == "asin":
|
||||
print(f"asin 模式查询不到,{asin}")
|
||||
yield (asin, {
|
||||
"statu": "查询不到"
|
||||
})
|
||||
break
|
||||
|
||||
retry_num += 1
|
||||
print(f"没有获取到 ASIN 商品,重试{retry_num}/{max_retry_num}")
|
||||
self.tab.refresh()
|
||||
@@ -680,19 +680,16 @@ class AmzonePriceMatch(AmamzonBase):
|
||||
bottom_price = f"{sum(split_currency_values(bottom_price_text))}"
|
||||
except Exception as e:
|
||||
print("最低价提取失败",e)
|
||||
|
||||
current_price_ele = sku_ele.eles('xpath:.//b[text()="价格"]/../..//kat-input',timeout=5)
|
||||
if len(current_price_ele) > 0:
|
||||
current_price = current_price_ele[0].attr("value")
|
||||
print("获取到当前价格为 ->",current_price)
|
||||
# 如果紫鸟里当前价格低于最低价则删除,否则跳过
|
||||
if mode == "status" and miniprice_info.get(asin):
|
||||
# if mode == "status" and miniprice_info.get(asin):
|
||||
if miniprice_info.get(asin):
|
||||
backend_price = float(miniprice_info.get(asin))
|
||||
if float(current_price) <= backend_price:
|
||||
# yield (asin, {
|
||||
# "statu": "删除(当前价格低于最低价)",
|
||||
# "deleteSkipAsin": True,
|
||||
# "removeAsin": asin,
|
||||
# })
|
||||
yield (asin, {
|
||||
"statu": "跳过(当前价格低于或等于最低价)",
|
||||
"currentPrice": current_price,
|
||||
@@ -764,6 +761,9 @@ class AmzonePriceMatch(AmamzonBase):
|
||||
print(asin,"-->>",f"【逻辑计算后的价格】",adjust_prices)
|
||||
if adjust_prices is not None and adjust_prices != float(current_price):
|
||||
#修改价格
|
||||
print("准备填入的原始值:",adjust_prices)
|
||||
adjust_prices = round(adjust_prices, 2)
|
||||
print("保留两位小数后的::",adjust_prices)
|
||||
self.tab.actions.click(current_price_ele[0])
|
||||
time.sleep(1)
|
||||
current_price_ele[0].input(f"{adjust_prices}", clear=True)
|
||||
@@ -784,12 +784,12 @@ class AmzonePriceMatch(AmamzonBase):
|
||||
"statu" : "改价成功",
|
||||
"currentPrice" : current_price,
|
||||
"recommendedPrice" : f"{recommendedPrice}",
|
||||
"minimumPrice" : bottom_price, #最低价格
|
||||
"minimumPrice" : f"{miniprice_info.get(asin)}" if miniprice_info.get(asin) else "",#bottom_price, #最低价格
|
||||
"firstPlace": price_1,
|
||||
"secondPlace": price_2,
|
||||
"cartShopName" : cartShopName,
|
||||
"shippingFee" : f"{shipping_fee}",
|
||||
"priceChangeStatus" : f"{adjust_prices}",
|
||||
"priceChangePrice" : f"{adjust_prices}",
|
||||
"priceChangeStatus" : "改价成功",
|
||||
})
|
||||
else:
|
||||
@@ -803,12 +803,14 @@ class AmzonePriceMatch(AmamzonBase):
|
||||
"statu": "跳过,无需改价",
|
||||
"currentPrice": current_price,
|
||||
"recommendedPrice": f"{recommendedPrice}",
|
||||
"minimumPrice": bottom_price, # 最低价格
|
||||
# "minimumPrice": bottom_price, # 最低价格
|
||||
"minimumPrice": f"{miniprice_info.get(asin)}" if miniprice_info.get(asin) else "",
|
||||
"firstPlace": price_1,
|
||||
"secondPlace": price_2,
|
||||
"cartShopName": cartShopName,
|
||||
"shippingFee": f"{shipping_fee}",
|
||||
"priceChangeStatus": "跳过,无需改价",
|
||||
"priceChangePrice": f"{adjust_prices}".replace("None",""),
|
||||
})
|
||||
|
||||
already_asin.add(asin)
|
||||
@@ -1029,6 +1031,68 @@ class PriceTask:
|
||||
return driver
|
||||
return driver
|
||||
|
||||
def action_init(self,driver,country_name,shop_name,risk_listing_filter):
|
||||
"""切换到指定国际 -> 页面 -》 筛选好"""
|
||||
max_retries = 5
|
||||
switch_success = False
|
||||
switch_success_pg = False
|
||||
search_success = False
|
||||
sku_ls = []
|
||||
for retry in range(max_retries):
|
||||
try:
|
||||
self.log(f"尝试切换到国家 {country_name} (第 {retry + 1}/{max_retries} 次)")
|
||||
if retry > 2:
|
||||
# 刷新不行就重新打开店铺
|
||||
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}")
|
||||
|
||||
# 切换到库存管理页面
|
||||
driver.SwitchPage()
|
||||
self.log(f"已切换到库存管理页面")
|
||||
time.sleep(3)
|
||||
switch_success_pg = True
|
||||
|
||||
sku_ls = driver.search(filter_type=risk_listing_filter)
|
||||
search_success = True
|
||||
self.log(f"国家 {country_name} 搜索出 {len(sku_ls)} 商品,开始处理...")
|
||||
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 or not switch_success_pg or not search_success:
|
||||
# error_message = f"切换到国家({switch_success})/库存页面({switch_success_pg})/搜索筛选到指定选项({search_success}) {country_name} 失败,已重试 {max_retries} 次,跳过该国家"
|
||||
# self.log(error_message, "ERROR")
|
||||
# if task_id in runing_task:
|
||||
# runing_task[task_id]["processed_countries"] += 1
|
||||
return switch_success,switch_success_pg,search_success
|
||||
|
||||
|
||||
def process_shop(self, shop_item: dict, country_codes: list, task_id: int, risk_listing_filter: str,
|
||||
user_id=None, stage_index=None, final_stage: bool = True, limit: str = None):
|
||||
@@ -1149,94 +1213,42 @@ class PriceTask:
|
||||
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)
|
||||
max_retries = 5
|
||||
|
||||
switch_success,switch_success_pg,search_success = self.action_init( driver, country_name, shop_name, risk_listing_filter)
|
||||
# 如果切换失败,直接返回
|
||||
if not switch_success:
|
||||
error_message = f"切换到国家 {country_name} 失败,已重试 {max_retries} 次,跳过该国家"
|
||||
if not switch_success or not switch_success_pg or not search_success:
|
||||
error_message = f"切换到国家({switch_success})/库存页面({switch_success_pg})/搜索筛选到指定选项({search_success}) {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
|
||||
|
||||
# 搜索需要审批的商品,最多重试3次
|
||||
sku_ls = []
|
||||
for retry in range(max_retries):
|
||||
try:
|
||||
self.log(f"尝试搜索匹配操作商品 (第 {retry + 1}/{max_retries} 次)")
|
||||
sku_ls = driver.search(filter_type=risk_listing_filter)
|
||||
break
|
||||
except Exception as e:
|
||||
self.log(f"搜索商品异常: {str(e)}", "ERROR")
|
||||
if retry < max_retries - 1:
|
||||
try:
|
||||
driver.tab.refresh()
|
||||
time.sleep(3)
|
||||
except Exception as refresh_error:
|
||||
self.log(f"刷新页面失败: {str(refresh_error)}", "WARNING")
|
||||
# sku_ls = []
|
||||
# for retry in range(max_retries):
|
||||
# try:
|
||||
# self.log(f"尝试搜索匹配操作商品 (第 {retry + 1}/{max_retries} 次)")
|
||||
# sku_ls = driver.search(filter_type=risk_listing_filter)
|
||||
# break
|
||||
# except Exception as e:
|
||||
# self.log(f"搜索商品异常: {str(e)}", "ERROR")
|
||||
# if retry < max_retries - 1:
|
||||
# try:
|
||||
# driver.tab.refresh()
|
||||
# driver.tab.wait.doc_loaded()
|
||||
# time.sleep(3)
|
||||
# except Exception as refresh_error:
|
||||
# self.log(f"刷新页面失败: {str(refresh_error)}", "WARNING")
|
||||
#
|
||||
# # 如果没有需要审批的商品,直接返回
|
||||
# if len(sku_ls) == 0:
|
||||
# self.log(f"国家 {country_name} 没有搜索出的商品")
|
||||
# if task_id in runing_task:
|
||||
# runing_task[task_id]["processed_countries"] += 1
|
||||
# return
|
||||
|
||||
# 如果没有需要审批的商品,直接返回
|
||||
if len(sku_ls) == 0:
|
||||
self.log(f"国家 {country_name} 没有搜索出的商品")
|
||||
if task_id in runing_task:
|
||||
runing_task[task_id]["processed_countries"] += 1
|
||||
return
|
||||
|
||||
self.log(f"国家 {country_name} 搜索出 {len(sku_ls)} 商品,开始处理...")
|
||||
self.log(f"国家 {country_name} ,开始处理...")
|
||||
|
||||
# 处理所有需要审批的商品(通过yield获取结果)
|
||||
|
||||
@@ -1350,13 +1362,14 @@ class PriceTask:
|
||||
"firstPlace": status.get("firstPlace") if status.get("firstPlace") else "",
|
||||
"secondPlace": status.get("secondPlace") if status.get("secondPlace") else "",
|
||||
"cartShopName": status.get("cartShopName") if status.get("cartShopName") else "",
|
||||
"priceChangeStatus": "UPDATED",
|
||||
# "priceChangeStatus": "UPDATED",
|
||||
"deleteSkipAsin": status.get("deleteSkipAsin",False),
|
||||
"removeAsin": status.get("removeAsin") if status.get("removeAsin") else "",
|
||||
"modifyCount": "1",
|
||||
"shippingFee" : status.get("shippingFee") if status.get("shippingFee") else "",
|
||||
"status": status.get("statu"),
|
||||
"priceChangeStatus" : status.get("priceChangeStatus") if status.get("priceChangeStatus") else "",
|
||||
"priceChangePrice" : status.get("priceChangePrice") if status.get("priceChangePrice") else "",
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1405,20 +1418,20 @@ class PriceTask:
|
||||
if __name__ == '__main__':
|
||||
# 使用示例
|
||||
# user_info = {
|
||||
# "company": "rongchuang123",
|
||||
# "company": "尾号5578的公司115",
|
||||
# "username": "自动化_Robot",
|
||||
# "password": "#20zsg25"
|
||||
# }
|
||||
# shop_name = "郭亚芳"
|
||||
# country = "德国"
|
||||
# shop_name = "刘建煌"
|
||||
# country = "法国"
|
||||
# kill_process('v6')
|
||||
# driver = AmzonePriceMatch(user_info)
|
||||
# browser = driver.open_shop(shop_name)
|
||||
# sw_suc = driver.SwitchingCountries(country)
|
||||
# driver.SwitchPage()
|
||||
# risk_listing_filter = "Active"
|
||||
# _shopMallName = "yafang123"
|
||||
# ap_asin ="B0BTHBJDKG"
|
||||
# _shopMallName = "Jianhuang 888"
|
||||
# ap_asin ="B0F25Y52PH"
|
||||
# skip_asin = []
|
||||
# for _ in range(3):
|
||||
# try:
|
||||
@@ -1436,14 +1449,19 @@ if __name__ == '__main__':
|
||||
# print(f"ASIN {asin} 的处理结果: {status}")
|
||||
# print("已完成操作")
|
||||
|
||||
front_end_data = {'top_sellers': [{'rank': 1, 'price': '50.24', 'stock': '26', 'shop_name': 'yafang123'}, {'rank': 2, 'price': '44.35', 'stock': '100', 'shop_name': 'QinPimy'}], 'cart_seller': 'Amazon US', 'timestamp': '2026-04-27 16:17:42'}
|
||||
front_end_data = {'top_sellers': [{'rank': 1, 'price': '52.51', 'stock': '100', 'shop_name': 'Amazon US'}, {'rank': 2, 'price': '62.58', 'stock': '30', 'shop_name': 'Amazon US'}], 'cart_seller': 'Jianhuang 888', 'timestamp': '2026-04-30 09:52:49'}
|
||||
|
||||
current_Price = 44.35
|
||||
current_shop_name = "yafang123"
|
||||
recommended_price = 44.35
|
||||
current_Price = 52.51
|
||||
current_shop_name = "Jianhuang 888"
|
||||
recommended_price = 52.51
|
||||
recommended_shipping =0.0
|
||||
res = calculate_target_price(
|
||||
front_end_data, current_Price, current_shop_name,
|
||||
recommended_price, recommended_shipping
|
||||
)
|
||||
print(res)
|
||||
print(res)
|
||||
|
||||
|
||||
# task_data = {'type': 'price-track-run', 'ts': 1777388166302, 'data': {'task_id': 4892, 'result_id': 6685, 'shop_name': '魏振峰', 'shop_id': '27543917795757', 'platform': '亚马逊', 'company_name': 'rongchuang123', 'shop_mall_name': 'WEIZHENFENG168', 'matched': True, 'match_status': 'MATCHED', 'match_message': None, 'success': False, 'error': None, 'output_filename': None, 'download_url': None, 'task_status': 'RUNNING', 'loop_run_id': 168, 'round_index': 1, 'country_codes': ['DE', 'UK', 'FR', 'IT', 'ES'], 'mode': 'asin', 'skip_asins': {}, 'skip_asins_by_country': {}, 'skip_asin_details_by_country': {}, 'minimum_price_by_country_and_asin': {'DE': {'B0CFY5M3XJ': '38', 'B0DSJGJBWV': '9'}, 'UK': {'B0G2LPSGP5': '30', 'B0G1LY2K8L': '10'}}, 'skip_asin_delete_policy': {'enabled': False, 'mode': 'NONE', 'deleteWhenPriceBelowMinimum': False, 'compareField': 'price', 'thresholdField': 'minimumPrice', 'target': 'skip_asin', 'source': 'frontend-price-track'}, 'delete_skip_asin_when_price_below_minimum': False, 'deleteSkipAsinWhenPriceBelowMinimum': False, 'asin_rows_by_country': {'DE': [{'shopMallName': '', 'asin': 'B0CFY5M3XJ', 'price': '', 'recommendedPrice': '', 'minimumPrice': '38', 'firstPlace': '', 'secondPlace': '', 'cartShopName': '', 'priceChangeStatus': '', 'modifyCount': '', 'status': ''}, {'shopMallName': '', 'asin': 'B0DSJGJBWV', 'price': '', 'recommendedPrice': '', 'minimumPrice': '9', 'firstPlace': '', 'secondPlace': '', 'cartShopName': '', 'priceChangeStatus': '', 'modifyCount': '', 'status': ''}], 'UK': [{'shopMallName': '', 'asin': 'B0G2LPSGP5', 'price': '', 'recommendedPrice': '', 'minimumPrice': '30', 'firstPlace': '', 'secondPlace': '', 'cartShopName': '', 'priceChangeStatus': '', 'modifyCount': '', 'status': ''}, {'shopMallName': '', 'asin': 'B0G1LY2K8L', 'price': '', 'recommendedPrice': '', 'minimumPrice': '10', 'firstPlace': '', 'secondPlace': '', 'cartShopName': '', 'priceChangeStatus': '', 'modifyCount': '', 'status': ''}]}, 'taskId': 4892, 'resultId': 6685, 'shopName': '魏振峰', 'shopId': '27543917795757', 'platformName': '亚马逊', 'companyName': 'rongchuang123', 'shopMallName': 'WEIZHENFENG168', 'matchStatus': 'MATCHED', 'matchMessage': None, 'outputFilename': None, 'downloadUrl': None, 'taskStatus': 'RUNNING', 'loopRunId': 168, 'roundIndex': 1}}
|
||||
# price_cls = PriceTask(user_info)
|
||||
# price_cls.process_task(task_data)
|
||||
@@ -195,10 +195,13 @@ def proxy(path):
|
||||
if key.lower() in ['host', 'content-length', 'connection']:
|
||||
continue
|
||||
headers[key] = value
|
||||
ignore_url = [f"{JAVA_API_BASE}/api/delete-brand/tasks/batch",
|
||||
f"{JAVA_API_BASE}/api/delete-brand/history",
|
||||
f"{JAVA_API_BASE}/api/product-risk-resolve/tasks/batch",
|
||||
]
|
||||
ignore_url = [f"{JAVA_API_BASE}/api/delete-brand/tasks/batch",
|
||||
f"{JAVA_API_BASE}/api/delete-brand/tasks/progress/batch",
|
||||
f"{JAVA_API_BASE}/api/delete-brand/history",
|
||||
f"{JAVA_API_BASE}/api/product-risk-resolve/tasks/batch",
|
||||
f"{JAVA_API_BASE}/api/product-risk-resolve/tasks/progress/batch",
|
||||
]
|
||||
proxy_timeout = 180 if target_url.endswith("/api/delete-brand/run") else 30
|
||||
if target_url not in ignore_url:
|
||||
try:
|
||||
print("=============================")
|
||||
@@ -219,7 +222,7 @@ def proxy(path):
|
||||
data=request.get_data() if request.get_data() else None,
|
||||
cookies=request.cookies,
|
||||
stream=True, # 启用流式传输
|
||||
timeout=30
|
||||
timeout=proxy_timeout
|
||||
)
|
||||
|
||||
# 流式响应
|
||||
|
||||
@@ -438,7 +438,7 @@ def main():
|
||||
window.expose(api.close, api.minimize, api.maximize, api.toggle_maximize, generate_images, api.save_image, api.save_image_to_folder, api.select_folder, api.select_brand_xlsx_files, api.select_brand_folder,
|
||||
api.save_file_from_url, api.save_template_xlsx,api.save_template_zip,api.upload_file_to_java,api.open_external_url,api.enqueue_json,api.get_detail_del,api.save_file_from_url_new)
|
||||
webview.start(
|
||||
debug=False,
|
||||
debug=True,
|
||||
storage_path=cache_path,
|
||||
private_mode=False
|
||||
)
|
||||
|
||||
@@ -25,8 +25,19 @@
|
||||
<hutool.version>5.8.36</hutool.version>
|
||||
<rocketmq-spring.version>2.3.5</rocketmq-spring.version>
|
||||
<minio.version>8.5.17</minio.version>
|
||||
<javassist.version>3.28.0-GA</javassist.version>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.javassist</groupId>
|
||||
<artifactId>javassist</artifactId>
|
||||
<version>${javassist.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
|
||||
@@ -12,4 +12,8 @@ public class TransientStorageProperties {
|
||||
private String accessKeyId;
|
||||
private String accessKeySecret;
|
||||
private String region = "us-east-1";
|
||||
private int connectTimeoutSeconds = 10;
|
||||
private int readTimeoutSeconds = 60;
|
||||
private int writeTimeoutSeconds = 60;
|
||||
private int uploadMaxRetries = 3;
|
||||
}
|
||||
|
||||
@@ -649,7 +649,7 @@ public class AppearancePatentCozeClient {
|
||||
}
|
||||
|
||||
private String normalize(String value) {
|
||||
return value == null ? "" : value.replace("\ufeff", "").replace("\u3000", " ").trim();
|
||||
return value == null ? "" : value.replace(String.valueOf((char) 0xFEFF), "").replace((char) 0x3000, ' ').trim();
|
||||
}
|
||||
|
||||
private String rowKey(AppearancePatentResultRowDto row) {
|
||||
|
||||
@@ -83,7 +83,10 @@ public class AppearancePatentController {
|
||||
public ApiResponse<Void> result(
|
||||
@Parameter(description = "外观专利检测任务 ID。任务必须处于 RUNNING 状态。", required = true, example = "3938")
|
||||
@PathVariable Long taskId,
|
||||
@Valid @RequestBody AppearancePatentSubmitResultRequest request) {
|
||||
@Valid @RequestBody AppearancePatentSubmitResultRequest request,
|
||||
jakarta.servlet.http.HttpServletResponse response) {
|
||||
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
service.submitResult(taskId, request);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ public class AppearancePatentTaskService {
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
if (sourceFiles.isEmpty()) {
|
||||
throw new BusinessException("璇峰厛涓婁紶 Excel 鏂囦欢");
|
||||
throw new BusinessException("请先上传 Excel 文件");
|
||||
}
|
||||
List<AppearancePatentParsedRowVo> allRows = new ArrayList<>();
|
||||
List<String> mergedHeaders = new ArrayList<>();
|
||||
@@ -461,10 +461,10 @@ public class AppearancePatentTaskService {
|
||||
private SubmitContext persistSubmittedChunk(Long taskId, AppearancePatentSubmitResultRequest request) {
|
||||
FileTaskEntity task = fileTaskMapper.selectById(taskId);
|
||||
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
||||
throw new BusinessException("浠诲姟涓嶅瓨鍦?");
|
||||
throw new BusinessException("任务不存在");
|
||||
}
|
||||
if (!STATUS_RUNNING.equals(task.getStatus())) {
|
||||
throw new BusinessException("浠诲姟涓嶆槸杩愯涓姸鎬?");
|
||||
throw new BusinessException("任务不是运行中状态");
|
||||
}
|
||||
|
||||
int chunkIndex = request.getChunkIndex() == null ? 0 : request.getChunkIndex();
|
||||
@@ -482,7 +482,7 @@ public class AppearancePatentTaskService {
|
||||
.last("limit 1"));
|
||||
if (existing == null) {
|
||||
List<AppearancePatentResultRowDto> rawRows = flattenSubmittedRows(request);
|
||||
String payloadJson = writeJson(rawRows, "缁撴灉搴忓垪鍖栧け璐?");
|
||||
String payloadJson = writeJson(rawRows, "结果序列化失败");
|
||||
|
||||
TaskChunkEntity chunk = new TaskChunkEntity();
|
||||
chunk.setTaskId(taskId);
|
||||
@@ -514,7 +514,7 @@ public class AppearancePatentTaskService {
|
||||
private void completeSubmittedChunk(SubmitContext context) {
|
||||
FileTaskEntity task = fileTaskMapper.selectById(context.task().getId());
|
||||
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
||||
throw new BusinessException("浠诲姟涓嶅瓨鍦?");
|
||||
throw new BusinessException("任务不存在");
|
||||
}
|
||||
if (!STATUS_RUNNING.equals(task.getStatus())) {
|
||||
log.info("[appearance-patent] skip completion because task already finalized taskId={} status={}",
|
||||
@@ -1379,7 +1379,7 @@ public class AppearancePatentTaskService {
|
||||
}
|
||||
|
||||
private String normalize(String val) {
|
||||
return val == null ? "" : val.replace("\ufeff", "").replace("\u3000", " ").trim().replaceAll("\\s+", " ");
|
||||
return val == null ? "" : val.replace(String.valueOf((char) 0xFEFF), "").replace((char) 0x3000, ' ').trim().replaceAll("\\s+", " ");
|
||||
}
|
||||
|
||||
private String buildParsedPayloadJson(String aiPrompt, List<AppearancePatentSourceFileDto> sourceFiles, List<String> headers, List<AppearancePatentParsedGroupVo> groups, List<AppearancePatentParsedRowVo> allRows) {
|
||||
|
||||
@@ -881,8 +881,8 @@ public class BrandTaskService {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
return value.replace("\ufeff", "")
|
||||
.replace("\u3000", " ")
|
||||
return value.replace(String.valueOf((char) 0xFEFF), "")
|
||||
.replace((char) 0x3000, ' ')
|
||||
.replace("\r\n", " ")
|
||||
.replace("\r", " ")
|
||||
.replace("\n", " ")
|
||||
|
||||
@@ -475,8 +475,8 @@ public class ConvertRunService {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
return value.replace("\ufeff", "")
|
||||
.replace("\u3000", " ")
|
||||
return value.replace(String.valueOf((char) 0xFEFF), "")
|
||||
.replace((char) 0x3000, ' ')
|
||||
.replace("\r\n", " ")
|
||||
.replace("\r", " ")
|
||||
.replace("\n", " ")
|
||||
|
||||
@@ -47,8 +47,8 @@ import java.util.zip.ZipOutputStream;
|
||||
@Slf4j
|
||||
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 static final String HEADER_SPLIT_MARKER = "idASIN国家状态价格变体数量";
|
||||
private static final String HEADER_STOP_COLUMN = "缩略图地址8";
|
||||
|
||||
private final FileTaskMapper fileTaskMapper;
|
||||
private final FileResultMapper fileResultMapper;
|
||||
@@ -59,7 +59,7 @@ public class DedupeRunService {
|
||||
public DedupeRunVo run(DedupeRunRequest request) {
|
||||
long runStartNs = System.nanoTime();
|
||||
if (!request.isKeepIntegerIds() && !request.isKeepUnderscoreIds() && !request.isKeepIntegerMainIdsWhenNoSubIds()) {
|
||||
throw new BusinessException("\u8bf7\u81f3\u5c11\u9009\u62e9\u4e00\u79cd ID \u4fdd\u7559\u89c4\u5219");
|
||||
throw new BusinessException("请至少选择一种 ID 保留规则");
|
||||
}
|
||||
|
||||
FileTaskEntity task = new FileTaskEntity();
|
||||
@@ -90,7 +90,7 @@ public class DedupeRunService {
|
||||
File inputFile = findLocalSourceFile(sourceFile.getFileKey());
|
||||
long findFileNs = elapsedNs(fileStartNs);
|
||||
if (inputFile == null || !inputFile.exists()) {
|
||||
throw new BusinessException("\u4e0a\u4f20\u6587\u4ef6\u4e0d\u5b58\u5728\uff0c\u8bf7\u91cd\u65b0\u4e0a\u4f20");
|
||||
throw new BusinessException("上传文件不存在,请重新上传");
|
||||
}
|
||||
|
||||
String inputName = sourceFile.getOriginalFilename() == null ? inputFile.getName() : sourceFile.getOriginalFilename();
|
||||
@@ -240,7 +240,7 @@ public class DedupeRunService {
|
||||
public void deleteHistory(Long resultId, Long userId) {
|
||||
FileResultEntity entity = fileResultMapper.selectById(resultId);
|
||||
if (entity == null || !"DEDUPE".equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
|
||||
throw new BusinessException("\u8bb0\u5f55\u4e0d\u5b58\u5728");
|
||||
throw new BusinessException("记录不存在");
|
||||
}
|
||||
fileResultMapper.deleteById(resultId);
|
||||
}
|
||||
@@ -248,11 +248,11 @@ public class DedupeRunService {
|
||||
public Resource getResultFile(Long resultId, Long userId) {
|
||||
FileResultEntity entity = fileResultMapper.selectById(resultId);
|
||||
if (entity == null || entity.getResultFileUrl() == null || !"DEDUPE".equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
|
||||
throw new BusinessException("\u7ed3\u679c\u6587\u4ef6\u4e0d\u5b58\u5728");
|
||||
throw new BusinessException("结果文件不存在");
|
||||
}
|
||||
File file = new File(entity.getResultFileUrl());
|
||||
if (!file.exists()) {
|
||||
throw new BusinessException("\u7ed3\u679c\u6587\u4ef6\u4e0d\u5b58\u5728");
|
||||
throw new BusinessException("结果文件不存在");
|
||||
}
|
||||
return new FileSystemResource(file);
|
||||
}
|
||||
@@ -342,18 +342,18 @@ public class DedupeRunService {
|
||||
Sheet sheet = workbook.getSheetAt(0);
|
||||
Row headerRow = sheet.getRow(0);
|
||||
if (headerRow == null) {
|
||||
throw new BusinessException("Excel \u8868\u5934\u4e3a\u7a7a");
|
||||
throw new BusinessException("Excel 表头为空");
|
||||
}
|
||||
|
||||
Map<String, Integer> headerMap = buildHeaderMap(headerRow, formatter);
|
||||
if (headerMap.isEmpty()) {
|
||||
throw new BusinessException("Excel \u8868\u5934\u4e3a\u7a7a");
|
||||
throw new BusinessException("Excel 表头为空");
|
||||
}
|
||||
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));
|
||||
throw new BusinessException("缺少列:" + String.join("、", missing));
|
||||
}
|
||||
|
||||
List<Integer> selectedIndexes = new ArrayList<>(selectedColumns.size());
|
||||
@@ -478,7 +478,7 @@ public class DedupeRunService {
|
||||
zos.closeEntry();
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
throw new BusinessException("\u53bb\u91cd\u7ed3\u679c\u6253\u5305\u5931\u8d25\uff1a" + ex.getMessage());
|
||||
throw new BusinessException("去重结果打包失败:" + ex.getMessage());
|
||||
}
|
||||
return zipFile;
|
||||
}
|
||||
@@ -602,7 +602,7 @@ public class DedupeRunService {
|
||||
boolean previousWhitespace = false;
|
||||
for (int i = start; i < end; i++) {
|
||||
char ch = value.charAt(i);
|
||||
if (ch == '\uFEFF') {
|
||||
if (ch == 0xFEFF) {
|
||||
if (builder == null) {
|
||||
builder = new StringBuilder(value.length());
|
||||
builder.append(value, start, i);
|
||||
@@ -629,11 +629,11 @@ public class DedupeRunService {
|
||||
}
|
||||
|
||||
private boolean isTrimmedWhitespace(char ch) {
|
||||
return ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n' || ch == '\u3000' || Character.isWhitespace(ch);
|
||||
return ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n' || ch == 0x3000 || Character.isWhitespace(ch);
|
||||
}
|
||||
|
||||
private boolean isNormalizedWhitespace(char ch) {
|
||||
return ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n' || ch == '\u3000' || Character.isWhitespace(ch);
|
||||
return ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n' || ch == 0x3000 || Character.isWhitespace(ch);
|
||||
}
|
||||
|
||||
private record DedupeArchiveEntry(String relativePath, String inputName, File outputFile) {
|
||||
|
||||
@@ -240,7 +240,10 @@ public class DeleteBrandTaskStorageService {
|
||||
DeleteBrandResultFileDto dto = objectMapper.readValue(payloadJson, DeleteBrandResultFileDto.class);
|
||||
grouped.computeIfAbsent(entity.getScopeKey(), ignored -> new ArrayList<>()).add(dto);
|
||||
} catch (Exception ex) {
|
||||
throw new BusinessException("failed to load delete-brand result chunks");
|
||||
log.warn("[delete-brand-storage] failed to load result chunk taskId={} scopeKey={} chunkIndex={} payload={}",
|
||||
taskId, entity.getScopeKey(), entity.getChunkIndex(), entity.getPayloadJson(), ex);
|
||||
throw new BusinessException("failed to load delete-brand result chunks: taskId="
|
||||
+ taskId + ", scopeKey=" + entity.getScopeKey() + ", chunkIndex=" + entity.getChunkIndex());
|
||||
}
|
||||
}
|
||||
for (List<DeleteBrandResultFileDto> chunks : grouped.values()) {
|
||||
|
||||
@@ -109,8 +109,8 @@ public class LocalFileStorageService {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
return value.replace("\ufeff", "")
|
||||
.replace("\u3000", " ")
|
||||
return value.replace(String.valueOf((char) 0xFEFF), "")
|
||||
.replace((char) 0x3000, ' ')
|
||||
.replace("\r\n", " ")
|
||||
.replace("\r", " ")
|
||||
.replace("\n", " ")
|
||||
|
||||
@@ -5,18 +5,24 @@ import io.minio.GetObjectArgs;
|
||||
import io.minio.MinioClient;
|
||||
import io.minio.PutObjectArgs;
|
||||
import io.minio.RemoveObjectArgs;
|
||||
import io.minio.StatObjectArgs;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import okhttp3.OkHttpClient;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class RustfsObjectStorageService {
|
||||
|
||||
private final TransientStorageProperties properties;
|
||||
private volatile OkHttpClient httpClient;
|
||||
|
||||
public boolean isConfigured() {
|
||||
return notBlank(properties.getEndpoint())
|
||||
@@ -29,18 +35,28 @@ public class RustfsObjectStorageService {
|
||||
if (!isConfigured()) {
|
||||
throw new IllegalStateException("transient storage is not configured");
|
||||
}
|
||||
try (ByteArrayInputStream stream = new ByteArrayInputStream(
|
||||
Objects.requireNonNullElse(content, "").getBytes(StandardCharsets.UTF_8))) {
|
||||
buildClient().putObject(PutObjectArgs.builder()
|
||||
.bucket(properties.getBucket())
|
||||
.object(objectKey)
|
||||
.stream(stream, stream.available(), -1)
|
||||
.contentType("application/json")
|
||||
.build());
|
||||
return objectKey;
|
||||
} catch (Exception ex) {
|
||||
throw new IllegalStateException("failed to upload payload to transient storage", ex);
|
||||
byte[] bytes = Objects.requireNonNullElse(content, "").getBytes(StandardCharsets.UTF_8);
|
||||
Exception last = null;
|
||||
int maxRetries = Math.max(1, properties.getUploadMaxRetries());
|
||||
for (int attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try (ByteArrayInputStream stream = new ByteArrayInputStream(bytes)) {
|
||||
buildClient().putObject(PutObjectArgs.builder()
|
||||
.bucket(properties.getBucket())
|
||||
.object(objectKey)
|
||||
.stream(stream, bytes.length, -1)
|
||||
.contentType("application/json")
|
||||
.build());
|
||||
verifyObjectVisible(objectKey);
|
||||
return objectKey;
|
||||
} catch (Exception ex) {
|
||||
last = ex;
|
||||
if (attempt < maxRetries) {
|
||||
log.warn("[rustfs] upload failed, retrying objectKey={} attempt={}/{}", objectKey, attempt, maxRetries, ex);
|
||||
sleepQuietly(500L * attempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("failed to upload payload to transient storage", last);
|
||||
}
|
||||
|
||||
public String readObjectAsString(String objectKey) {
|
||||
@@ -76,9 +92,57 @@ public class RustfsObjectStorageService {
|
||||
.endpoint(properties.getEndpoint())
|
||||
.credentials(properties.getAccessKeyId(), properties.getAccessKeySecret())
|
||||
.region(properties.getRegion())
|
||||
.httpClient(getHttpClient())
|
||||
.build();
|
||||
}
|
||||
|
||||
private OkHttpClient getHttpClient() {
|
||||
OkHttpClient current = httpClient;
|
||||
if (current != null) {
|
||||
return current;
|
||||
}
|
||||
synchronized (this) {
|
||||
if (httpClient == null) {
|
||||
httpClient = new OkHttpClient.Builder()
|
||||
.connectTimeout(Math.max(1, properties.getConnectTimeoutSeconds()), TimeUnit.SECONDS)
|
||||
.readTimeout(Math.max(1, properties.getReadTimeoutSeconds()), TimeUnit.SECONDS)
|
||||
.writeTimeout(Math.max(1, properties.getWriteTimeoutSeconds()), TimeUnit.SECONDS)
|
||||
.build();
|
||||
}
|
||||
return httpClient;
|
||||
}
|
||||
}
|
||||
|
||||
private void verifyObjectVisible(String objectKey) {
|
||||
RuntimeException last = null;
|
||||
for (int attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
buildClient().statObject(StatObjectArgs.builder()
|
||||
.bucket(properties.getBucket())
|
||||
.object(objectKey)
|
||||
.build());
|
||||
return;
|
||||
} catch (Exception ex) {
|
||||
last = new IllegalStateException("transient payload is not visible after upload: " + objectKey, ex);
|
||||
if (attempt < 3) {
|
||||
sleepQuietly(100L * attempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw last == null
|
||||
? new IllegalStateException("transient payload is not visible after upload: " + objectKey)
|
||||
: last;
|
||||
}
|
||||
|
||||
private void sleepQuietly(long millis) {
|
||||
try {
|
||||
Thread.sleep(millis);
|
||||
} catch (InterruptedException ex) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException("interrupted while verifying transient payload upload", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean notBlank(String value) {
|
||||
return value != null && !value.isBlank();
|
||||
}
|
||||
|
||||
@@ -281,7 +281,6 @@ public class PatrolDeleteTaskService {
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void submitResult(Long taskId, PatrolDeleteSubmitResultRequest request) {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
throw new BusinessException("taskId 不合法");
|
||||
@@ -334,7 +333,6 @@ public class PatrolDeleteTaskService {
|
||||
tryFinalizeTask(taskId, false);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public boolean tryFinalizeTask(Long taskId, boolean fromCompensation) {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
return false;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.nanri.aiimage.modules.pricetrack.model.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonAlias;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@@ -88,6 +89,10 @@ public class PriceTrackSubmitResultRequest {
|
||||
@Schema(description = "购物车店铺名", example = "店铺A")
|
||||
private String cartShopName;
|
||||
|
||||
@JsonAlias({"changedPrice", "changePrice", "modifiedPrice", "newPrice", "targetPrice", "updatedPrice", "改价价格"})
|
||||
@Schema(description = "改价价格", example = "18.99")
|
||||
private String priceChangePrice;
|
||||
|
||||
@Schema(description = "改价情况", example = "UPDATED")
|
||||
private String priceChangeStatus;
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ public class PriceTrackExcelAssemblyService {
|
||||
"第一名",
|
||||
"第二名",
|
||||
"购物车店铺名",
|
||||
"改价价格",
|
||||
"改价情况",
|
||||
"修改次数",
|
||||
"状态"
|
||||
@@ -40,13 +41,14 @@ public class PriceTrackExcelAssemblyService {
|
||||
workbook.setCompressTempFiles(true);
|
||||
try (FileOutputStream fos = new FileOutputStream(outputXlsx)) {
|
||||
for (ProductRiskCountryCode code : ProductRiskCountryCode.values()) {
|
||||
String sheetName = code.name();
|
||||
String countryCode = code.name();
|
||||
String sheetName = sheetNameOf(code);
|
||||
Sheet sheet = workbook.createSheet(sheetName);
|
||||
Row headerRow = sheet.createRow(0);
|
||||
for (int i = 0; i < RESULT_HEADER.length; i++) {
|
||||
headerRow.createCell(i).setCellValue(RESULT_HEADER[i]);
|
||||
}
|
||||
List<PriceTrackSubmitResultRequest.AsinResult> rows = safe.getOrDefault(sheetName, List.of());
|
||||
List<PriceTrackSubmitResultRequest.AsinResult> rows = safe.getOrDefault(countryCode, List.of());
|
||||
int rowIdx = 1;
|
||||
for (PriceTrackSubmitResultRequest.AsinResult item : rows) {
|
||||
if (item == null) {
|
||||
@@ -62,9 +64,10 @@ public class PriceTrackExcelAssemblyService {
|
||||
row.createCell(6).setCellValue(valueOf(item.getFirstPlace()));
|
||||
row.createCell(7).setCellValue(valueOf(item.getSecondPlace()));
|
||||
row.createCell(8).setCellValue(valueOf(item.getCartShopName()));
|
||||
row.createCell(9).setCellValue(valueOf(item.getPriceChangeStatus()));
|
||||
row.createCell(10).setCellValue(valueOf(item.getModifyCount()));
|
||||
row.createCell(11).setCellValue(valueOf(item.getStatus()));
|
||||
row.createCell(9).setCellValue(valueOf(item.getPriceChangePrice()));
|
||||
row.createCell(10).setCellValue(valueOf(item.getPriceChangeStatus()));
|
||||
row.createCell(11).setCellValue(valueOf(item.getModifyCount()));
|
||||
row.createCell(12).setCellValue(valueOf(item.getStatus()));
|
||||
}
|
||||
applyDefaultColumnWidths(sheet);
|
||||
}
|
||||
@@ -113,8 +116,18 @@ public class PriceTrackExcelAssemblyService {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
|
||||
private static String sheetNameOf(ProductRiskCountryCode code) {
|
||||
return switch (code) {
|
||||
case DE -> "德国";
|
||||
case FR -> "法国";
|
||||
case ES -> "西班牙";
|
||||
case IT -> "意大利";
|
||||
case UK -> "英国";
|
||||
};
|
||||
}
|
||||
|
||||
private void applyDefaultColumnWidths(Sheet sheet) {
|
||||
int[] widths = {22, 18, 12, 12, 12, 12, 20, 20, 22, 18, 12, 12};
|
||||
int[] widths = {22, 18, 12, 12, 12, 12, 20, 20, 22, 14, 18, 12, 12};
|
||||
for (int i = 0; i < widths.length; i++) {
|
||||
sheet.setColumnWidth(i, widths[i] * 256);
|
||||
}
|
||||
|
||||
@@ -127,6 +127,21 @@ public class PriceTrackTaskService {
|
||||
return tasks;
|
||||
}
|
||||
|
||||
private List<FileTaskEntity> selectTaskStatusesByIdsInBatches(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.selectList(new LambdaQueryWrapper<FileTaskEntity>()
|
||||
.select(FileTaskEntity::getId, FileTaskEntity::getStatus)
|
||||
.in(FileTaskEntity::getId, taskIds.subList(start, end))));
|
||||
}
|
||||
return tasks;
|
||||
}
|
||||
|
||||
public PriceTrackDashboardVo dashboard(Long userId) {
|
||||
if (userId == null || userId <= 0) throw new BusinessException("user_id 不合法");
|
||||
PriceTrackDashboardVo vo = new PriceTrackDashboardVo();
|
||||
@@ -166,7 +181,7 @@ public class PriceTrackTaskService {
|
||||
.distinct()
|
||||
.toList();
|
||||
if (!taskIds.isEmpty()) {
|
||||
List<FileTaskEntity> tasks = selectTasksByIdsInBatches(taskIds);
|
||||
List<FileTaskEntity> tasks = selectTaskStatusesByIdsInBatches(taskIds);
|
||||
for (FileTaskEntity t : tasks) {
|
||||
if (t != null) statusByTaskId.put(t.getId(), t.getStatus());
|
||||
}
|
||||
@@ -383,7 +398,6 @@ public class PriceTrackTaskService {
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void submitResult(Long taskId, PriceTrackSubmitResultRequest request) {
|
||||
if (taskId == null || taskId <= 0) throw new BusinessException("taskId 不合法");
|
||||
if (request == null || request.getShops() == null || request.getShops().isEmpty()) {
|
||||
@@ -458,7 +472,6 @@ public class PriceTrackTaskService {
|
||||
updateTaskStatusFromLatestRows(task, latest);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public boolean tryFinalizeTask(Long taskId, boolean fromCompensation) {
|
||||
if (taskId == null || taskId <= 0) return false;
|
||||
FileTaskEntity task = loadTaskForExecution(taskId);
|
||||
@@ -1139,6 +1152,7 @@ public class PriceTrackTaskService {
|
||||
merged.setFirstPlace(firstNonBlank(incoming.getFirstPlace(), merged.getFirstPlace()));
|
||||
merged.setSecondPlace(firstNonBlank(incoming.getSecondPlace(), merged.getSecondPlace()));
|
||||
merged.setCartShopName(firstNonBlank(incoming.getCartShopName(), merged.getCartShopName()));
|
||||
merged.setPriceChangePrice(firstNonBlank(incoming.getPriceChangePrice(), merged.getPriceChangePrice()));
|
||||
merged.setPriceChangeStatus(firstNonBlank(incoming.getPriceChangeStatus(), merged.getPriceChangeStatus()));
|
||||
merged.setModifyCount(firstNonBlank(incoming.getModifyCount(), merged.getModifyCount()));
|
||||
merged.setStatus(firstNonBlank(incoming.getStatus(), merged.getStatus()));
|
||||
@@ -1163,6 +1177,7 @@ public class PriceTrackTaskService {
|
||||
out.setFirstPlace(row.getFirstPlace());
|
||||
out.setSecondPlace(row.getSecondPlace());
|
||||
out.setCartShopName(row.getCartShopName());
|
||||
out.setPriceChangePrice(row.getPriceChangePrice());
|
||||
out.setPriceChangeStatus(row.getPriceChangeStatus());
|
||||
out.setModifyCount(row.getModifyCount());
|
||||
out.setStatus(row.getStatus());
|
||||
|
||||
@@ -124,6 +124,21 @@ public class ProductRiskTaskService {
|
||||
return tasks;
|
||||
}
|
||||
|
||||
private List<FileTaskEntity> selectTaskStatusesByIdsInBatches(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.selectList(new LambdaQueryWrapper<FileTaskEntity>()
|
||||
.select(FileTaskEntity::getId, FileTaskEntity::getStatus)
|
||||
.in(FileTaskEntity::getId, taskIds.subList(start, end))));
|
||||
}
|
||||
return tasks;
|
||||
}
|
||||
|
||||
public ProductRiskDashboardVo dashboard(Long userId) {
|
||||
if (userId == null || userId <= 0) {
|
||||
throw new BusinessException("user_id 不合法");
|
||||
@@ -166,7 +181,7 @@ public class ProductRiskTaskService {
|
||||
.distinct()
|
||||
.toList();
|
||||
if (!taskIds.isEmpty()) {
|
||||
List<FileTaskEntity> tasks = selectTasksByIdsInBatches(taskIds);
|
||||
List<FileTaskEntity> tasks = selectTaskStatusesByIdsInBatches(taskIds);
|
||||
for (FileTaskEntity t : tasks) {
|
||||
if (t != null) {
|
||||
statusByTaskId.put(t.getId(), t.getStatus());
|
||||
@@ -465,7 +480,6 @@ public class ProductRiskTaskService {
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void submitResult(Long taskId, ProductRiskSubmitResultRequest request) {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
throw new BusinessException("taskId 不合法");
|
||||
@@ -630,7 +644,6 @@ public class ProductRiskTaskService {
|
||||
cleanupTaskCacheIfTerminal(taskId, task.getStatus());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public boolean tryFinalizeTask(Long taskId, boolean fromCompensation) {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
return false;
|
||||
|
||||
@@ -287,7 +287,6 @@ public class QueryAsinTaskService {
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void submitResult(Long taskId, QueryAsinSubmitResultRequest request) {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
throw new BusinessException("taskId 不合法");
|
||||
@@ -340,7 +339,6 @@ public class QueryAsinTaskService {
|
||||
tryFinalizeTask(taskId, false);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public boolean tryFinalizeTask(Long taskId, boolean fromCompensation) {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
return false;
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.nanri.aiimage.modules.shopkey.controller;
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.modules.shopkey.model.dto.SkipPriceAsinCountryUpdateRequest;
|
||||
import com.nanri.aiimage.modules.shopkey.model.dto.SkipPriceAsinCreateRequest;
|
||||
import com.nanri.aiimage.modules.shopkey.model.vo.QueryAsinImportProgressVo;
|
||||
import com.nanri.aiimage.modules.shopkey.model.vo.QueryAsinImportStartVo;
|
||||
import com.nanri.aiimage.modules.shopkey.model.vo.SkipPriceAsinItemVo;
|
||||
import com.nanri.aiimage.modules.shopkey.model.vo.SkipPriceAsinPageVo;
|
||||
import com.nanri.aiimage.modules.shopkey.service.SkipPriceAsinService;
|
||||
@@ -20,6 +22,7 @@ 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
|
||||
@@ -59,6 +62,40 @@ public class SkipPriceAsinController {
|
||||
skipPriceAsinService.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 = "页面选择的分组ID", required = true) @RequestParam Long groupId,
|
||||
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId,
|
||||
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) {
|
||||
return ApiResponse.success("开始导入",
|
||||
skipPriceAsinService.startImport(file, groupId, operatorId, Boolean.TRUE.equals(superAdmin)));
|
||||
}
|
||||
|
||||
@GetMapping("/import/{importId}")
|
||||
@Operation(summary = "查询导入新增进度")
|
||||
public ApiResponse<QueryAsinImportProgressVo> importProgress(@PathVariable String importId) {
|
||||
return ApiResponse.success(skipPriceAsinService.getImportProgress(importId));
|
||||
}
|
||||
|
||||
@PostMapping("/delete-import")
|
||||
@Operation(summary = "导入删除跳过跟价 ASIN")
|
||||
public ApiResponse<QueryAsinImportStartVo> deleteImportExcel(
|
||||
@Parameter(description = "xlsx/xls 文件,文件名必须等于店铺名", required = true) @RequestParam("file") MultipartFile file,
|
||||
@Parameter(description = "页面选择的分组ID", required = true) @RequestParam Long groupId,
|
||||
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId,
|
||||
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) {
|
||||
return ApiResponse.success("开始删除",
|
||||
skipPriceAsinService.startDeleteImport(file, groupId, operatorId, Boolean.TRUE.equals(superAdmin)));
|
||||
}
|
||||
|
||||
@GetMapping("/delete-import/{importId}")
|
||||
@Operation(summary = "查询导入删除进度")
|
||||
public ApiResponse<QueryAsinImportProgressVo> deleteImportProgress(@PathVariable String importId) {
|
||||
return ApiResponse.success(skipPriceAsinService.getDeleteImportProgress(importId));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/countries/{country}")
|
||||
@Operation(summary = "编辑指定国家的跳过跟价 ASIN")
|
||||
public ApiResponse<SkipPriceAsinItemVo> updateCountry(
|
||||
|
||||
@@ -3,7 +3,68 @@ package com.nanri.aiimage.modules.shopkey.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageGroupEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface ShopManageGroupMapper extends BaseMapper<ShopManageGroupEntity> {
|
||||
|
||||
@Select("""
|
||||
SELECT DISTINCT accessible_group_id
|
||||
FROM (
|
||||
SELECT g.id AS accessible_group_id
|
||||
FROM biz_shop_manage_group g
|
||||
WHERE g.created_by_id = #{operatorId}
|
||||
OR g.user_id = #{operatorId}
|
||||
UNION
|
||||
SELECT gm.group_id AS accessible_group_id
|
||||
FROM biz_shop_manage_group_member gm
|
||||
WHERE gm.user_id = #{operatorId}
|
||||
UNION
|
||||
SELECT g2.id AS accessible_group_id
|
||||
FROM biz_shop_manage_group_member gm
|
||||
INNER JOIN biz_shop_manage_group g1 ON g1.id = gm.group_id
|
||||
INNER JOIN biz_shop_manage_group g2
|
||||
ON g2.created_by_id = COALESCE(g1.created_by_id, g1.user_id)
|
||||
OR g2.user_id = COALESCE(g1.created_by_id, g1.user_id)
|
||||
WHERE gm.user_id = #{operatorId}
|
||||
AND COALESCE(g1.created_by_id, g1.user_id) IS NOT NULL
|
||||
UNION
|
||||
SELECT member_groups.id AS accessible_group_id
|
||||
FROM biz_shop_manage_group leader_group
|
||||
INNER JOIN biz_shop_manage_group_member direct_member ON direct_member.group_id = leader_group.id
|
||||
INNER JOIN biz_shop_manage_group member_groups
|
||||
ON member_groups.created_by_id = direct_member.user_id
|
||||
OR member_groups.user_id = direct_member.user_id
|
||||
WHERE leader_group.created_by_id = #{operatorId}
|
||||
OR leader_group.user_id = #{operatorId}
|
||||
) accessible_groups
|
||||
WHERE accessible_group_id IS NOT NULL
|
||||
ORDER BY accessible_group_id ASC
|
||||
""")
|
||||
List<Long> selectAccessibleGroupIds(@Param("operatorId") Long operatorId);
|
||||
|
||||
@Select("""
|
||||
SELECT DISTINCT visible_user_id
|
||||
FROM (
|
||||
SELECT #{operatorId} AS visible_user_id
|
||||
UNION
|
||||
SELECT direct_member.user_id AS visible_user_id
|
||||
FROM biz_shop_manage_group leader_group
|
||||
INNER JOIN biz_shop_manage_group_member direct_member ON direct_member.group_id = leader_group.id
|
||||
WHERE leader_group.created_by_id = #{operatorId}
|
||||
OR leader_group.user_id = #{operatorId}
|
||||
UNION
|
||||
SELECT COALESCE(direct_group.created_by_id, direct_group.user_id) AS visible_user_id
|
||||
FROM biz_shop_manage_group_member membership
|
||||
INNER JOIN biz_shop_manage_group direct_group ON direct_group.id = membership.group_id
|
||||
WHERE membership.user_id = #{operatorId}
|
||||
AND COALESCE(direct_group.created_by_id, direct_group.user_id) IS NOT NULL
|
||||
) visible_users
|
||||
WHERE visible_user_id IS NOT NULL
|
||||
ORDER BY visible_user_id ASC
|
||||
""")
|
||||
List<Long> selectAccessibleCreatorUserIds(@Param("operatorId") Long operatorId);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.nanri.aiimage.modules.shopkey.model.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
@@ -25,31 +26,31 @@ public class SkipPriceAsinEntity {
|
||||
@TableField("asin_de")
|
||||
private String asinDe;
|
||||
|
||||
@TableField("minimum_price_de")
|
||||
@TableField(value = "minimum_price_de", updateStrategy = FieldStrategy.ALWAYS)
|
||||
private BigDecimal minimumPriceDe;
|
||||
|
||||
@TableField("asin_uk")
|
||||
private String asinUk;
|
||||
|
||||
@TableField("minimum_price_uk")
|
||||
@TableField(value = "minimum_price_uk", updateStrategy = FieldStrategy.ALWAYS)
|
||||
private BigDecimal minimumPriceUk;
|
||||
|
||||
@TableField("asin_fr")
|
||||
private String asinFr;
|
||||
|
||||
@TableField("minimum_price_fr")
|
||||
@TableField(value = "minimum_price_fr", updateStrategy = FieldStrategy.ALWAYS)
|
||||
private BigDecimal minimumPriceFr;
|
||||
|
||||
@TableField("asin_it")
|
||||
private String asinIt;
|
||||
|
||||
@TableField("minimum_price_it")
|
||||
@TableField(value = "minimum_price_it", updateStrategy = FieldStrategy.ALWAYS)
|
||||
private BigDecimal minimumPriceIt;
|
||||
|
||||
@TableField("asin_es")
|
||||
private String asinEs;
|
||||
|
||||
@TableField("minimum_price_es")
|
||||
@TableField(value = "minimum_price_es", updateStrategy = FieldStrategy.ALWAYS)
|
||||
private BigDecimal minimumPriceEs;
|
||||
|
||||
@TableField("created_at")
|
||||
|
||||
@@ -40,7 +40,7 @@ public class QueryAsinService {
|
||||
|
||||
private static final List<String> SUPPORTED_COUNTRIES = List.of("DE", "UK", "FR", "IT", "ES");
|
||||
private static final String KEY_SEPARATOR = Character.toString((char) 1);
|
||||
private static final String UTF8_BOM = Character.toString(0xFEFF);
|
||||
private static final String UTF8_BOM = String.valueOf((char) 0xFEFF);
|
||||
|
||||
private final QueryAsinMapper queryAsinMapper;
|
||||
private final ShopManageGroupService shopManageGroupService;
|
||||
@@ -79,7 +79,7 @@ public class QueryAsinService {
|
||||
List<QueryAsinEntity> rows = queryAsinMapper
|
||||
.selectList(query.last("LIMIT " + ((safePage - 1) * safePageSize) + ", " + safePageSize));
|
||||
|
||||
Map<Long, String> groupNameById = buildGroupNameMap(rows.stream()
|
||||
Map<Long, String> groupNameById = shopManageGroupService.buildGroupNameMap(rows.stream()
|
||||
.map(QueryAsinEntity::getGroupId)
|
||||
.filter(id -> id != null && id > 0)
|
||||
.distinct()
|
||||
@@ -316,22 +316,6 @@ public class QueryAsinService {
|
||||
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) {
|
||||
@@ -384,11 +368,11 @@ public class QueryAsinService {
|
||||
return upper;
|
||||
}
|
||||
return switch (normalizeHeaderKey(normalized)) {
|
||||
case "\u5fb7\u56fd", "\u5fb7", "de", "germany", "german", "deutschland" -> "DE";
|
||||
case "\u82f1\u56fd", "\u82f1", "uk", "gb", "greatbritain", "britain", "unitedkingdom" -> "UK";
|
||||
case "\u6cd5\u56fd", "\u6cd5", "fr", "france", "french" -> "FR";
|
||||
case "\u610f\u5927\u5229", "\u610f", "it", "italy", "italian" -> "IT";
|
||||
case "\u897f\u73ed\u7259", "\u897f", "es", "spain", "spanish", "espana" -> "ES";
|
||||
case "德国", "德", "de", "germany", "german", "deutschland" -> "DE";
|
||||
case "英国", "英", "uk", "gb", "greatbritain", "britain", "unitedkingdom" -> "UK";
|
||||
case "法国", "法", "fr", "france", "french" -> "FR";
|
||||
case "意大利", "意", "it", "italy", "italian" -> "IT";
|
||||
case "西班牙", "西", "es", "spain", "spanish", "espana" -> "ES";
|
||||
default -> "";
|
||||
};
|
||||
}
|
||||
@@ -860,11 +844,11 @@ public class QueryAsinService {
|
||||
if (index != null) {
|
||||
return index;
|
||||
}
|
||||
index = firstHeader("groupname", "\u5206\u7ec4id", "\u5206\u7ec4");
|
||||
index = firstHeader("groupname", "分组id", "分组");
|
||||
if (index != null) {
|
||||
return index;
|
||||
}
|
||||
return findHeaderByPredicate(key -> key.contains("\u5206\u7ec4") || key.contains("group"));
|
||||
return findHeaderByPredicate(key -> key.contains("分组") || key.contains("group"));
|
||||
}
|
||||
|
||||
private Integer resolveShopHeaderIndex() {
|
||||
@@ -873,13 +857,13 @@ public class QueryAsinService {
|
||||
return index;
|
||||
}
|
||||
index = firstHeader(
|
||||
"\u5e97\u94fa\u540d", "\u5e97\u94fa\u540d\u79f0", "\u5e97\u94fa\u8d26\u53f7", "\u8d26\u53f7",
|
||||
"店铺名", "店铺名称", "店铺账号", "账号",
|
||||
"account", "selleraccount", "storeaccount", "storename");
|
||||
if (index != null) {
|
||||
return index;
|
||||
}
|
||||
return findHeaderByPredicate(key ->
|
||||
(key.contains("\u5e97\u94fa") && (key.contains("\u540d") || key.contains("\u8d26\u53f7")))
|
||||
(key.contains("店铺") && (key.contains("名") || key.contains("账号")))
|
||||
|| (key.contains("shop") && (key.contains("name") || key.contains("account")))
|
||||
|| key.contains("selleraccount")
|
||||
|| key.contains("storeaccount"));
|
||||
@@ -891,32 +875,32 @@ public class QueryAsinService {
|
||||
return index;
|
||||
}
|
||||
return switch (country) {
|
||||
case "DE" -> firstHeader("\u5fb7\u56fd", "\u5fb7", "\u5fb7\u56fdasin");
|
||||
case "UK" -> firstHeader("\u82f1\u56fd", "\u82f1", "\u82f1\u56fdasin");
|
||||
case "FR" -> firstHeader("\u6cd5\u56fd", "\u6cd5", "\u6cd5\u56fdasin");
|
||||
case "IT" -> firstHeader("\u610f\u5927\u5229", "\u610f", "\u610f\u5927\u5229asin");
|
||||
case "ES" -> firstHeader("\u897f\u73ed\u7259", "\u897f", "\u897f\u73ed\u7259asin");
|
||||
case "DE" -> firstHeader("德国", "德", "德国asin");
|
||||
case "UK" -> firstHeader("英国", "英", "英国asin");
|
||||
case "FR" -> firstHeader("法国", "法", "法国asin");
|
||||
case "IT" -> firstHeader("意大利", "意", "意大利asin");
|
||||
case "ES" -> firstHeader("西班牙", "西", "西班牙asin");
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
private Integer findSingleCountryIndex() {
|
||||
Integer index = firstHeader(
|
||||
"\u4e2d\u6587\u56fd\u5bb6\u540d", "\u56fd\u5bb6", "\u56fd\u5bb6\u540d", "\u56fd\u5bb6\u7ad9\u70b9",
|
||||
"\u7ad9\u70b9", "country", "countryname", "marketplace", "site");
|
||||
"中文国家名", "国家", "国家名", "国家站点",
|
||||
"站点", "country", "countryname", "marketplace", "site");
|
||||
if (index != null) {
|
||||
return index;
|
||||
}
|
||||
return findHeaderByPredicate(key ->
|
||||
key.contains("\u56fd\u5bb6")
|
||||
|| key.contains("\u7ad9\u70b9")
|
||||
key.contains("国家")
|
||||
|| key.contains("站点")
|
||||
|| key.contains("country")
|
||||
|| key.contains("marketplace")
|
||||
|| key.equals("site"));
|
||||
}
|
||||
|
||||
private Integer findSingleAsinIndex() {
|
||||
return firstHeader("asin", "\u67e5\u8be2asin", "\u4ea7\u54c1asin", "\u94fe\u63a5asin");
|
||||
return firstHeader("asin", "查询asin", "产品asin", "链接asin");
|
||||
}
|
||||
|
||||
private boolean hasWideCountryColumns() {
|
||||
|
||||
@@ -48,6 +48,7 @@ public class ShopManageGroupService {
|
||||
public List<ShopManageGroupItemVo> list(Long createdById) {
|
||||
return toVoList(groupMapper.selectList(new LambdaQueryWrapper<ShopManageGroupEntity>()
|
||||
.eq(createdById != null && createdById > 0, ShopManageGroupEntity::getCreatedById, createdById)
|
||||
.or(createdById != null && createdById > 0, wrapper -> wrapper.eq(ShopManageGroupEntity::getUserId, createdById))
|
||||
.orderByAsc(ShopManageGroupEntity::getId)));
|
||||
}
|
||||
|
||||
@@ -74,40 +75,44 @@ public class ShopManageGroupService {
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
}
|
||||
Long normalizedOperatorId = normalizePositiveId(operatorId, "操作人不能为空");
|
||||
LinkedHashSet<Long> groupIds = groupMapper.selectList(new LambdaQueryWrapper<ShopManageGroupEntity>()
|
||||
.select(ShopManageGroupEntity::getId)
|
||||
.eq(ShopManageGroupEntity::getCreatedById, normalizedOperatorId))
|
||||
return groupMapper.selectAccessibleGroupIds(normalizedOperatorId)
|
||||
.stream()
|
||||
.map(ShopManageGroupEntity::getId)
|
||||
.filter(id -> id != null && id > 0)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
List<Long> memberGroupIds = groupMemberMapper.selectList(new LambdaQueryWrapper<ShopManageGroupMemberEntity>()
|
||||
.select(ShopManageGroupMemberEntity::getGroupId)
|
||||
.eq(ShopManageGroupMemberEntity::getUserId, normalizedOperatorId))
|
||||
.stream()
|
||||
.map(ShopManageGroupMemberEntity::getGroupId)
|
||||
.filter(id -> id != null && id > 0)
|
||||
.toList();
|
||||
groupIds.addAll(memberGroupIds);
|
||||
if (!memberGroupIds.isEmpty()) {
|
||||
LinkedHashSet<Long> leaderIds = groupMapper.selectList(new LambdaQueryWrapper<ShopManageGroupEntity>()
|
||||
.select(ShopManageGroupEntity::getCreatedById)
|
||||
.in(ShopManageGroupEntity::getId, memberGroupIds))
|
||||
.stream()
|
||||
.map(ShopManageGroupEntity::getCreatedById)
|
||||
.filter(id -> id != null && id > 0)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
if (!leaderIds.isEmpty()) {
|
||||
groupIds.addAll(groupMapper.selectList(new LambdaQueryWrapper<ShopManageGroupEntity>()
|
||||
.select(ShopManageGroupEntity::getId)
|
||||
.in(ShopManageGroupEntity::getCreatedById, leaderIds))
|
||||
.stream()
|
||||
.map(ShopManageGroupEntity::getId)
|
||||
.filter(id -> id != null && id > 0)
|
||||
.toList());
|
||||
}
|
||||
}
|
||||
|
||||
public Set<Long> listAccessibleCreatorUserIds(Long operatorId, boolean superAdmin) {
|
||||
if (superAdmin) {
|
||||
return Set.of();
|
||||
}
|
||||
return groupIds;
|
||||
Long normalizedOperatorId = normalizePositiveId(operatorId, "操作人不能为空");
|
||||
return groupMapper.selectAccessibleCreatorUserIds(normalizedOperatorId)
|
||||
.stream()
|
||||
.filter(id -> id != null && id > 0)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
}
|
||||
|
||||
public Map<Long, String> buildGroupNameMap(List<Long> groupIds) {
|
||||
if (groupIds == null || groupIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
List<Long> normalizedGroupIds = groupIds.stream()
|
||||
.filter(id -> id != null && id > 0)
|
||||
.distinct()
|
||||
.toList();
|
||||
if (normalizedGroupIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
return groupMapper.selectList(new LambdaQueryWrapper<ShopManageGroupEntity>()
|
||||
.select(ShopManageGroupEntity::getId, ShopManageGroupEntity::getGroupName)
|
||||
.in(ShopManageGroupEntity::getId, normalizedGroupIds))
|
||||
.stream()
|
||||
.filter(group -> group.getId() != null)
|
||||
.collect(Collectors.toMap(
|
||||
ShopManageGroupEntity::getId,
|
||||
group -> normalizeBlank(group.getGroupName()),
|
||||
(left, right) -> left,
|
||||
LinkedHashMap::new));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -207,8 +212,9 @@ public class ShopManageGroupService {
|
||||
return;
|
||||
}
|
||||
Long normalizedOperatorId = normalizePositiveId(operatorId, "操作人不能为空");
|
||||
boolean createdBySelf = entity.getCreatedById() != null && entity.getCreatedById().equals(normalizedOperatorId);
|
||||
if (!createdBySelf) {
|
||||
boolean leaderBySelf = entity.getCreatedById() != null && entity.getCreatedById().equals(normalizedOperatorId);
|
||||
boolean legacyLeaderBySelf = entity.getUserId() != null && entity.getUserId().equals(normalizedOperatorId);
|
||||
if (!leaderBySelf && !legacyLeaderBySelf) {
|
||||
throw new BusinessException("只有组长才能维护分组");
|
||||
}
|
||||
}
|
||||
@@ -332,8 +338,9 @@ public class ShopManageGroupService {
|
||||
|
||||
LinkedHashSet<Long> userIds = new LinkedHashSet<>();
|
||||
for (ShopManageGroupEntity group : groups) {
|
||||
if (group.getCreatedById() != null && group.getCreatedById() > 0) {
|
||||
userIds.add(group.getCreatedById());
|
||||
Long leaderUserId = resolveGroupLeaderUserId(group);
|
||||
if (leaderUserId != null && leaderUserId > 0) {
|
||||
userIds.add(leaderUserId);
|
||||
}
|
||||
}
|
||||
for (List<ShopManageGroupMemberEntity> members : membersByGroupId.values()) {
|
||||
@@ -355,8 +362,9 @@ public class ShopManageGroupService {
|
||||
ShopManageGroupItemVo vo = new ShopManageGroupItemVo();
|
||||
vo.setId(entity.getId());
|
||||
vo.setGroupName(entity.getGroupName());
|
||||
vo.setLeaderUserId(entity.getCreatedById());
|
||||
AdminUserEntity leaderUser = entity.getCreatedById() == null ? null : userById.get(entity.getCreatedById());
|
||||
Long leaderUserId = resolveGroupLeaderUserId(entity);
|
||||
vo.setLeaderUserId(leaderUserId);
|
||||
AdminUserEntity leaderUser = leaderUserId == null ? null : userById.get(leaderUserId);
|
||||
vo.setLeaderUsername(leaderUser == null ? "" : normalizeBlank(leaderUser.getUsername()));
|
||||
|
||||
List<ShopManageGroupMemberEntity> memberEntities = membersByGroupId.getOrDefault(entity.getId(), Collections.emptyList());
|
||||
@@ -380,4 +388,14 @@ public class ShopManageGroupService {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private Long resolveGroupLeaderUserId(ShopManageGroupEntity entity) {
|
||||
if (entity == null) {
|
||||
return null;
|
||||
}
|
||||
if (entity.getCreatedById() != null && entity.getCreatedById() > 0) {
|
||||
return entity.getCreatedById();
|
||||
}
|
||||
return entity.getUserId();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
@@ -33,6 +32,7 @@ public class ShopManageService {
|
||||
long safePageSize = Math.min(Math.max(pageSize, 1), 100);
|
||||
String safeShopName = shopName == null ? "" : shopName.trim();
|
||||
Set<Long> accessibleGroupIds = superAdmin ? Set.of() : shopManageGroupService.listAccessibleGroupIds(operatorId, false);
|
||||
Set<Long> accessibleCreatorUserIds = superAdmin ? Set.of() : shopManageGroupService.listAccessibleCreatorUserIds(operatorId, false);
|
||||
|
||||
LambdaQueryWrapper<ShopManageEntity> query = new LambdaQueryWrapper<ShopManageEntity>()
|
||||
.eq(groupId != null && groupId > 0, ShopManageEntity::getGroupId, groupId)
|
||||
@@ -40,20 +40,30 @@ public class ShopManageService {
|
||||
.orderByDesc(ShopManageEntity::getId);
|
||||
|
||||
if (!superAdmin) {
|
||||
Long normalizedOperatorId = normalizePositiveId(operatorId, "操作人不能为空");
|
||||
query.and(wrapper -> {
|
||||
wrapper.eq(ShopManageEntity::getCreatedById, normalizedOperatorId);
|
||||
if (!accessibleGroupIds.isEmpty()) {
|
||||
wrapper.or().in(ShopManageEntity::getGroupId, accessibleGroupIds);
|
||||
}
|
||||
});
|
||||
normalizePositiveId(operatorId, "操作人不能为空");
|
||||
if (accessibleGroupIds.isEmpty() && accessibleCreatorUserIds.isEmpty()) {
|
||||
query.eq(ShopManageEntity::getId, -1L);
|
||||
} else {
|
||||
query.and(wrapper -> {
|
||||
boolean hasCreatorFilter = !accessibleCreatorUserIds.isEmpty();
|
||||
if (hasCreatorFilter) {
|
||||
wrapper.in(ShopManageEntity::getCreatedById, accessibleCreatorUserIds);
|
||||
}
|
||||
if (!accessibleGroupIds.isEmpty()) {
|
||||
if (hasCreatorFilter) {
|
||||
wrapper.or();
|
||||
}
|
||||
wrapper.in(ShopManageEntity::getGroupId, accessibleGroupIds);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Long total = shopManageMapper.selectCount(query);
|
||||
List<ShopManageEntity> rows = shopManageMapper
|
||||
.selectList(query.last("LIMIT " + ((safePage - 1) * safePageSize) + ", " + safePageSize));
|
||||
|
||||
Map<Long, String> groupNameById = buildGroupNameMap(rows.stream()
|
||||
Map<Long, String> groupNameById = shopManageGroupService.buildGroupNameMap(rows.stream()
|
||||
.map(ShopManageEntity::getGroupId)
|
||||
.filter(id -> id != null && id > 0)
|
||||
.distinct()
|
||||
@@ -190,22 +200,6 @@ public class ShopManageService {
|
||||
}
|
||||
}
|
||||
|
||||
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 ShopManageItemVo toItemVo(ShopManageEntity entity, String groupName) {
|
||||
ShopManageItemVo vo = new ShopManageItemVo();
|
||||
vo.setId(entity.getId());
|
||||
|
||||
@@ -1,17 +1,33 @@
|
||||
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.modules.shopkey.mapper.SkipPriceAsinMapper;
|
||||
import com.nanri.aiimage.modules.shopkey.mapper.ShopManageMapper;
|
||||
import com.nanri.aiimage.modules.shopkey.model.dto.SkipPriceAsinCreateRequest;
|
||||
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageEntity;
|
||||
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageGroupEntity;
|
||||
import com.nanri.aiimage.modules.shopkey.model.entity.SkipPriceAsinEntity;
|
||||
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.SkipPriceAsinItemVo;
|
||||
import com.nanri.aiimage.modules.shopkey.model.vo.SkipPriceAsinPageVo;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.poi.ss.usermodel.Cell;
|
||||
import org.apache.poi.ss.usermodel.DataFormatter;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
import org.apache.poi.ss.usermodel.Workbook;
|
||||
import org.apache.poi.ss.usermodel.WorkbookFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDateTime;
|
||||
@@ -21,15 +37,20 @@ import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class SkipPriceAsinService {
|
||||
|
||||
private static final List<String> SUPPORTED_COUNTRIES = List.of("DE", "UK", "FR", "IT", "ES");
|
||||
|
||||
private final SkipPriceAsinMapper skipPriceAsinMapper;
|
||||
private final ShopManageMapper shopManageMapper;
|
||||
private final ShopManageGroupService shopManageGroupService;
|
||||
private final Map<String, QueryAsinImportProgressVo> importProgressMap = new ConcurrentHashMap<>();
|
||||
private final Map<String, QueryAsinImportProgressVo> deleteImportProgressMap = new ConcurrentHashMap<>();
|
||||
|
||||
public SkipPriceAsinPageVo page(long page, long pageSize, Long groupId, String shopName, String asin,
|
||||
Long operatorId, boolean superAdmin) {
|
||||
@@ -62,7 +83,7 @@ public class SkipPriceAsinService {
|
||||
List<SkipPriceAsinEntity> rows = skipPriceAsinMapper
|
||||
.selectList(query.last("LIMIT " + ((safePage - 1) * safePageSize) + ", " + safePageSize));
|
||||
|
||||
Map<Long, String> groupNameById = buildGroupNameMap(rows.stream()
|
||||
Map<Long, String> groupNameById = shopManageGroupService.buildGroupNameMap(rows.stream()
|
||||
.map(SkipPriceAsinEntity::getGroupId)
|
||||
.filter(id -> id != null && id > 0)
|
||||
.distinct()
|
||||
@@ -141,6 +162,486 @@ public class SkipPriceAsinService {
|
||||
return toItemVo(saved, groupName);
|
||||
}
|
||||
|
||||
public QueryAsinImportStartVo startImport(MultipartFile file, Long groupId, Long operatorId, boolean superAdmin) {
|
||||
return startImportTask(file, groupId, 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 groupId, Long operatorId, boolean superAdmin) {
|
||||
return startImportTask(file, groupId, 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 groupId, Long operatorId,
|
||||
boolean superAdmin, boolean deleteMode) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new BusinessException("请上传 xlsx 或 xls 文件");
|
||||
}
|
||||
validateExcelFilename(file.getOriginalFilename());
|
||||
if (groupId == null || groupId <= 0) {
|
||||
throw new BusinessException("请先选择分组");
|
||||
}
|
||||
shopManageGroupService.getAccessibleById(groupId, operatorId, superAdmin);
|
||||
String shopName = normalizeShopNameFromFilename(file.getOriginalFilename());
|
||||
ensureShopExistsInGroup(groupId, shopName);
|
||||
|
||||
String importId = IdUtil.fastSimpleUUID();
|
||||
QueryAsinImportProgressVo progress = newImportProgress();
|
||||
Map<String, QueryAsinImportProgressVo> progressMap = deleteMode ? deleteImportProgressMap : importProgressMap;
|
||||
progressMap.put(importId, progress);
|
||||
try {
|
||||
File tempFile = saveMultipartToTempFile(file, deleteMode ? "skip-price-asin-delete-" : "skip-price-asin-import-");
|
||||
Thread.ofVirtual().start(() -> runImportTask(importId, tempFile, file.getOriginalFilename(), groupId,
|
||||
shopName, 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 groupId,
|
||||
String shopName, 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, groupId, shopName, deleteMode, progress);
|
||||
progress.setStatus("success");
|
||||
} catch (Exception ex) {
|
||||
log.warn("[skip-price-asin-import] failed importId={} deleteMode={} msg={}",
|
||||
importId, deleteMode, ex.getMessage());
|
||||
progress.setStatus("failed");
|
||||
progress.setErrorMessage(ex instanceof BusinessException ? ex.getMessage()
|
||||
: (deleteMode ? "导入删除 Excel 失败" : "导入新增 Excel 失败"));
|
||||
} finally {
|
||||
deleteQuietly(tempFile);
|
||||
}
|
||||
}
|
||||
|
||||
private void processImportFile(File tempFile, String filename, Long groupId, String shopName,
|
||||
boolean deleteMode, QueryAsinImportProgressVo progress) throws Exception {
|
||||
validateExcelFilename(filename);
|
||||
try (FileInputStream inputStream = new FileInputStream(tempFile);
|
||||
Workbook workbook = WorkbookFactory.create(inputStream)) {
|
||||
Sheet sheet = workbook.getNumberOfSheets() == 0 ? null : workbook.getSheetAt(0);
|
||||
if (sheet == null) {
|
||||
throw new BusinessException("Excel 为空");
|
||||
}
|
||||
HeaderMapping mapping = resolveHeaderMapping(sheet);
|
||||
if (mapping.asinColumns().isEmpty()) {
|
||||
throw new BusinessException("Excel 至少需要包含一个国家的 ASIN 列");
|
||||
}
|
||||
|
||||
int firstDataRow = mapping.firstDataRowIndex();
|
||||
int lastRow = Math.max(sheet.getLastRowNum(), firstDataRow - 1);
|
||||
int totalRows = Math.max(0, lastRow - firstDataRow + 1);
|
||||
progress.setTotalRows(totalRows);
|
||||
|
||||
DataFormatter formatter = new DataFormatter();
|
||||
ImportCounters counters = new ImportCounters();
|
||||
for (int rowIndex = firstDataRow; rowIndex <= lastRow; rowIndex++) {
|
||||
Row row = sheet.getRow(rowIndex);
|
||||
if (row == null || isBlankRow(row, formatter)) {
|
||||
counters.skippedCount++;
|
||||
updateImportProgress(progress, counters, rowIndex - firstDataRow + 1);
|
||||
continue;
|
||||
}
|
||||
if (deleteMode) {
|
||||
consumeDeleteImportRow(row, formatter, mapping, groupId, shopName, counters);
|
||||
} else {
|
||||
consumeAddImportRow(row, formatter, mapping, groupId, shopName, counters);
|
||||
}
|
||||
updateImportProgress(progress, counters, rowIndex - firstDataRow + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void consumeAddImportRow(Row row, DataFormatter formatter, HeaderMapping mapping,
|
||||
Long groupId, String shopName, ImportCounters counters) {
|
||||
boolean acceptedAny = false;
|
||||
boolean sawAnyAsin = false;
|
||||
SkipPriceAsinEntity entity = new SkipPriceAsinEntity();
|
||||
entity.setGroupId(groupId);
|
||||
entity.setShopName(shopName);
|
||||
for (Map.Entry<String, Integer> entry : mapping.asinColumns().entrySet()) {
|
||||
String country = entry.getKey();
|
||||
String asin = normalizeBlank(cellText(row, entry.getValue(), formatter)).toUpperCase(Locale.ROOT);
|
||||
if (asin.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
sawAnyAsin = true;
|
||||
counters.asinCount++;
|
||||
if (asin.length() > 64) {
|
||||
counters.skippedCount++;
|
||||
continue;
|
||||
}
|
||||
BigDecimal minimumPrice = parseOptionalMinimumPrice(
|
||||
cellText(row, mapping.priceColumns().get(country), formatter));
|
||||
if (existsCountryAsin(groupId, shopName, country, asin)) {
|
||||
counters.skippedCount++;
|
||||
continue;
|
||||
}
|
||||
setCountryData(entity, country, asin, minimumPrice);
|
||||
counters.insertedCount++;
|
||||
acceptedAny = true;
|
||||
}
|
||||
if (!acceptedAny) {
|
||||
if (!sawAnyAsin) {
|
||||
counters.skippedCount++;
|
||||
}
|
||||
return;
|
||||
}
|
||||
skipPriceAsinMapper.insert(entity);
|
||||
}
|
||||
|
||||
private void consumeDeleteImportRow(Row row, DataFormatter formatter, HeaderMapping mapping,
|
||||
Long groupId, String shopName, ImportCounters counters) {
|
||||
boolean acceptedAny = false;
|
||||
boolean sawAnyAsin = false;
|
||||
for (Map.Entry<String, Integer> entry : mapping.asinColumns().entrySet()) {
|
||||
String country = entry.getKey();
|
||||
String asin = normalizeBlank(cellText(row, entry.getValue(), formatter)).toUpperCase(Locale.ROOT);
|
||||
if (asin.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
sawAnyAsin = true;
|
||||
counters.asinCount++;
|
||||
if (asin.length() > 64) {
|
||||
counters.skippedCount++;
|
||||
continue;
|
||||
}
|
||||
SkipPriceAsinEntity entity = findCountryAsin(groupId, shopName, country, asin);
|
||||
if (entity == null) {
|
||||
counters.skippedCount++;
|
||||
continue;
|
||||
}
|
||||
setCountryData(entity, country, "", null);
|
||||
if (isEmptyRow(entity)) {
|
||||
skipPriceAsinMapper.deleteById(entity.getId());
|
||||
} else {
|
||||
skipPriceAsinMapper.updateById(entity);
|
||||
}
|
||||
counters.deletedCount++;
|
||||
acceptedAny = true;
|
||||
}
|
||||
if (!acceptedAny && !sawAnyAsin) {
|
||||
counters.skippedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean existsCountryAsin(Long groupId, String shopName, String country, String asin) {
|
||||
return findCountryAsin(groupId, shopName, country, asin) != null;
|
||||
}
|
||||
|
||||
private SkipPriceAsinEntity findCountryAsin(Long groupId, String shopName, String country, String asin) {
|
||||
LambdaQueryWrapper<SkipPriceAsinEntity> query = new LambdaQueryWrapper<SkipPriceAsinEntity>()
|
||||
.eq(SkipPriceAsinEntity::getGroupId, groupId)
|
||||
.eq(SkipPriceAsinEntity::getShopName, shopName);
|
||||
switch (country) {
|
||||
case "DE" -> query.eq(SkipPriceAsinEntity::getAsinDe, asin);
|
||||
case "UK" -> query.eq(SkipPriceAsinEntity::getAsinUk, asin);
|
||||
case "FR" -> query.eq(SkipPriceAsinEntity::getAsinFr, asin);
|
||||
case "IT" -> query.eq(SkipPriceAsinEntity::getAsinIt, asin);
|
||||
case "ES" -> query.eq(SkipPriceAsinEntity::getAsinEs, asin);
|
||||
default -> throw new BusinessException("鍥藉鍙傛暟涓嶆敮鎸? " + country);
|
||||
}
|
||||
return skipPriceAsinMapper.selectOne(query.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
private HeaderMapping resolveHeaderMapping(Sheet sheet) {
|
||||
DataFormatter formatter = new DataFormatter();
|
||||
Row firstHeader = sheet.getRow(0);
|
||||
Row secondHeader = sheet.getRow(1);
|
||||
if (firstHeader == null) {
|
||||
throw new BusinessException("Excel 表头为空");
|
||||
}
|
||||
|
||||
int maxColumn = Math.max(lastCellNum(firstHeader), lastCellNum(secondHeader));
|
||||
Map<String, Integer> asinColumns = new LinkedHashMap<>();
|
||||
Map<String, Integer> priceColumns = new LinkedHashMap<>();
|
||||
for (int columnIndex = 0; columnIndex < maxColumn; columnIndex++) {
|
||||
String countryText = cellTextWithMerged(sheet, firstHeader, columnIndex, formatter);
|
||||
String fieldText = cellText(secondHeader, columnIndex, formatter);
|
||||
String country = normalizeCountryAlias(countryText);
|
||||
String fieldKey = normalizeHeaderKey(fieldText);
|
||||
if (country.isEmpty()) {
|
||||
country = normalizeCountryAlias(fieldText);
|
||||
fieldKey = normalizeHeaderKey(countryText);
|
||||
}
|
||||
if (country.isEmpty() || !SUPPORTED_COUNTRIES.contains(country)) {
|
||||
continue;
|
||||
}
|
||||
if (isAsinHeader(fieldKey)) {
|
||||
asinColumns.putIfAbsent(country, columnIndex);
|
||||
} else if (isPriceHeader(fieldKey)) {
|
||||
priceColumns.putIfAbsent(country, columnIndex);
|
||||
}
|
||||
}
|
||||
if (asinColumns.isEmpty()) {
|
||||
for (int columnIndex = 0; columnIndex < maxColumn; columnIndex++) {
|
||||
String headerText = cellText(firstHeader, columnIndex, formatter);
|
||||
String key = normalizeHeaderKey(headerText);
|
||||
String country = normalizeCountryAlias(headerText);
|
||||
if (country.isEmpty()) {
|
||||
country = countryFromHeaderKey(key);
|
||||
}
|
||||
if (!country.isEmpty() && isAsinHeader(key)) {
|
||||
asinColumns.putIfAbsent(country, columnIndex);
|
||||
}
|
||||
}
|
||||
return new HeaderMapping(asinColumns, priceColumns, 1);
|
||||
}
|
||||
return new HeaderMapping(asinColumns, priceColumns, 2);
|
||||
}
|
||||
|
||||
private int lastCellNum(Row row) {
|
||||
return row == null || row.getLastCellNum() < 0 ? 0 : row.getLastCellNum();
|
||||
}
|
||||
|
||||
private boolean isAsinHeader(String key) {
|
||||
return key.contains("asin");
|
||||
}
|
||||
|
||||
private boolean isPriceHeader(String key) {
|
||||
return key.contains("最低价") || key.contains("最低价格") || key.contains("minimumprice")
|
||||
|| key.contains("minprice") || key.equals("price");
|
||||
}
|
||||
|
||||
private String cellTextWithMerged(Sheet sheet, Row row, int columnIndex, DataFormatter formatter) {
|
||||
if (row == null) {
|
||||
return "";
|
||||
}
|
||||
int rowIndex = row.getRowNum();
|
||||
for (int i = 0; i < sheet.getNumMergedRegions(); i++) {
|
||||
var region = sheet.getMergedRegion(i);
|
||||
if (region.isInRange(rowIndex, columnIndex)) {
|
||||
Row firstRow = sheet.getRow(region.getFirstRow());
|
||||
return cellText(firstRow, region.getFirstColumn(), formatter);
|
||||
}
|
||||
}
|
||||
return cellText(row, columnIndex, formatter);
|
||||
}
|
||||
|
||||
private String cellText(Row row, Integer columnIndex, DataFormatter formatter) {
|
||||
if (row == null || columnIndex == null || columnIndex < 0) {
|
||||
return "";
|
||||
}
|
||||
Cell cell = row.getCell(columnIndex);
|
||||
return cell == null ? "" : normalizeExcelText(formatter.formatCellValue(cell));
|
||||
}
|
||||
|
||||
private String normalizeExcelText(String value) {
|
||||
return value == null ? "" : value.replace(String.valueOf((char) 0xFEFF), "").trim();
|
||||
}
|
||||
|
||||
private String normalizeHeaderKey(String value) {
|
||||
return normalizeExcelText(value)
|
||||
.toLowerCase(Locale.ROOT)
|
||||
.replace(" ", "")
|
||||
.replace("_", "")
|
||||
.replace("-", "")
|
||||
.replace("/", "")
|
||||
.replace("\\", "")
|
||||
.replace(":", "")
|
||||
.replace(":", "")
|
||||
.replace("(", "")
|
||||
.replace(")", "")
|
||||
.replace("(", "")
|
||||
.replace(")", "");
|
||||
}
|
||||
|
||||
private String normalizeCountryAlias(String country) {
|
||||
String normalized = normalizeBlank(country);
|
||||
if (normalized.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
String upper = normalized.toUpperCase(Locale.ROOT);
|
||||
if (SUPPORTED_COUNTRIES.contains(upper)) {
|
||||
return upper;
|
||||
}
|
||||
return switch (normalizeHeaderKey(normalized)) {
|
||||
case "德国", "德", "de", "germany", "german", "deutschland" -> "DE";
|
||||
case "英国", "英", "uk", "gb", "greatbritain", "britain", "unitedkingdom" -> "UK";
|
||||
case "法国", "法", "fr", "france", "french" -> "FR";
|
||||
case "意大利", "意", "it", "italy", "italian" -> "IT";
|
||||
case "西班牙", "西", "es", "spain", "spanish", "espana" -> "ES";
|
||||
default -> "";
|
||||
};
|
||||
}
|
||||
|
||||
private String countryFromHeaderKey(String key) {
|
||||
if (key.contains("asinde") || key.contains("deasin") || key.contains("德国")) return "DE";
|
||||
if (key.contains("asinuk") || key.contains("ukasin") || key.contains("英国")) return "UK";
|
||||
if (key.contains("asinfr") || key.contains("frasin") || key.contains("法国")) return "FR";
|
||||
if (key.contains("asinit") || key.contains("itasin") || key.contains("意大利")) return "IT";
|
||||
if (key.contains("asines") || key.contains("esasin") || key.contains("西班牙")) return "ES";
|
||||
return "";
|
||||
}
|
||||
|
||||
private BigDecimal parseOptionalMinimumPrice(String value) {
|
||||
String normalized = normalizeBlank(value);
|
||||
if (normalized.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return normalizeMinimumPrice(new BigDecimal(normalized.replace(",", "")));
|
||||
} catch (Exception ex) {
|
||||
throw new BusinessException("最低价格式不正确: " + value);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean priceEquals(BigDecimal left, BigDecimal right) {
|
||||
if (left == null && right == null) {
|
||||
return true;
|
||||
}
|
||||
if (left == null || right == null) {
|
||||
return false;
|
||||
}
|
||||
return left.compareTo(right) == 0;
|
||||
}
|
||||
|
||||
private boolean isBlankRow(Row row, DataFormatter formatter) {
|
||||
if (row == null) {
|
||||
return true;
|
||||
}
|
||||
for (int i = 0; i < lastCellNum(row); i++) {
|
||||
if (!cellText(row, i, formatter).isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void updateImportProgress(QueryAsinImportProgressVo progress, ImportCounters counters, int processedRows) {
|
||||
progress.setProcessedRows(processedRows);
|
||||
progress.setAsinCount(counters.asinCount);
|
||||
progress.setInsertedCount(counters.insertedCount);
|
||||
progress.setDeletedCount(counters.deletedCount);
|
||||
progress.setSkippedCount(counters.skippedCount);
|
||||
}
|
||||
|
||||
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 String normalizeShopNameFromFilename(String filename) {
|
||||
String normalized = normalizeBlank(filename);
|
||||
int slashIndex = Math.max(normalized.lastIndexOf('/'), normalized.lastIndexOf('\\'));
|
||||
if (slashIndex >= 0) {
|
||||
normalized = normalized.substring(slashIndex + 1);
|
||||
}
|
||||
int dotIndex = normalized.lastIndexOf('.');
|
||||
if (dotIndex > 0) {
|
||||
normalized = normalized.substring(0, dotIndex);
|
||||
}
|
||||
return normalizeRequired(normalized, "文件名必须是店铺名");
|
||||
}
|
||||
|
||||
private void ensureShopExistsInGroup(Long groupId, String shopName) {
|
||||
Long count = shopManageMapper.selectCount(new LambdaQueryWrapper<ShopManageEntity>()
|
||||
.eq(ShopManageEntity::getGroupId, groupId)
|
||||
.eq(ShopManageEntity::getShopName, shopName));
|
||||
if (count == null || count <= 0) {
|
||||
throw new BusinessException("当前分组下不存在文件名对应的店铺: " + shopName);
|
||||
}
|
||||
}
|
||||
|
||||
private String getCountryAsin(SkipPriceAsinEntity 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 BigDecimal getCountryMinimumPrice(SkipPriceAsinEntity entity, String country) {
|
||||
return switch (country) {
|
||||
case "DE" -> entity.getMinimumPriceDe();
|
||||
case "UK" -> entity.getMinimumPriceUk();
|
||||
case "FR" -> entity.getMinimumPriceFr();
|
||||
case "IT" -> entity.getMinimumPriceIt();
|
||||
case "ES" -> entity.getMinimumPriceEs();
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
private record HeaderMapping(Map<String, Integer> asinColumns,
|
||||
Map<String, Integer> priceColumns,
|
||||
int firstDataRowIndex) {
|
||||
}
|
||||
|
||||
private static final class ImportCounters {
|
||||
private int asinCount;
|
||||
private int insertedCount;
|
||||
private int deletedCount;
|
||||
private int skippedCount;
|
||||
}
|
||||
|
||||
private SkipPriceAsinEntity getById(Long id) {
|
||||
SkipPriceAsinEntity entity = skipPriceAsinMapper.selectById(id);
|
||||
if (entity == null) {
|
||||
@@ -174,22 +675,6 @@ public class SkipPriceAsinService {
|
||||
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) {
|
||||
|
||||
@@ -132,6 +132,21 @@ public class ShopMatchTaskService {
|
||||
return tasks;
|
||||
}
|
||||
|
||||
private List<FileTaskEntity> selectTaskStatusesByIdsInBatches(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.selectList(new LambdaQueryWrapper<FileTaskEntity>()
|
||||
.select(FileTaskEntity::getId, FileTaskEntity::getStatus)
|
||||
.in(FileTaskEntity::getId, taskIds.subList(start, end))));
|
||||
}
|
||||
return tasks;
|
||||
}
|
||||
|
||||
public ProductRiskDashboardVo dashboard(Long userId) {
|
||||
if (userId == null || userId <= 0) {
|
||||
throw new BusinessException("user_id 不合法");
|
||||
@@ -174,7 +189,7 @@ public class ShopMatchTaskService {
|
||||
.distinct()
|
||||
.toList();
|
||||
if (!taskIds.isEmpty()) {
|
||||
for (FileTaskEntity task : selectTasksByIdsInBatches(taskIds)) {
|
||||
for (FileTaskEntity task : selectTaskStatusesByIdsInBatches(taskIds)) {
|
||||
if (task != null) {
|
||||
statusByTaskId.put(task.getId(), task.getStatus());
|
||||
}
|
||||
@@ -465,7 +480,6 @@ public class ShopMatchTaskService {
|
||||
}
|
||||
|
||||
|
||||
@Transactional
|
||||
public void submitResult(Long taskId, ShopMatchSubmitResultRequest request) {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
throw new BusinessException("taskId 不合法");
|
||||
@@ -540,7 +554,6 @@ public class ShopMatchTaskService {
|
||||
updateTaskStatusFromLatestRows(task, latest);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public boolean tryFinalizeTask(Long taskId, boolean fromCompensation) {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
return false;
|
||||
|
||||
@@ -456,8 +456,8 @@ public class SplitRunService {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
return value.replace("\ufeff", "")
|
||||
.replace("\u3000", " ")
|
||||
return value.replace(String.valueOf((char) 0xFEFF), "")
|
||||
.replace((char) 0x3000, ' ')
|
||||
.replace("\r\n", " ")
|
||||
.replace("\r", " ")
|
||||
.replace("\n", " ")
|
||||
|
||||
@@ -23,7 +23,6 @@ public class TaskFileJobService {
|
||||
private final TaskFileJobMapper taskFileJobMapper;
|
||||
private final ApplicationEventPublisher applicationEventPublisher;
|
||||
|
||||
@Transactional
|
||||
public TaskFileJobEntity enqueueAssembleResult(Long taskId, String moduleType, Long resultId, String scopeKey) {
|
||||
if (taskId == null || taskId <= 0 || moduleType == null || moduleType.isBlank() || resultId == null || resultId <= 0) {
|
||||
return null;
|
||||
@@ -146,7 +145,6 @@ public class TaskFileJobService {
|
||||
return reset;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public boolean markRunning(Long jobId) {
|
||||
if (jobId == null || jobId <= 0) {
|
||||
return false;
|
||||
@@ -158,7 +156,6 @@ public class TaskFileJobService {
|
||||
.set(TaskFileJobEntity::getUpdatedAt, LocalDateTime.now())) > 0;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void touchRunning(Long jobId) {
|
||||
if (jobId == null || jobId <= 0) {
|
||||
return;
|
||||
@@ -169,7 +166,6 @@ public class TaskFileJobService {
|
||||
.set(TaskFileJobEntity::getUpdatedAt, LocalDateTime.now()));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void markSuccess(TaskFileJobEntity job, String resultFileUrl) {
|
||||
taskFileJobMapper.update(null, new LambdaUpdateWrapper<TaskFileJobEntity>()
|
||||
.eq(TaskFileJobEntity::getId, job.getId())
|
||||
@@ -180,7 +176,6 @@ public class TaskFileJobService {
|
||||
.set(TaskFileJobEntity::getFinishedAt, LocalDateTime.now()));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void markFailed(TaskFileJobEntity job, String message) {
|
||||
int retryCount = job.getRetryCount() == null ? 0 : job.getRetryCount() + 1;
|
||||
taskFileJobMapper.update(null, new LambdaUpdateWrapper<TaskFileJobEntity>()
|
||||
|
||||
@@ -25,7 +25,6 @@ public class TaskResultItemService {
|
||||
private final ObjectMapper objectMapper;
|
||||
private final TransientPayloadStorageService transientPayloadStorageService;
|
||||
|
||||
@Transactional
|
||||
public void replaceResultSnapshot(Long taskId, String moduleType, Long resultId, String scopeKey, Object snapshot) {
|
||||
if (taskId == null || taskId <= 0 || isBlank(moduleType) || resultId == null || resultId <= 0 || snapshot == null) {
|
||||
return;
|
||||
@@ -35,7 +34,6 @@ public class TaskResultItemService {
|
||||
upsert(taskId, moduleType, resultId, normalizedScopeKey, itemKey, null, null, null, snapshot);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void replaceTaskSnapshots(Long taskId, String moduleType, List<? extends Object> snapshots, SnapshotKeyResolver resolver) {
|
||||
if (taskId == null || taskId <= 0 || isBlank(moduleType) || snapshots == null) {
|
||||
return;
|
||||
|
||||
@@ -25,7 +25,6 @@ public class TaskResultPayloadService {
|
||||
private final ObjectMapper objectMapper;
|
||||
private final TransientPayloadStorageService transientPayloadStorageService;
|
||||
|
||||
@Transactional
|
||||
public void saveLatest(Long taskId, String moduleType, String scopeKey, Object payload) {
|
||||
if (taskId == null || taskId <= 0 || isBlank(moduleType) || isBlank(scopeKey) || payload == null) {
|
||||
return;
|
||||
|
||||
@@ -60,7 +60,7 @@ public class TransientPayloadStorageService {
|
||||
return ossStorageService.readObjectAsString(pointer.substring(OSS_POINTER_PREFIX.length()));
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
throw new IllegalStateException(errorMessage, ex);
|
||||
throw new IllegalStateException(errorMessage + ": " + pointer, ex);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -212,7 +212,7 @@ public boolean isIpWhitelistError(BusinessException ex) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
return value.replace("\u3000", " ")
|
||||
return value.replace((char) 0x3000, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
|
||||
@@ -357,7 +357,7 @@ public class ZiniaoShopIndexService {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
return value.replace("\u3000", " ").trim();
|
||||
return value.replace((char) 0x3000, ' ').trim();
|
||||
}
|
||||
|
||||
private ZiniaoShopMatchResultVo pendingResult(String message) {
|
||||
|
||||
@@ -19,6 +19,10 @@ spring:
|
||||
maximum-pool-size: ${AIIMAGE_DB_POOL_MAX_SIZE:30}
|
||||
minimum-idle: ${AIIMAGE_DB_POOL_MIN_IDLE:5}
|
||||
connection-timeout: ${AIIMAGE_DB_POOL_CONNECTION_TIMEOUT_MS:30000}
|
||||
validation-timeout: ${AIIMAGE_DB_POOL_VALIDATION_TIMEOUT_MS:5000}
|
||||
idle-timeout: ${AIIMAGE_DB_POOL_IDLE_TIMEOUT_MS:600000}
|
||||
max-lifetime: ${AIIMAGE_DB_POOL_MAX_LIFETIME_MS:1500000}
|
||||
keepalive-time: ${AIIMAGE_DB_POOL_KEEPALIVE_TIME_MS:120000}
|
||||
leak-detection-threshold: ${AIIMAGE_DB_POOL_LEAK_DETECT_MS:0}
|
||||
data:
|
||||
redis:
|
||||
@@ -54,7 +58,7 @@ mybatis-plus:
|
||||
mapper-locations: classpath*:mapper/**/*.xml
|
||||
configuration:
|
||||
map-underscore-to-camel-case: true
|
||||
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
|
||||
log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
|
||||
|
||||
springdoc:
|
||||
api-docs:
|
||||
@@ -80,6 +84,10 @@ aiimage:
|
||||
access-key-id: ${AIIMAGE_TRANSIENT_STORAGE_ACCESS_KEY_ID:}
|
||||
access-key-secret: ${AIIMAGE_TRANSIENT_STORAGE_ACCESS_KEY_SECRET:}
|
||||
region: ${AIIMAGE_TRANSIENT_STORAGE_REGION:us-east-1}
|
||||
connect-timeout-seconds: ${AIIMAGE_TRANSIENT_STORAGE_CONNECT_TIMEOUT_SECONDS:10}
|
||||
read-timeout-seconds: ${AIIMAGE_TRANSIENT_STORAGE_READ_TIMEOUT_SECONDS:60}
|
||||
write-timeout-seconds: ${AIIMAGE_TRANSIENT_STORAGE_WRITE_TIMEOUT_SECONDS:60}
|
||||
upload-max-retries: ${AIIMAGE_TRANSIENT_STORAGE_UPLOAD_MAX_RETRIES:3}
|
||||
storage:
|
||||
local-temp-dir: ${AIIMAGE_STORAGE_LOCAL_TEMP_DIR:./data/tmp}
|
||||
cleanup-enabled: ${AIIMAGE_STORAGE_CLEANUP_ENABLED:true}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
SET @db_name = DATABASE();
|
||||
|
||||
SET @idx_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = @db_name
|
||||
AND TABLE_NAME = 'biz_shop_manage'
|
||||
AND INDEX_NAME = 'idx_group_id_id'
|
||||
);
|
||||
SET @sql := IF(@idx_exists = 0,
|
||||
'ALTER TABLE biz_shop_manage ADD INDEX idx_group_id_id (group_id, id)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @idx_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = @db_name
|
||||
AND TABLE_NAME = 'biz_shop_manage'
|
||||
AND INDEX_NAME = 'idx_created_by_id_id'
|
||||
);
|
||||
SET @sql := IF(@idx_exists = 0,
|
||||
'ALTER TABLE biz_shop_manage ADD INDEX idx_created_by_id_id (created_by_id, id)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @idx_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = @db_name
|
||||
AND TABLE_NAME = 'biz_skip_price_asin'
|
||||
AND INDEX_NAME = 'idx_group_id_id'
|
||||
);
|
||||
SET @sql := IF(@idx_exists = 0,
|
||||
'ALTER TABLE biz_skip_price_asin ADD INDEX idx_group_id_id (group_id, id)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @idx_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = @db_name
|
||||
AND TABLE_NAME = 'biz_query_asin'
|
||||
AND INDEX_NAME = 'idx_group_id_id'
|
||||
);
|
||||
SET @sql := IF(@idx_exists = 0,
|
||||
'ALTER TABLE biz_query_asin ADD INDEX idx_group_id_id (group_id, id)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
@@ -0,0 +1,31 @@
|
||||
SET @db_name = DATABASE();
|
||||
|
||||
SET @idx_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = @db_name
|
||||
AND TABLE_NAME = 'columns'
|
||||
AND INDEX_NAME = 'idx_menu_type_sort_id'
|
||||
);
|
||||
SET @sql := IF(@idx_exists = 0,
|
||||
'ALTER TABLE columns ADD INDEX idx_menu_type_sort_id (menu_type, sort_order, id)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @idx_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = @db_name
|
||||
AND TABLE_NAME = 'user_column_permission'
|
||||
AND INDEX_NAME = 'idx_user_column'
|
||||
);
|
||||
SET @sql := IF(@idx_exists = 0,
|
||||
'ALTER TABLE user_column_permission ADD INDEX idx_user_column (user_id, column_id)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
@@ -0,0 +1,31 @@
|
||||
SET @db_name = DATABASE();
|
||||
|
||||
SET @idx_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = @db_name
|
||||
AND TABLE_NAME = 'biz_file_result'
|
||||
AND INDEX_NAME = 'idx_file_result_task_module_id'
|
||||
);
|
||||
SET @sql := IF(@idx_exists = 0,
|
||||
'ALTER TABLE biz_file_result ADD INDEX idx_file_result_task_module_id (task_id, module_type, id)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @idx_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = @db_name
|
||||
AND TABLE_NAME = 'biz_task_file_job'
|
||||
AND INDEX_NAME = 'idx_file_job_status_retry_updated'
|
||||
);
|
||||
SET @sql := IF(@idx_exists = 0,
|
||||
'ALTER TABLE biz_task_file_job ADD INDEX idx_file_job_status_retry_updated (status, retry_count, updated_at)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
@@ -0,0 +1,106 @@
|
||||
SET @db_name = DATABASE();
|
||||
|
||||
SET @idx_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = @db_name
|
||||
AND TABLE_NAME = 'biz_skip_price_asin'
|
||||
AND INDEX_NAME = 'uk_group_shop_name'
|
||||
);
|
||||
SET @sql := IF(@idx_exists > 0,
|
||||
'ALTER TABLE biz_skip_price_asin DROP INDEX uk_group_shop_name',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @idx_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = @db_name
|
||||
AND TABLE_NAME = 'biz_skip_price_asin'
|
||||
AND INDEX_NAME = 'idx_group_shop_name'
|
||||
);
|
||||
SET @sql := IF(@idx_exists = 0,
|
||||
'ALTER TABLE biz_skip_price_asin ADD INDEX idx_group_shop_name (group_id, shop_name)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @idx_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = @db_name
|
||||
AND TABLE_NAME = 'biz_skip_price_asin'
|
||||
AND INDEX_NAME = 'idx_group_shop_asin_de'
|
||||
);
|
||||
SET @sql := IF(@idx_exists = 0,
|
||||
'ALTER TABLE biz_skip_price_asin ADD INDEX idx_group_shop_asin_de (group_id, shop_name, asin_de)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @idx_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = @db_name
|
||||
AND TABLE_NAME = 'biz_skip_price_asin'
|
||||
AND INDEX_NAME = 'idx_group_shop_asin_uk'
|
||||
);
|
||||
SET @sql := IF(@idx_exists = 0,
|
||||
'ALTER TABLE biz_skip_price_asin ADD INDEX idx_group_shop_asin_uk (group_id, shop_name, asin_uk)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @idx_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = @db_name
|
||||
AND TABLE_NAME = 'biz_skip_price_asin'
|
||||
AND INDEX_NAME = 'idx_group_shop_asin_fr'
|
||||
);
|
||||
SET @sql := IF(@idx_exists = 0,
|
||||
'ALTER TABLE biz_skip_price_asin ADD INDEX idx_group_shop_asin_fr (group_id, shop_name, asin_fr)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @idx_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = @db_name
|
||||
AND TABLE_NAME = 'biz_skip_price_asin'
|
||||
AND INDEX_NAME = 'idx_group_shop_asin_it'
|
||||
);
|
||||
SET @sql := IF(@idx_exists = 0,
|
||||
'ALTER TABLE biz_skip_price_asin ADD INDEX idx_group_shop_asin_it (group_id, shop_name, asin_it)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @idx_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = @db_name
|
||||
AND TABLE_NAME = 'biz_skip_price_asin'
|
||||
AND INDEX_NAME = 'idx_group_shop_asin_es'
|
||||
);
|
||||
SET @sql := IF(@idx_exists = 0,
|
||||
'ALTER TABLE biz_skip_price_asin ADD INDEX idx_group_shop_asin_es (group_id, shop_name, asin_es)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
@@ -4,9 +4,11 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
|
||||
import requests
|
||||
from flask import Blueprint, request, jsonify, session, current_app
|
||||
from requests.adapters import HTTPAdapter
|
||||
from flask import Blueprint, request, jsonify, session, current_app, g
|
||||
|
||||
import pymysql
|
||||
from werkzeug.security import generate_password_hash
|
||||
@@ -43,6 +45,9 @@ except ImportError:
|
||||
backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://127.0.0.1:18080').rstrip('/')
|
||||
|
||||
admin_api = Blueprint('admin_api', __name__, url_prefix='/api/admin')
|
||||
_backend_java_session_local = threading.local()
|
||||
_column_sort_schema_checked = False
|
||||
_column_sort_schema_lock = threading.Lock()
|
||||
|
||||
ADMIN_MENU_ACCESS_CONFIG = {
|
||||
'dedupe-total-data': {
|
||||
@@ -103,10 +108,22 @@ def _safe_version_key(version):
|
||||
return s or 'unknown'
|
||||
|
||||
|
||||
def _get_backend_java_session():
|
||||
http_session = getattr(_backend_java_session_local, 'session', None)
|
||||
if http_session is None:
|
||||
http_session = requests.Session()
|
||||
adapter = HTTPAdapter(pool_connections=8, pool_maxsize=32, max_retries=0)
|
||||
http_session.mount('http://', adapter)
|
||||
http_session.mount('https://', adapter)
|
||||
_backend_java_session_local.session = http_session
|
||||
return http_session
|
||||
|
||||
|
||||
def _proxy_backend_java(method, path, *, params=None, json_data=None, files=None, data=None, timeout=10):
|
||||
url = f"{backend_java_base_url}{path}"
|
||||
try:
|
||||
resp = requests.request(method=method, url=url, params=params, json=json_data, files=files, data=data, timeout=timeout)
|
||||
requester = requests if files else _get_backend_java_session()
|
||||
resp = requester.request(method=method, url=url, params=params, json=json_data, files=files, data=data, timeout=timeout)
|
||||
except requests.RequestException:
|
||||
return None, jsonify({'success': False, 'error': 'backend-java 服务不可用'}), 502
|
||||
try:
|
||||
@@ -146,6 +163,28 @@ def _parse_optional_int(value):
|
||||
|
||||
|
||||
def _ensure_column_sort_schema():
|
||||
global _column_sort_schema_checked
|
||||
if _column_sort_schema_checked:
|
||||
return
|
||||
with _column_sort_schema_lock:
|
||||
if _column_sort_schema_checked:
|
||||
return
|
||||
conn = get_db()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
try:
|
||||
cur.execute("ALTER TABLE columns ADD COLUMN sort_order INT NOT NULL DEFAULT 0 COMMENT '鑿滃崟鎺掑簭' AFTER route_path")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
cur.execute("UPDATE columns SET sort_order = id WHERE sort_order IS NULL OR sort_order = 0")
|
||||
except Exception:
|
||||
pass
|
||||
conn.commit()
|
||||
_column_sort_schema_checked = True
|
||||
finally:
|
||||
conn.close()
|
||||
return
|
||||
conn = get_db()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
@@ -299,6 +338,10 @@ def _get_current_admin_permission_sets():
|
||||
user_id = _get_current_admin_id()
|
||||
if not user_id:
|
||||
return set(), set()
|
||||
cache_key = f'_admin_permission_sets_{user_id}'
|
||||
cached = getattr(g, cache_key, None)
|
||||
if cached is not None:
|
||||
return cached
|
||||
conn = get_db()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
@@ -314,7 +357,9 @@ def _get_current_admin_permission_sets():
|
||||
conn.close()
|
||||
key_set = {(row.get('column_key') or '').strip() for row in rows if (row.get('column_key') or '').strip()}
|
||||
route_set = {(row.get('route_path') or '').strip() for row in rows if (row.get('route_path') or '').strip()}
|
||||
return key_set, route_set
|
||||
cached = (key_set, route_set)
|
||||
setattr(g, cache_key, cached)
|
||||
return cached
|
||||
|
||||
|
||||
def _ensure_admin_menu_access(*menu_names):
|
||||
@@ -830,14 +875,6 @@ def list_columns():
|
||||
if denied:
|
||||
return denied
|
||||
menu_type = (request.args.get('menu_type') or '').strip()
|
||||
params = {'menuType': menu_type} if menu_type else None
|
||||
result, error_response, status = _proxy_backend_java(
|
||||
'GET',
|
||||
'/api/admin/permission-menus',
|
||||
params=params,
|
||||
)
|
||||
if error_response is None:
|
||||
_sync_local_columns_from_backend([_format_permission_item(item) for item in (result.get('data') or [])])
|
||||
local_rows = _load_local_columns(menu_type or None)
|
||||
items = [
|
||||
{
|
||||
@@ -1484,32 +1521,7 @@ def _load_expanded_shop_manage_groups(role, current_row):
|
||||
if error_response is not None:
|
||||
return None, error_response, status
|
||||
|
||||
direct_groups = result.get('data') or []
|
||||
if role == 'super_admin':
|
||||
return direct_groups, None, 200
|
||||
|
||||
direct_ids = {item.get('id') for item in direct_groups if item.get('id')}
|
||||
leader_ids = {item.get('leaderUserId') for item in direct_groups if item.get('leaderUserId')}
|
||||
if not leader_ids:
|
||||
return direct_groups, None, 200
|
||||
|
||||
all_result, error_response, status = _proxy_backend_java(
|
||||
'GET',
|
||||
'/api/admin/shop-manages/groups',
|
||||
params={'superAdmin': 'true'},
|
||||
)
|
||||
if error_response is not None:
|
||||
return None, error_response, status
|
||||
|
||||
merged = []
|
||||
seen_ids = set()
|
||||
for item in all_result.get('data') or []:
|
||||
item_id = item.get('id')
|
||||
if item_id in direct_ids or item.get('leaderUserId') in leader_ids:
|
||||
if item_id not in seen_ids:
|
||||
merged.append(item)
|
||||
seen_ids.add(item_id)
|
||||
return merged, None, 200
|
||||
return result.get('data') or [], None, 200
|
||||
|
||||
|
||||
@admin_api.route('/shop-manages')
|
||||
@@ -1523,59 +1535,6 @@ def list_shop_manages():
|
||||
group_id_raw = (request.args.get('group_id') or '').strip()
|
||||
shop_name = (request.args.get('shop_name') or '').strip()
|
||||
|
||||
if role != 'super_admin':
|
||||
groups, error_response, status = _load_expanded_shop_manage_groups(role, current_row)
|
||||
if error_response is not None:
|
||||
return error_response, status
|
||||
accessible_group_ids = [item.get('id') for item in (groups or []) if item.get('id')]
|
||||
selected_group_id = None
|
||||
if group_id_raw:
|
||||
try:
|
||||
selected_group_id = int(group_id_raw)
|
||||
except (TypeError, ValueError):
|
||||
selected_group_id = None
|
||||
if selected_group_id and selected_group_id not in accessible_group_ids:
|
||||
return jsonify({'success': True, 'items': [], 'total': 0, 'page': page, 'page_size': page_size})
|
||||
query_group_ids = [selected_group_id] if selected_group_id else accessible_group_ids
|
||||
all_items = []
|
||||
for group_id in query_group_ids:
|
||||
group_page = 1
|
||||
while True:
|
||||
group_params = {
|
||||
'page': group_page,
|
||||
'pageSize': 100,
|
||||
'groupId': group_id,
|
||||
'superAdmin': 'true',
|
||||
}
|
||||
if current_row and current_row.get('id'):
|
||||
group_params['operatorId'] = current_row.get('id')
|
||||
if shop_name:
|
||||
group_params['shopName'] = shop_name
|
||||
result, error_response, status = _proxy_backend_java(
|
||||
'GET',
|
||||
'/api/admin/shop-manages',
|
||||
params=group_params,
|
||||
)
|
||||
if error_response is not None:
|
||||
return error_response, status
|
||||
payload = result.get('data') or {}
|
||||
rows = payload.get('items') or []
|
||||
all_items.extend(rows)
|
||||
total = payload.get('total') or 0
|
||||
if group_page * 100 >= total or not rows:
|
||||
break
|
||||
group_page += 1
|
||||
all_items.sort(key=lambda item: item.get('id') or 0, reverse=True)
|
||||
total = len(all_items)
|
||||
offset = (page - 1) * page_size
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'items': [_format_shop_manage_item(item) for item in all_items[offset:offset + page_size]],
|
||||
'total': total,
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
})
|
||||
|
||||
params = {'page': page, 'pageSize': page_size}
|
||||
if current_row and current_row.get('id'):
|
||||
params['operatorId'] = current_row.get('id')
|
||||
@@ -2031,6 +1990,116 @@ def update_skip_price_asin_country(item_id, country):
|
||||
})
|
||||
|
||||
|
||||
@admin_api.route('/skip-price-asins/import/<import_id>')
|
||||
@login_required
|
||||
def skip_price_asin_import_progress(import_id):
|
||||
role, current_row, denied = _ensure_backend_menu_access('skip-price-asin')
|
||||
if denied:
|
||||
return denied
|
||||
result, error_response, status = _proxy_backend_java(
|
||||
'GET',
|
||||
f'/api/admin/skip-price-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('/skip-price-asins/import', methods=['POST'])
|
||||
@login_required
|
||||
def import_skip_price_asins():
|
||||
role, current_row, denied = _ensure_backend_menu_access('skip-price-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()
|
||||
if not group_id_raw:
|
||||
return jsonify({'success': False, 'error': '请先选择分组'})
|
||||
params = {
|
||||
'groupId': group_id_raw,
|
||||
'operatorId': current_row.get('id') if current_row else None,
|
||||
'superAdmin': 'true' if role == 'super_admin' else 'false',
|
||||
}
|
||||
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/skip-price-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('/skip-price-asins/delete-import/<import_id>')
|
||||
@login_required
|
||||
def skip_price_asin_delete_import_progress(import_id):
|
||||
role, current_row, denied = _ensure_backend_menu_access('skip-price-asin')
|
||||
if denied:
|
||||
return denied
|
||||
result, error_response, status = _proxy_backend_java(
|
||||
'GET',
|
||||
f'/api/admin/skip-price-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('/skip-price-asins/delete-import', methods=['POST'])
|
||||
@login_required
|
||||
def delete_import_skip_price_asins():
|
||||
role, current_row, denied = _ensure_backend_menu_access('skip-price-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()
|
||||
if not group_id_raw:
|
||||
return jsonify({'success': False, 'error': '请先选择分组'})
|
||||
params = {
|
||||
'groupId': group_id_raw,
|
||||
'operatorId': current_row.get('id') if current_row else None,
|
||||
'superAdmin': 'true' if role == 'super_admin' else 'false',
|
||||
}
|
||||
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/skip-price-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 '',
|
||||
})
|
||||
|
||||
|
||||
def _format_query_asin_item(item):
|
||||
return {
|
||||
'id': item.get('id'),
|
||||
|
||||
@@ -23,9 +23,10 @@ bucket_path = "nanri-image/"
|
||||
file_url_pre = f"https://{bucket}.oss-cn-hangzhou.aliyuncs.com/"
|
||||
|
||||
import os
|
||||
# backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://127.0.0.1:18080').rstrip('/')
|
||||
backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://127.0.0.1:18080').rstrip('/')
|
||||
# backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://8.136.19.173:18080').rstrip('/')
|
||||
backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://47.111.163.154:18080').rstrip('/')
|
||||
# backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://47.111.163.154:18080').rstrip('/')
|
||||
# backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://121.196.149.225:18080').rstrip('/')
|
||||
os.environ['OSS_ACCESS_KEY_ID'] = accessKeyId
|
||||
os.environ['OSS_ACCESS_KEY_SECRET'] = accessKeySecret
|
||||
os.environ['SECRET_KEY'] = "ddffc7c1d02121d9554d7b080b2511b6"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"""
|
||||
from functools import wraps
|
||||
|
||||
from flask import request, redirect, url_for, session, jsonify
|
||||
from flask import request, redirect, url_for, session, jsonify, g
|
||||
|
||||
from utils.db import get_db
|
||||
|
||||
@@ -13,15 +13,22 @@ def is_session_user_valid():
|
||||
uid = session.get('user_id')
|
||||
if not uid:
|
||||
return False
|
||||
cached_user = getattr(g, '_current_user_row', None)
|
||||
if cached_user and cached_user.get('id') == uid:
|
||||
return True
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT id FROM users WHERE id = %s", (uid,))
|
||||
cur.execute(
|
||||
"SELECT id, username, is_admin, role, created_by_id FROM users WHERE id = %s",
|
||||
(uid,)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.close()
|
||||
if not row:
|
||||
session.clear()
|
||||
return False
|
||||
g._current_user_row = row
|
||||
return True
|
||||
except Exception:
|
||||
session.clear()
|
||||
@@ -34,16 +41,19 @@ def get_current_admin_role():
|
||||
if not uid:
|
||||
return None, None
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT id, username, is_admin, role, created_by_id FROM users WHERE id = %s",
|
||||
(uid,)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.close()
|
||||
row = getattr(g, '_current_user_row', None)
|
||||
if not row or row.get('id') != uid:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT id, username, is_admin, role, created_by_id FROM users WHERE id = %s",
|
||||
(uid,)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.close()
|
||||
if not row:
|
||||
return None, None
|
||||
g._current_user_row = row
|
||||
role = (row.get('role') or '').strip().lower()
|
||||
if not role:
|
||||
role = 'super_admin' if row.get('is_admin') and row.get('created_by_id') is None else (
|
||||
|
||||
@@ -6,12 +6,20 @@ import pymysql
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
try:
|
||||
from config import mysql_host, mysql_user, mysql_password, mysql_database
|
||||
from config import mysql_host as config_mysql_host
|
||||
from config import mysql_user as config_mysql_user
|
||||
from config import mysql_password as config_mysql_password
|
||||
from config import mysql_database as config_mysql_database
|
||||
except ImportError:
|
||||
mysql_host = os.environ.get('MYSQL_HOST', 'localhost')
|
||||
mysql_user = os.environ.get('MYSQL_USER', 'root')
|
||||
mysql_password = os.environ.get('MYSQL_PASSWORD', '')
|
||||
mysql_database = os.environ.get('MYSQL_DATABASE', 'maixiang_ai')
|
||||
config_mysql_host = 'localhost'
|
||||
config_mysql_user = 'root'
|
||||
config_mysql_password = ''
|
||||
config_mysql_database = 'maixiang_ai'
|
||||
|
||||
mysql_host = os.environ.get('MYSQL_HOST', config_mysql_host)
|
||||
mysql_user = os.environ.get('MYSQL_USER', config_mysql_user)
|
||||
mysql_password = os.environ.get('MYSQL_PASSWORD', config_mysql_password)
|
||||
mysql_database = os.environ.get('MYSQL_DATABASE', config_mysql_database)
|
||||
|
||||
|
||||
def get_db():
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -153,6 +153,7 @@ import {
|
||||
import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
|
||||
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
||||
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
|
||||
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
|
||||
|
||||
const selectedFileNames = ref<string[]>([])
|
||||
const uploadedFiles = ref<UploadFileVo[]>([])
|
||||
@@ -169,8 +170,11 @@ const parsing = ref(false)
|
||||
const pushing = ref(false)
|
||||
const queuePayloadText = ref('')
|
||||
const pollingTaskIds = ref<number[]>([])
|
||||
const pendingFileTaskIds = ref<number[]>([])
|
||||
const pollTimer = ref<number | null>(null)
|
||||
const pollingInFlight = ref(false)
|
||||
let disposed = false
|
||||
const timers = createCategorizedTimers('appearance-patent')
|
||||
|
||||
const dashboard = ref<AppearancePatentDashboardVo>({
|
||||
pendingTaskCount: 0,
|
||||
@@ -201,12 +205,14 @@ function pollingKey() {
|
||||
|
||||
function savePollingIds() {
|
||||
if (typeof window === 'undefined') return
|
||||
if (!pollingTaskIds.value.length) window.localStorage.removeItem(pollingKey())
|
||||
else window.localStorage.setItem(pollingKey(), JSON.stringify(pollingTaskIds.value))
|
||||
const ids = Array.from(new Set([...pollingTaskIds.value, ...pendingFileTaskIds.value]))
|
||||
if (!ids.length) window.localStorage.removeItem(pollingKey())
|
||||
else window.localStorage.setItem(pollingKey(), JSON.stringify(ids))
|
||||
}
|
||||
|
||||
function clearPollingIds() {
|
||||
pollingTaskIds.value = []
|
||||
pendingFileTaskIds.value = []
|
||||
savePollingIds()
|
||||
stopPolling()
|
||||
}
|
||||
@@ -388,65 +394,114 @@ function removePollingTask(taskId: number) {
|
||||
savePollingIds()
|
||||
}
|
||||
|
||||
function addPendingFileTask(taskId: number) {
|
||||
if (!pendingFileTaskIds.value.includes(taskId)) {
|
||||
pendingFileTaskIds.value = [...pendingFileTaskIds.value, taskId]
|
||||
savePollingIds()
|
||||
}
|
||||
}
|
||||
|
||||
function removePendingFileTask(taskId: number) {
|
||||
pendingFileTaskIds.value = pendingFileTaskIds.value.filter((id) => id !== taskId)
|
||||
savePollingIds()
|
||||
}
|
||||
|
||||
function ensurePolling() {
|
||||
if (disposed) return
|
||||
if (pollTimer.value != null) return
|
||||
scheduleNextPoll(true)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer.value != null) {
|
||||
window.clearTimeout(pollTimer.value)
|
||||
timers.clearTimer('task-poll', pollTimer.value)
|
||||
pollTimer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleNextPoll(immediate = false) {
|
||||
if (disposed) return
|
||||
if (pollTimer.value != null) {
|
||||
if (!immediate) return
|
||||
window.clearTimeout(pollTimer.value)
|
||||
timers.clearTimer('task-poll', pollTimer.value)
|
||||
pollTimer.value = null
|
||||
}
|
||||
const run = async () => {
|
||||
pollTimer.value = null
|
||||
if (!pollingTaskIds.value.length) return
|
||||
if (disposed) return
|
||||
if (!pollingTaskIds.value.length && !pendingFileTaskIds.value.length) return
|
||||
await refreshTaskProgress()
|
||||
if (pollingTaskIds.value.length) {
|
||||
pollTimer.value = window.setTimeout(run, getTaskPollIntervalMs())
|
||||
if (!disposed && (pollingTaskIds.value.length || pendingFileTaskIds.value.length)) {
|
||||
pollTimer.value = timers.setTimeout('task-poll', run, getTaskPollIntervalMs())
|
||||
}
|
||||
}
|
||||
if (immediate) void run()
|
||||
else pollTimer.value = window.setTimeout(run, getTaskPollIntervalMs())
|
||||
else pollTimer.value = timers.setTimeout('task-poll', run, getTaskPollIntervalMs())
|
||||
}
|
||||
|
||||
async function refreshTaskProgress() {
|
||||
if (pollingInFlight.value || !pollingTaskIds.value.length) {
|
||||
if (!pollingTaskIds.value.length) stopPolling()
|
||||
if (pollingInFlight.value || (!pollingTaskIds.value.length && !pendingFileTaskIds.value.length)) {
|
||||
if (!pollingTaskIds.value.length && !pendingFileTaskIds.value.length) stopPolling()
|
||||
return
|
||||
}
|
||||
pollingInFlight.value = true
|
||||
try {
|
||||
const batch = await getAppearancePatentTaskProgressBatch(pollingTaskIds.value)
|
||||
let hasTerminalTask = false
|
||||
for (const detail of batch.items || []) {
|
||||
const task = detail.task
|
||||
if (!task?.id) continue
|
||||
if (task.status === 'SUCCESS' || task.status === 'FAILED') {
|
||||
removePollingTask(task.id)
|
||||
hasTerminalTask = true
|
||||
let shouldRefreshHistory = pendingFileTaskIds.value.length > 0
|
||||
if (pollingTaskIds.value.length) {
|
||||
const batch = await getAppearancePatentTaskProgressBatch(pollingTaskIds.value)
|
||||
for (const detail of batch.items || []) {
|
||||
const task = detail.task
|
||||
if (!task?.id) continue
|
||||
if (task.status === 'SUCCESS' || task.status === 'FAILED') {
|
||||
removePollingTask(task.id)
|
||||
shouldRefreshHistory = true
|
||||
if (task.status === 'SUCCESS') addPendingFileTask(task.id)
|
||||
}
|
||||
}
|
||||
if (batch.missingTaskIds?.length) {
|
||||
batch.missingTaskIds.forEach((taskId) => {
|
||||
removePollingTask(taskId)
|
||||
removePendingFileTask(taskId)
|
||||
})
|
||||
shouldRefreshHistory = true
|
||||
}
|
||||
}
|
||||
if (batch.missingTaskIds?.length) {
|
||||
batch.missingTaskIds.forEach((taskId) => removePollingTask(taskId))
|
||||
}
|
||||
if (hasTerminalTask) {
|
||||
if (shouldRefreshHistory) {
|
||||
await loadDashboard()
|
||||
await loadHistory()
|
||||
settlePendingFileTasks()
|
||||
}
|
||||
} finally {
|
||||
pollingInFlight.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function settlePendingFileTasks() {
|
||||
if (!pendingFileTaskIds.value.length) return
|
||||
const remaining: number[] = []
|
||||
for (const taskId of pendingFileTaskIds.value) {
|
||||
const item = historyItems.value.find((row) => row.taskId === taskId)
|
||||
if (!item) continue
|
||||
const taskStatus = (item.taskStatus || '').toUpperCase()
|
||||
const fileStatus = (item.fileStatus || '').toUpperCase()
|
||||
if (taskStatus === 'FAILED' || item.fileReady || fileStatus === 'SUCCESS' || fileStatus === 'FAILED') {
|
||||
continue
|
||||
}
|
||||
remaining.push(taskId)
|
||||
}
|
||||
pendingFileTaskIds.value = remaining
|
||||
savePollingIds()
|
||||
}
|
||||
|
||||
function seedPendingFileTasksFromHistory() {
|
||||
const ids = historyItems.value
|
||||
.filter((item) => item.taskId != null && isResultPreparing(item))
|
||||
.map((item) => item.taskId as number)
|
||||
pendingFileTaskIds.value = Array.from(new Set(ids))
|
||||
savePollingIds()
|
||||
if (pendingFileTaskIds.value.length) ensurePolling()
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
dashboard.value = await getAppearancePatentDashboard()
|
||||
}
|
||||
@@ -545,9 +600,14 @@ onMounted(async () => {
|
||||
loadDashboard().catch(() => undefined),
|
||||
loadHistory().catch(() => undefined),
|
||||
])
|
||||
seedPendingFileTasksFromHistory()
|
||||
})
|
||||
|
||||
onUnmounted(() => stopPolling())
|
||||
onUnmounted(() => {
|
||||
disposed = true
|
||||
stopPolling()
|
||||
timers.clearScope()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -226,6 +226,7 @@ import {
|
||||
} from '@/shared/api/java-modules'
|
||||
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
|
||||
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
|
||||
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
|
||||
|
||||
interface SessionDeleteBrandItem extends DeleteBrandResultItem {
|
||||
_pushed?: boolean
|
||||
@@ -263,6 +264,8 @@ const activeItemKey = ref('')
|
||||
const autoAdvancing = ref(false)
|
||||
const autoRetryTimer = ref<number | null>(null)
|
||||
const waitingForBackendRecovery = ref(false)
|
||||
let disposed = false
|
||||
const timers = createCategorizedTimers('delete-brand')
|
||||
const displayPaths = computed(() => selectedPaths.value.slice(0, 10))
|
||||
|
||||
const currentSectionItems = computed(() => {
|
||||
@@ -892,14 +895,16 @@ function getPollIntervalMs() {
|
||||
}
|
||||
|
||||
function scheduleNextPoll(immediate = false) {
|
||||
if (disposed) return
|
||||
if (pollTimer.value) {
|
||||
if (!immediate) return
|
||||
clearTimeout(pollTimer.value)
|
||||
timers.clearTimer('task-poll', pollTimer.value)
|
||||
pollTimer.value = null
|
||||
}
|
||||
|
||||
const executePoll = async () => {
|
||||
pollTimer.value = null
|
||||
if (disposed) return
|
||||
if (pollingInFlight.value) {
|
||||
scheduleNextPoll()
|
||||
return
|
||||
@@ -933,7 +938,7 @@ function scheduleNextPoll(immediate = false) {
|
||||
syncResultState()
|
||||
}
|
||||
|
||||
if (getPollingTaskIds().length > 0) {
|
||||
if (!disposed && getPollingTaskIds().length > 0) {
|
||||
scheduleNextPoll()
|
||||
}
|
||||
}
|
||||
@@ -941,7 +946,7 @@ function scheduleNextPoll(immediate = false) {
|
||||
if (immediate) {
|
||||
executePoll()
|
||||
} else {
|
||||
pollTimer.value = window.setTimeout(executePoll, getPollIntervalMs())
|
||||
pollTimer.value = timers.setTimeout('task-poll', executePoll, getPollIntervalMs())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -969,7 +974,7 @@ function getTaskStatusInfo(item: DeleteBrandResultItem) {
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer.value) {
|
||||
window.clearTimeout(pollTimer.value)
|
||||
timers.clearTimer('task-poll', pollTimer.value)
|
||||
pollTimer.value = null
|
||||
}
|
||||
clearAutoRetryTimer()
|
||||
@@ -1178,16 +1183,17 @@ function debugAutoAdvance(step: string, payload?: Record<string, unknown>) {
|
||||
|
||||
function clearAutoRetryTimer() {
|
||||
if (autoRetryTimer.value) {
|
||||
window.clearTimeout(autoRetryTimer.value)
|
||||
timers.clearTimer('auto-retry', autoRetryTimer.value)
|
||||
autoRetryTimer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleAutoAdvanceRetry(delayMs = 3000) {
|
||||
if (disposed) return
|
||||
clearAutoRetryTimer()
|
||||
autoRetryTimer.value = window.setTimeout(() => {
|
||||
autoRetryTimer.value = timers.setTimeout('auto-retry', () => {
|
||||
autoRetryTimer.value = null
|
||||
maybeAutoAdvance().catch(() => undefined)
|
||||
if (!disposed) maybeAutoAdvance().catch(() => undefined)
|
||||
}, delayMs)
|
||||
}
|
||||
|
||||
@@ -1428,7 +1434,9 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
disposed = true
|
||||
stopPolling()
|
||||
timers.clearScope()
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
@@ -334,6 +334,7 @@ import {
|
||||
} from "@/shared/api/java-modules";
|
||||
import { getPywebviewApi } from "@/shared/bridges/pywebview";
|
||||
import { getTaskPollIntervalMs } from "@/shared/task-progress-config";
|
||||
import { createCategorizedTimers } from "@/shared/utils/categorized-timers";
|
||||
|
||||
const COUNTRY_TEMPLATE = ["德国", "英国", "法国", "意大利", "西班牙"] as const;
|
||||
const MAX_TRANSIENT_ERRORS = 30;
|
||||
@@ -360,6 +361,7 @@ const activeQueueItem = ref<PatrolDeleteShopQueueItem | null>(null);
|
||||
const queueWorkerRunning = ref(false);
|
||||
const autoQueueEnabled = ref(false);
|
||||
let historyPollTimer: number | null = null;
|
||||
const timers = createCategorizedTimers("patrol-delete");
|
||||
|
||||
const matchedRunnableItems = computed(() =>
|
||||
matchedItems.value.filter((item) => item.matched),
|
||||
@@ -399,8 +401,15 @@ function historyItemKey(item: PatrolDeleteHistoryItem) {
|
||||
return `${item.taskId ?? 0}:${item.resultId ?? 0}`;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
if (disposed) return Promise.resolve();
|
||||
return timers.sleep("queue-wait", ms);
|
||||
}
|
||||
|
||||
function clearSleepTimers() {
|
||||
timers.clearCategory("queue-wait");
|
||||
}
|
||||
|
||||
function isTransientBackendError(error: unknown) {
|
||||
@@ -425,6 +434,7 @@ async function withTransientRetry<T>(
|
||||
) {
|
||||
let attempt = 0;
|
||||
while (true) {
|
||||
if (disposed) throw new Error("component disposed");
|
||||
try {
|
||||
return await action();
|
||||
} catch (error) {
|
||||
@@ -716,21 +726,21 @@ async function refreshActiveTaskProgress(taskIds?: number[]) {
|
||||
|
||||
function startHistoryPolling() {
|
||||
stopHistoryPolling();
|
||||
if (!hasQueueWork.value) return;
|
||||
if (disposed || !hasQueueWork.value) return;
|
||||
const run = async () => {
|
||||
historyPollTimer = null;
|
||||
if (!hasQueueWork.value) return;
|
||||
if (disposed || !hasQueueWork.value) return;
|
||||
await refreshActiveTaskProgress();
|
||||
if (hasQueueWork.value) {
|
||||
historyPollTimer = window.setTimeout(run, getTaskPollIntervalMs() * 2);
|
||||
if (!disposed && hasQueueWork.value) {
|
||||
historyPollTimer = timers.setTimeout("history-poll", run, getTaskPollIntervalMs() * 2);
|
||||
}
|
||||
};
|
||||
historyPollTimer = window.setTimeout(run, getTaskPollIntervalMs() * 2);
|
||||
historyPollTimer = timers.setTimeout("history-poll", run, getTaskPollIntervalMs() * 2);
|
||||
}
|
||||
|
||||
function stopHistoryPolling() {
|
||||
if (historyPollTimer) {
|
||||
window.clearTimeout(historyPollTimer);
|
||||
timers.clearTimer("history-poll", historyPollTimer);
|
||||
historyPollTimer = null;
|
||||
}
|
||||
}
|
||||
@@ -865,6 +875,7 @@ function findHistoryItemByTaskId(taskId: number) {
|
||||
async function waitForTaskTerminal(taskId: number) {
|
||||
let transientErrorCount = 0;
|
||||
while (true) {
|
||||
if (disposed) return "STOPPED";
|
||||
try {
|
||||
await refreshActiveTaskProgress([taskId]);
|
||||
if (transientErrorCount > 0) {
|
||||
@@ -893,7 +904,7 @@ async function waitForTaskTerminal(taskId: number) {
|
||||
}
|
||||
|
||||
async function processQueue() {
|
||||
if (queueWorkerRunning.value) return;
|
||||
if (disposed || queueWorkerRunning.value) return;
|
||||
queueWorkerRunning.value = true;
|
||||
pushing.value = true;
|
||||
startHistoryPolling();
|
||||
@@ -904,7 +915,7 @@ async function processQueue() {
|
||||
throw new Error("当前客户端未提供 enqueue_json");
|
||||
}
|
||||
|
||||
while (activeTaskId.value || pendingQueue.value.length) {
|
||||
while (!disposed && (activeTaskId.value || pendingQueue.value.length)) {
|
||||
if (activeTaskId.value) {
|
||||
const finalStatus = await waitForTaskTerminal(activeTaskId.value);
|
||||
queuePushResult.value =
|
||||
@@ -975,6 +986,7 @@ async function processQueue() {
|
||||
await refreshTaskViews();
|
||||
ElMessage.success("巡店删除队列已按顺序执行完成");
|
||||
} catch (error) {
|
||||
if (disposed) return;
|
||||
const message = error instanceof Error ? error.message : "队列执行失败";
|
||||
queuePushResult.value = message;
|
||||
ElMessage.error(message);
|
||||
@@ -1061,7 +1073,10 @@ onMounted(async () => {
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
disposed = true;
|
||||
stopHistoryPolling();
|
||||
clearSleepTimers();
|
||||
timers.clearScope();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -265,6 +265,7 @@ import { LISTING_FILTER_OPTIONS, type ListingFilterValue } from '@/pages/brand/c
|
||||
import { expandBrandFolderRecursive } from '@/shared/api/brand'
|
||||
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
|
||||
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
|
||||
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
|
||||
import {
|
||||
addPriceTrackCandidate,
|
||||
completePriceTrackLoopChild,
|
||||
@@ -369,6 +370,17 @@ const taskDetails = ref<Record<number, string>>({})
|
||||
const taskSnapshots = ref<Record<number, PriceTrackTaskDetailVo>>({})
|
||||
const pollTimer = ref<number | null>(null)
|
||||
const pollingInFlight = ref(false)
|
||||
let disposed = false
|
||||
const timers = createCategorizedTimers('price-track')
|
||||
|
||||
function sleep(ms: number) {
|
||||
if (disposed) return Promise.resolve()
|
||||
return timers.sleep('queue-wait', ms)
|
||||
}
|
||||
|
||||
function clearSleepTimers() {
|
||||
timers.clearCategory('queue-wait')
|
||||
}
|
||||
|
||||
function uidForStorage() {
|
||||
return typeof window !== 'undefined' ? window.localStorage.getItem('uid') || '0' : '0'
|
||||
@@ -649,8 +661,8 @@ function onCountryDrop(toIndex: number) {
|
||||
}
|
||||
|
||||
function scheduleSaveCountryPref() {
|
||||
if (countryPrefSaveTimer) clearTimeout(countryPrefSaveTimer)
|
||||
countryPrefSaveTimer = setTimeout(() => {
|
||||
if (countryPrefSaveTimer) timers.clearTimer('preference-save', countryPrefSaveTimer)
|
||||
countryPrefSaveTimer = timers.setTimeout('preference-save', () => {
|
||||
countryPrefSaveTimer = null
|
||||
void persistCountryPref()
|
||||
}, 450)
|
||||
@@ -1043,6 +1055,7 @@ async function waitForTaskTerminal(taskId: number) {
|
||||
let transientErrorCount = 0
|
||||
const maxTransientErrors = 30
|
||||
while (true) {
|
||||
if (disposed) return 'STOPPED'
|
||||
try {
|
||||
const batch = await getPriceTrackTaskProgressBatch([taskId])
|
||||
if (transientErrorCount > 0) {
|
||||
@@ -1086,10 +1099,10 @@ async function waitForTaskTerminal(taskId: number) {
|
||||
if (transientErrorCount === 1 || transientErrorCount % 5 === 0) {
|
||||
ElMessage.warning(`任务 ${taskId} 运行中,后端服务暂时不可用,正在自动重试`)
|
||||
}
|
||||
await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) })
|
||||
await sleep(getPollIntervalMs())
|
||||
continue
|
||||
}
|
||||
await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) })
|
||||
await sleep(getPollIntervalMs())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1103,6 +1116,7 @@ function nextMatchedQueueItem() {
|
||||
}
|
||||
|
||||
async function processMatchedQueue() {
|
||||
if (disposed) return
|
||||
if (queueWorkerRunning.value) {
|
||||
return
|
||||
}
|
||||
@@ -1131,7 +1145,7 @@ async function processMatchedQueue() {
|
||||
let failedCount = 0
|
||||
try {
|
||||
let index = 0
|
||||
while (autoQueueEnabled.value) {
|
||||
while (!disposed && autoQueueEnabled.value) {
|
||||
const row = nextMatchedQueueItem()
|
||||
if (!row) {
|
||||
break
|
||||
@@ -1141,6 +1155,7 @@ async function processMatchedQueue() {
|
||||
let attempt = 0
|
||||
const taskVo = await (async () => {
|
||||
while (true) {
|
||||
if (disposed) throw new Error('component disposed')
|
||||
try {
|
||||
return await createPriceTrackTask({
|
||||
userId: 0,
|
||||
@@ -1156,7 +1171,7 @@ async function processMatchedQueue() {
|
||||
attempt += 1
|
||||
if (!transient || attempt >= 10) throw error
|
||||
queuePushResult.value = '后端服务暂时不可用,正在重试创建任务(' + attempt + '/10)...'
|
||||
await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) })
|
||||
await sleep(getPollIntervalMs())
|
||||
}
|
||||
}
|
||||
})()
|
||||
@@ -1197,6 +1212,7 @@ async function processMatchedQueue() {
|
||||
successCount += 1
|
||||
queuePushResult.value = '任务 ' + taskVo.taskId + ' 已完成'
|
||||
} catch (error) {
|
||||
if (disposed) break
|
||||
failedCount += 1
|
||||
queuePushResult.value = '第 ' + index + ' 条任务处理失败,已自动继续下一条:' + (error instanceof Error ? error.message : '未知错误')
|
||||
ElMessage.error(queuePushResult.value)
|
||||
@@ -1208,6 +1224,7 @@ async function processMatchedQueue() {
|
||||
ElMessage.success('店铺任务推送已完成:成功 ' + successCount + ' 条,失败 ' + failedCount + ' 条')
|
||||
}
|
||||
} catch (e) {
|
||||
if (disposed) return
|
||||
queuePushResult.value = e instanceof Error ? e.message : '推送异常'
|
||||
ElMessage.error(queuePushResult.value)
|
||||
} finally {
|
||||
@@ -1282,6 +1299,7 @@ async function stopLoopExecution() {
|
||||
}
|
||||
|
||||
async function runLoopExecution(loopId?: number) {
|
||||
if (disposed) return
|
||||
if (loopDispatching.value) return
|
||||
if (loopId && activeLoopRunId.value !== loopId) {
|
||||
activeLoopRunId.value = loopId
|
||||
@@ -1294,7 +1312,7 @@ async function runLoopExecution(loopId?: number) {
|
||||
}
|
||||
loopDispatching.value = true
|
||||
try {
|
||||
while (activeLoopRunId.value) {
|
||||
while (!disposed && activeLoopRunId.value) {
|
||||
const loop = await syncActiveLoopRun()
|
||||
if (!loop || loop.status === 'SUCCESS' || loop.status === 'FAILED' || loop.status === 'STOPPED') break
|
||||
|
||||
@@ -1306,6 +1324,7 @@ async function runLoopExecution(loopId?: number) {
|
||||
let completeAttempt = 0
|
||||
const updatedLoop = await (async () => {
|
||||
while (true) {
|
||||
if (disposed) throw new Error('component disposed')
|
||||
try {
|
||||
return await completePriceTrackLoopChild(loop.id, activeTaskId)
|
||||
} catch (error) {
|
||||
@@ -1314,7 +1333,7 @@ async function runLoopExecution(loopId?: number) {
|
||||
completeAttempt += 1
|
||||
if (!transient || completeAttempt >= 10) throw error
|
||||
queuePushResult.value = `循环任务 ${loop.id} 等待后端恢复后确认子任务完成(${completeAttempt}/10)...`
|
||||
await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) })
|
||||
await sleep(getPollIntervalMs())
|
||||
}
|
||||
}
|
||||
})()
|
||||
@@ -1332,6 +1351,7 @@ async function runLoopExecution(loopId?: number) {
|
||||
let dispatchAttempt = 0
|
||||
const dispatch = await (async () => {
|
||||
while (true) {
|
||||
if (disposed) throw new Error('component disposed')
|
||||
try {
|
||||
return await dispatchNextPriceTrackLoopRun(loop.id)
|
||||
} catch (error) {
|
||||
@@ -1340,7 +1360,7 @@ async function runLoopExecution(loopId?: number) {
|
||||
dispatchAttempt += 1
|
||||
if (!transient || dispatchAttempt >= 10) throw error
|
||||
queuePushResult.value = `循环任务 ${loop.id} 等待后端恢复后继续派发(${dispatchAttempt}/10)...`
|
||||
await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) })
|
||||
await sleep(getPollIntervalMs())
|
||||
}
|
||||
}
|
||||
})()
|
||||
@@ -1355,6 +1375,7 @@ async function runLoopExecution(loopId?: number) {
|
||||
let createAttempt = 0
|
||||
const taskVo = await (async () => {
|
||||
while (true) {
|
||||
if (disposed) throw new Error('component disposed')
|
||||
try {
|
||||
return await createPriceTrackTask(childTaskRequest)
|
||||
} catch (error) {
|
||||
@@ -1363,7 +1384,7 @@ async function runLoopExecution(loopId?: number) {
|
||||
createAttempt += 1
|
||||
if (!transient || createAttempt >= 10) throw error
|
||||
queuePushResult.value = `循环任务 ${loop.id} 等待后端恢复后重试创建子任务(${createAttempt}/10)...`
|
||||
await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) })
|
||||
await sleep(getPollIntervalMs())
|
||||
}
|
||||
}
|
||||
})()
|
||||
@@ -1483,25 +1504,27 @@ async function refreshTaskBatch() {
|
||||
}
|
||||
|
||||
function scheduleNextPoll(immediate = false) {
|
||||
if (disposed) return
|
||||
if (pollTimer.value) {
|
||||
if (!immediate) return
|
||||
window.clearTimeout(pollTimer.value)
|
||||
timers.clearTimer('task-poll', pollTimer.value)
|
||||
pollTimer.value = null
|
||||
}
|
||||
const run = async () => {
|
||||
pollTimer.value = null
|
||||
if (disposed) return
|
||||
if (pollingInFlight.value) { scheduleNextPoll(); return }
|
||||
if (!pollingTaskIds.value.length) return
|
||||
pollingInFlight.value = true
|
||||
try { await refreshTaskBatch() } finally { pollingInFlight.value = false }
|
||||
if (pollingTaskIds.value.length > 0) pollTimer.value = window.setTimeout(run, getPollIntervalMs())
|
||||
if (!disposed && pollingTaskIds.value.length > 0) pollTimer.value = timers.setTimeout('task-poll', run, getPollIntervalMs())
|
||||
}
|
||||
if (immediate) void run()
|
||||
else pollTimer.value = window.setTimeout(run, getPollIntervalMs())
|
||||
else pollTimer.value = timers.setTimeout('task-poll', run, getPollIntervalMs())
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer.value) { window.clearTimeout(pollTimer.value); pollTimer.value = null }
|
||||
if (pollTimer.value) { timers.clearTimer('task-poll', pollTimer.value); pollTimer.value = null }
|
||||
}
|
||||
|
||||
function addPollingTask(taskId: number) {
|
||||
@@ -1658,8 +1681,11 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
disposed = true
|
||||
stopPolling()
|
||||
if (countryPrefSaveTimer) clearTimeout(countryPrefSaveTimer)
|
||||
clearSleepTimers()
|
||||
timers.clearScope()
|
||||
if (countryPrefSaveTimer) timers.clearTimer('preference-save', countryPrefSaveTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -221,6 +221,7 @@ import {
|
||||
} from '@/shared/api/java-modules'
|
||||
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
||||
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
|
||||
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
|
||||
|
||||
const shopInput = ref('')
|
||||
const candidates = ref<ProductRiskCandidateVo[]>([])
|
||||
@@ -327,8 +328,8 @@ function onCountryDrop(toIndex: number) {
|
||||
}
|
||||
|
||||
function scheduleSaveCountryPref() {
|
||||
if (countryPrefSaveTimer) clearTimeout(countryPrefSaveTimer)
|
||||
countryPrefSaveTimer = setTimeout(() => {
|
||||
if (countryPrefSaveTimer) timers.clearTimer('preference-save', countryPrefSaveTimer)
|
||||
countryPrefSaveTimer = timers.setTimeout('preference-save', () => {
|
||||
countryPrefSaveTimer = null
|
||||
void persistCountryPref()
|
||||
}, 450)
|
||||
@@ -374,6 +375,8 @@ const taskDetails = ref<Record<number, string>>({})
|
||||
const taskSnapshots = ref<Record<number, ProductRiskTaskDetailVo>>({})
|
||||
const pollTimer = ref<number | null>(null)
|
||||
const pollingInFlight = ref(false)
|
||||
let disposed = false
|
||||
const timers = createCategorizedTimers('product-risk')
|
||||
|
||||
function uidForStorage() {
|
||||
return typeof window !== 'undefined' ? window.localStorage.getItem('uid') || '0' : '0'
|
||||
@@ -684,9 +687,12 @@ const TRANSIENT_BACKEND_ERROR_PATTERNS = [
|
||||
] as const
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise<void>((resolve) => {
|
||||
window.setTimeout(() => resolve(), ms)
|
||||
})
|
||||
if (disposed) return Promise.resolve()
|
||||
return timers.sleep('queue-wait', ms)
|
||||
}
|
||||
|
||||
function clearSleepTimers() {
|
||||
timers.clearCategory('queue-wait')
|
||||
}
|
||||
|
||||
function isTransientBackendError(error: unknown) {
|
||||
@@ -697,6 +703,7 @@ function isTransientBackendError(error: unknown) {
|
||||
async function createProductRiskTaskWithRetry(items: ProductRiskShopQueueItem[], maxAttempts = 10) {
|
||||
let attempt = 0
|
||||
while (true) {
|
||||
if (disposed) throw new Error('component disposed')
|
||||
try {
|
||||
return await createProductRiskTask(items)
|
||||
} catch (error) {
|
||||
@@ -751,12 +758,13 @@ function getPollIntervalMs() {
|
||||
function scheduleNextPoll(immediate = false) {
|
||||
if (pollTimer.value) {
|
||||
if (!immediate) return
|
||||
window.clearTimeout(pollTimer.value)
|
||||
timers.clearTimer('task-poll', pollTimer.value)
|
||||
pollTimer.value = null
|
||||
}
|
||||
|
||||
const run = async () => {
|
||||
pollTimer.value = null
|
||||
if (disposed) return
|
||||
if (pollingInFlight.value) {
|
||||
scheduleNextPoll()
|
||||
return
|
||||
@@ -768,13 +776,14 @@ function scheduleNextPoll(immediate = false) {
|
||||
} finally {
|
||||
pollingInFlight.value = false
|
||||
}
|
||||
if (pollingTaskIds.value.length > 0) {
|
||||
pollTimer.value = window.setTimeout(run, getPollIntervalMs())
|
||||
if (!disposed && pollingTaskIds.value.length > 0) {
|
||||
pollTimer.value = timers.setTimeout('task-poll', run, getPollIntervalMs())
|
||||
}
|
||||
}
|
||||
|
||||
if (disposed) return
|
||||
if (immediate) void run()
|
||||
else pollTimer.value = window.setTimeout(run, getPollIntervalMs())
|
||||
else pollTimer.value = timers.setTimeout('task-poll', run, getPollIntervalMs())
|
||||
}
|
||||
|
||||
function ensurePolling(immediate = false) {
|
||||
@@ -784,7 +793,7 @@ function ensurePolling(immediate = false) {
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer.value) {
|
||||
window.clearTimeout(pollTimer.value)
|
||||
timers.clearTimer('task-poll', pollTimer.value)
|
||||
pollTimer.value = null
|
||||
}
|
||||
}
|
||||
@@ -793,6 +802,7 @@ async function waitForTaskTerminal(taskId: number) {
|
||||
let transientErrorCount = 0
|
||||
const maxTransientErrors = 30
|
||||
while (true) {
|
||||
if (disposed) return 'STOPPED'
|
||||
try {
|
||||
const batch = await getProductRiskTaskProgressBatch([taskId])
|
||||
if (transientErrorCount > 0) {
|
||||
@@ -930,9 +940,6 @@ async function runMatch() {
|
||||
const batch = res.items || []
|
||||
matchedItems.value = mergeMatchedItems(matchedItems.value, batch)
|
||||
saveMatchedItemsToStorage()
|
||||
if (autoQueueEnabled.value) {
|
||||
void processMatchedQueue()
|
||||
}
|
||||
if (!batch.length) {
|
||||
ElMessage.warning('未返回匹配结果')
|
||||
} else {
|
||||
@@ -1027,7 +1034,7 @@ function nextMatchedQueueItem() {
|
||||
}
|
||||
|
||||
async function processMatchedQueue() {
|
||||
if (queueWorkerRunning.value) {
|
||||
if (disposed || queueWorkerRunning.value) {
|
||||
return
|
||||
}
|
||||
if (!matchedItems.value.length) {
|
||||
@@ -1051,7 +1058,7 @@ async function processMatchedQueue() {
|
||||
let failedCount = 0
|
||||
try {
|
||||
let index = 0
|
||||
while (autoQueueEnabled.value) {
|
||||
while (!disposed && autoQueueEnabled.value) {
|
||||
const item = nextMatchedQueueItem()
|
||||
if (!item) {
|
||||
break
|
||||
@@ -1102,6 +1109,7 @@ async function processMatchedQueue() {
|
||||
successCount += 1
|
||||
queuePushResult.value = '任务 ' + taskId + ' 已完成'
|
||||
} catch (error) {
|
||||
if (disposed) break
|
||||
failedCount += 1
|
||||
queuePushResult.value = '第 ' + index + ' 条任务处理失败,已自动继续下一条:' + (error instanceof Error ? error.message : '未知错误')
|
||||
ElMessage.error(queuePushResult.value)
|
||||
@@ -1115,6 +1123,7 @@ async function processMatchedQueue() {
|
||||
await loadHistory()
|
||||
ensurePolling(true)
|
||||
} catch (e) {
|
||||
if (disposed) return
|
||||
queuePushResult.value = e instanceof Error ? e.message : '推送异常'
|
||||
ElMessage.error(queuePushResult.value)
|
||||
} finally {
|
||||
@@ -1141,8 +1150,11 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
disposed = true
|
||||
stopPolling()
|
||||
if (countryPrefSaveTimer) clearTimeout(countryPrefSaveTimer)
|
||||
clearSleepTimers()
|
||||
timers.clearScope()
|
||||
if (countryPrefSaveTimer) timers.clearTimer('preference-save', countryPrefSaveTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -333,6 +333,7 @@ import {
|
||||
} from "@/shared/api/java-modules";
|
||||
import { getPywebviewApi } from "@/shared/bridges/pywebview";
|
||||
import { getTaskPollIntervalMs } from "@/shared/task-progress-config";
|
||||
import { createCategorizedTimers } from "@/shared/utils/categorized-timers";
|
||||
|
||||
const MAX_TRANSIENT_ERRORS = 30;
|
||||
|
||||
@@ -358,6 +359,7 @@ const activeQueueItem = ref<QueryAsinShopQueueItem | null>(null);
|
||||
const queueWorkerRunning = ref(false);
|
||||
const autoQueueEnabled = ref(false);
|
||||
let historyPollTimer: number | null = null;
|
||||
const timers = createCategorizedTimers("query-asin");
|
||||
|
||||
const matchedRunnableItems = computed(() =>
|
||||
matchedItems.value.filter((item) => item.matched && (item.queryAsins || []).some((row) => row.asins?.length)),
|
||||
@@ -398,8 +400,15 @@ function historyItemKey(item: QueryAsinHistoryItem) {
|
||||
return `${item.taskId ?? 0}:${item.resultId ?? 0}`;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
if (disposed) return Promise.resolve();
|
||||
return timers.sleep("queue-wait", ms);
|
||||
}
|
||||
|
||||
function clearSleepTimers() {
|
||||
timers.clearCategory("queue-wait");
|
||||
}
|
||||
|
||||
function isTransientBackendError(error: unknown) {
|
||||
@@ -424,6 +433,7 @@ async function withTransientRetry<T>(
|
||||
) {
|
||||
let attempt = 0;
|
||||
while (true) {
|
||||
if (disposed) throw new Error("component disposed");
|
||||
try {
|
||||
return await action();
|
||||
} catch (error) {
|
||||
@@ -747,21 +757,21 @@ async function refreshActiveTaskProgress(taskIds?: number[]) {
|
||||
|
||||
function startHistoryPolling() {
|
||||
stopHistoryPolling();
|
||||
if (!hasQueueWork.value) return;
|
||||
if (disposed || !hasQueueWork.value) return;
|
||||
const run = async () => {
|
||||
historyPollTimer = null;
|
||||
if (!hasQueueWork.value) return;
|
||||
if (disposed || !hasQueueWork.value) return;
|
||||
await refreshActiveTaskProgress();
|
||||
if (hasQueueWork.value) {
|
||||
historyPollTimer = window.setTimeout(run, getTaskPollIntervalMs() * 2);
|
||||
if (!disposed && hasQueueWork.value) {
|
||||
historyPollTimer = timers.setTimeout("history-poll", run, getTaskPollIntervalMs() * 2);
|
||||
}
|
||||
};
|
||||
historyPollTimer = window.setTimeout(run, getTaskPollIntervalMs() * 2);
|
||||
historyPollTimer = timers.setTimeout("history-poll", run, getTaskPollIntervalMs() * 2);
|
||||
}
|
||||
|
||||
function stopHistoryPolling() {
|
||||
if (historyPollTimer) {
|
||||
window.clearTimeout(historyPollTimer);
|
||||
timers.clearTimer("history-poll", historyPollTimer);
|
||||
historyPollTimer = null;
|
||||
}
|
||||
}
|
||||
@@ -893,6 +903,7 @@ function findHistoryItemByTaskId(taskId: number) {
|
||||
async function waitForTaskTerminal(taskId: number) {
|
||||
let transientErrorCount = 0;
|
||||
while (true) {
|
||||
if (disposed) return "STOPPED";
|
||||
try {
|
||||
await refreshActiveTaskProgress([taskId]);
|
||||
if (activeTaskId.value !== taskId) {
|
||||
@@ -924,7 +935,7 @@ async function waitForTaskTerminal(taskId: number) {
|
||||
}
|
||||
|
||||
async function processQueue() {
|
||||
if (queueWorkerRunning.value) return;
|
||||
if (disposed || queueWorkerRunning.value) return;
|
||||
queueWorkerRunning.value = true;
|
||||
pushing.value = true;
|
||||
startHistoryPolling();
|
||||
@@ -935,7 +946,7 @@ async function processQueue() {
|
||||
throw new Error("当前客户端未提供 enqueue_json");
|
||||
}
|
||||
|
||||
while (activeTaskId.value || pendingQueue.value.length) {
|
||||
while (!disposed && (activeTaskId.value || pendingQueue.value.length)) {
|
||||
if (activeTaskId.value) {
|
||||
const finalStatus = await waitForTaskTerminal(activeTaskId.value);
|
||||
queuePushResult.value =
|
||||
@@ -1002,6 +1013,7 @@ async function processQueue() {
|
||||
await refreshTaskViews();
|
||||
ElMessage.success("查询ASIN队列已按顺序执行完成");
|
||||
} catch (error) {
|
||||
if (disposed) return;
|
||||
const message = error instanceof Error ? error.message : "队列执行失败";
|
||||
queuePushResult.value = message;
|
||||
ElMessage.error(message);
|
||||
@@ -1092,7 +1104,10 @@ onMounted(async () => {
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
disposed = true;
|
||||
stopHistoryPolling();
|
||||
clearSleepTimers();
|
||||
timers.clearScope();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -111,6 +111,7 @@ import { LISTING_FILTER_OPTIONS, type ListingFilterValue } from '@/pages/brand/c
|
||||
import { activateShopMatchTask, addShopMatchCandidate, createShopMatchTask, deleteShopMatchCandidate, deleteShopMatchHistory, deleteShopMatchTask, getShopMatchCountryPreference, getShopMatchDashboard, getShopMatchHistory, getShopMatchResultDownloadUrl, getShopMatchTaskProgressBatch, getShopMatchTasksBatch, listShopMatchCandidates, matchShopMatchShops, putShopMatchCountryPreference, type ShopMatchCandidateVo, type ShopMatchDashboardVo, type ShopMatchHistoryItem, type ShopMatchShopQueueItem, type ShopMatchTaskDetailVo } from '@/shared/api/java-modules'
|
||||
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
||||
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
|
||||
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
|
||||
|
||||
const COUNTRY_OPTIONS = [{ code: 'DE', label: '德国' }, { code: 'UK', label: '英国' }, { code: 'FR', label: '法国' }, { code: 'IT', label: '意大利' }, { code: 'ES', label: '西班牙' }] as const
|
||||
const shopInput = ref('')
|
||||
@@ -144,6 +145,8 @@ const scheduledDispatchInFlight = ref(false)
|
||||
let scheduleHeartbeatTimer: number | null = null
|
||||
let lastScheduledErrorAt = 0
|
||||
let lastScheduledErrorKey = ''
|
||||
let disposed = false
|
||||
const timers = createCategorizedTimers('shop-match')
|
||||
const currentSectionItems = computed(() => uniqueTaskRows(historyItems.value.filter((item) => { const taskId = normalizeTaskId(item.taskId); return !!taskId && !isTaskTerminalItem(item) }), taskSnapshots.value, 'current'))
|
||||
const historySectionItems = computed(() => uniqueTaskRows(historyItems.value.filter((item) => { const taskId = normalizeTaskId(item.taskId); return !taskId || isTaskTerminalItem(item) }), taskSnapshots.value, 'history'))
|
||||
const hasVisibleList = computed(() => currentSectionItems.value.length > 0 || historySectionItems.value.length > 0)
|
||||
@@ -194,7 +197,7 @@ function saveMatchedItemsToStorage() { setStorageJson(matchedItemsStorageKey(),
|
||||
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 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) { timers.clearTimer('scheduled-dispatch', timer); dispatchTimers.delete(taskId); if (!dispatchTimers.size) stopScheduleHeartbeat() } }
|
||||
function removePollingTask(taskId: number) {
|
||||
clearDispatchTimer(taskId)
|
||||
pollingTaskIds.value = pollingTaskIds.value.filter((id) => id !== taskId)
|
||||
@@ -226,7 +229,7 @@ async function loadDashboard() { dashboard.value = await getShopMatchDashboard()
|
||||
async function loadHistory() { const data = await getShopMatchHistory(); historyItems.value = data.items || [] }
|
||||
async function loadCountryPreference() { try { const data = await getShopMatchCountryPreference(); if (countryPrefUserTouched.value) return; const codes = (data.country_codes || []).filter((item) => COUNTRY_OPTIONS.some((option) => option.code === item)); if (codes.length) orderedCountryCodes.value = codes } catch {} }
|
||||
async function refreshTaskViewsBestEffort() { await Promise.allSettled([loadHistory(), loadDashboard()]) }
|
||||
function scheduleSaveCountryPreference() { if (countryPrefSaveTimer) clearTimeout(countryPrefSaveTimer); countryPrefSaveTimer = setTimeout(() => { countryPrefSaveTimer = null; void saveCountryPreference() }, 450) }
|
||||
function scheduleSaveCountryPreference() { if (countryPrefSaveTimer) timers.clearTimer('preference-save', countryPrefSaveTimer); countryPrefSaveTimer = timers.setTimeout('preference-save', () => { countryPrefSaveTimer = null; void saveCountryPreference() }, 450) }
|
||||
async function saveCountryPreference() { if (!orderedCountryCodes.value.length) return; countryPrefSaving.value = true; try { await putShopMatchCountryPreference(orderedCountryCodes.value) } catch (error) { ElMessage.error(error instanceof Error ? error.message : '保存国家配置失败') } finally { countryPrefSaving.value = false } }
|
||||
function onSelectionChange(rows: ShopMatchCandidateVo[]) { selectedCandidates.value = rows }
|
||||
async function confirmAdd() { const name = shopInput.value.trim(); if (!name) { ElMessage.warning('请输入店铺名'); return } adding.value = true; try { await addShopMatchCandidate(name); shopInput.value = ''; await loadCandidates(); await loadDashboard() } catch (error) { ElMessage.error(error instanceof Error ? error.message : '添加失败') } finally { adding.value = false } }
|
||||
@@ -320,25 +323,27 @@ async function pumpScheduledQueue() {
|
||||
restoreScheduledDispatches()
|
||||
}
|
||||
}
|
||||
function scheduleDispatch(taskId: number, scheduledAt?: string) { if (!scheduledAt) return; clearDispatchTimer(taskId); const time = scheduledTimestamp(scheduledAt); if (!Number.isFinite(time)) return; const delay = Math.max(0, time - Date.now()); const timer = window.setTimeout(() => { dispatchTimers.delete(taskId); void pumpScheduledQueue() }, delay); dispatchTimers.set(taskId, timer) }
|
||||
function restoreScheduledDispatches() { const taskIds = new Set<number>(pollingTaskIds.value); for (const key of Object.keys(taskSnapshots.value)) { const id = normalizeTaskId(key); if (id > 0) taskIds.add(id) } for (const item of historyItems.value) { const id = normalizeTaskId(item.taskId); if (id > 0) taskIds.add(id) } for (const taskId of taskIds) { const snapshot = taskSnapshots.value[taskId]; const taskStatus = snapshot?.task?.status || taskDetails.value[taskId]; const stage = nextScheduleStage(taskId); if (taskStatus === 'SCHEDULED' && stage?.scheduledAt) scheduleDispatch(taskId, stage.scheduledAt); else clearDispatchTimer(taskId) } void pumpScheduledQueue() }
|
||||
function startScheduleHeartbeat() { if (scheduleHeartbeatTimer) return; scheduleHeartbeatTimer = window.setInterval(() => { void pumpScheduledQueue() }, 1000) }
|
||||
function stopScheduleHeartbeat() { if (!scheduleHeartbeatTimer) return; window.clearInterval(scheduleHeartbeatTimer); scheduleHeartbeatTimer = null }
|
||||
function scheduleDispatch(taskId: number, scheduledAt?: string) { if (disposed || !scheduledAt) return; clearDispatchTimer(taskId); const time = scheduledTimestamp(scheduledAt); if (!Number.isFinite(time)) return; const delay = Math.max(0, time - Date.now()); const timer = timers.setTimeout('scheduled-dispatch', () => { dispatchTimers.delete(taskId); if (!dispatchTimers.size) stopScheduleHeartbeat(); if (!disposed) void pumpScheduledQueue() }, delay); dispatchTimers.set(taskId, timer); startScheduleHeartbeat() }
|
||||
function restoreScheduledDispatches() { if (disposed) return; const taskIds = new Set<number>(pollingTaskIds.value); for (const key of Object.keys(taskSnapshots.value)) { const id = normalizeTaskId(key); if (id > 0) taskIds.add(id) } for (const item of historyItems.value) { const id = normalizeTaskId(item.taskId); if (id > 0) taskIds.add(id) } for (const taskId of taskIds) { const snapshot = taskSnapshots.value[taskId]; const taskStatus = snapshot?.task?.status || taskDetails.value[taskId]; const stage = nextScheduleStage(taskId); if (taskStatus === 'SCHEDULED' && stage?.scheduledAt) scheduleDispatch(taskId, stage.scheduledAt); else clearDispatchTimer(taskId) } if (dispatchTimers.size) startScheduleHeartbeat(); else stopScheduleHeartbeat(); void pumpScheduledQueue() }
|
||||
function startScheduleHeartbeat() { if (disposed || scheduleHeartbeatTimer || !dispatchTimers.size) return; scheduleHeartbeatTimer = timers.setInterval('schedule-heartbeat', () => { if (!disposed) void pumpScheduledQueue() }, 30000) }
|
||||
function stopScheduleHeartbeat() { if (!scheduleHeartbeatTimer) return; timers.clearTimer('schedule-heartbeat', scheduleHeartbeatTimer); scheduleHeartbeatTimer = null }
|
||||
function handleScheduleRecovery() { restoreScheduledDispatches(); if (pollingTaskIds.value.length) ensurePolling(true) }
|
||||
const TRANSIENT_BACKEND_ERROR_PATTERNS = ['无法连接到后端服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'] as const
|
||||
function sleep(ms: number) { return new Promise<void>((resolve) => { window.setTimeout(() => resolve(), ms) }) }
|
||||
function sleep(ms: number) { if (disposed) return Promise.resolve(); return timers.sleep('queue-wait', ms) }
|
||||
function clearSleepTimers() { timers.clearCategory('queue-wait') }
|
||||
function isTransientBackendError(error: unknown) { const message = (error instanceof Error ? error.message : String(error || '')).toLowerCase(); return TRANSIENT_BACKEND_ERROR_PATTERNS.some((pattern) => message.includes(pattern.toLowerCase())) }
|
||||
async function withTransientRetry<T>(action: () => Promise<T>, onRetry: (attempt: number, maxAttempts: number) => void, maxAttempts = 10) { let attempt = 0; while (true) { try { return await action() } catch (error) { attempt += 1; if (!isTransientBackendError(error) || attempt >= maxAttempts) throw error; onRetry(attempt, maxAttempts); await sleep(getPollIntervalMs()) } } }
|
||||
async function withTransientRetry<T>(action: () => Promise<T>, onRetry: (attempt: number, maxAttempts: number) => void, maxAttempts = 10) { let attempt = 0; while (true) { if (disposed) throw new Error('component disposed'); try { return await action() } catch (error) { attempt += 1; if (!isTransientBackendError(error) || attempt >= maxAttempts) throw error; onRetry(attempt, maxAttempts); await sleep(getPollIntervalMs()) } } }
|
||||
async function refreshTaskBatch() { if (!pollingTaskIds.value.length) return { settledTaskIds: [] as number[] }; try { const batch = await getShopMatchTaskProgressBatch(pollingTaskIds.value); const nextSnapshots = { ...taskSnapshots.value }; const settledTaskIds = new Set<number>(); for (const missingId of batch.missingTaskIds || []) { removeTaskLocally(missingId); delete nextSnapshots[missingId]; settledTaskIds.add(missingId) } for (const detail of batch.items || []) { const taskId = detail.task?.id; if (!taskId) continue; const prev = nextSnapshots[taskId]; nextSnapshots[taskId] = prev ? { ...prev, task: { ...(prev.task || {}), ...(detail.task || {}) } } : detail; const status = detail.task?.status || ''; if (!status) continue; taskDetails.value[taskId] = status; if (isTaskTerminalStatus(status)) { removePollingTask(taskId); settledTaskIds.add(taskId); continue } if (status === 'SCHEDULED') { stopPollingTask(taskId); settledTaskIds.add(taskId) } } taskSnapshots.value = nextSnapshots; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); restoreScheduledDispatches(); return { settledTaskIds: Array.from(settledTaskIds) } } catch { return { settledTaskIds: [] as number[] } } }
|
||||
function getPollIntervalMs() { return getTaskPollIntervalMs() }
|
||||
function scheduleNextPoll(immediate = false) { if (pollTimer.value) { if (!immediate) return; window.clearTimeout(pollTimer.value); pollTimer.value = null } const run = async () => { pollTimer.value = null; if (pollingInFlight.value || !pollingTaskIds.value.length) { if (pollingTaskIds.value.length) scheduleNextPoll(); return } pollingInFlight.value = true; try { const { settledTaskIds } = await refreshTaskBatch(); if (settledTaskIds.length) await Promise.allSettled([loadHistory(), loadDashboard()]) } finally { pollingInFlight.value = false } if (pollingTaskIds.value.length) pollTimer.value = window.setTimeout(run, getPollIntervalMs()) }; if (immediate) void run(); else pollTimer.value = window.setTimeout(run, getPollIntervalMs()) }
|
||||
function scheduleNextPoll(immediate = false) { if (disposed) return; if (pollTimer.value) { if (!immediate) return; timers.clearTimer('task-poll', pollTimer.value); pollTimer.value = null } const run = async () => { pollTimer.value = null; if (disposed) return; if (pollingInFlight.value || !pollingTaskIds.value.length) { if (pollingTaskIds.value.length) scheduleNextPoll(); return } pollingInFlight.value = true; try { const { settledTaskIds } = await refreshTaskBatch(); if (settledTaskIds.length) await Promise.allSettled([loadHistory(), loadDashboard()]) } finally { pollingInFlight.value = false } if (!disposed && pollingTaskIds.value.length) pollTimer.value = timers.setTimeout('task-poll', run, getPollIntervalMs()) }; if (immediate) void run(); else pollTimer.value = timers.setTimeout('task-poll', run, getPollIntervalMs()) }
|
||||
function ensurePolling(immediate = false) { scheduleNextPoll(immediate) }
|
||||
function stopPolling() { if (pollTimer.value) { window.clearTimeout(pollTimer.value); pollTimer.value = null } }
|
||||
async function waitForTaskTerminal(taskId: number) { let transientErrorCount = 0; const maxTransientErrors = 30; while (true) { try { const batch = await getShopMatchTaskProgressBatch([taskId]); if (transientErrorCount > 0) queuePushResult.value = `任务 ${taskId} 后端已恢复,继续等待执行结果...`; transientErrorCount = 0; if ((batch.missingTaskIds || []).includes(taskId)) { removeTaskLocally(taskId); await refreshTaskViewsBestEffort(); restoreScheduledDispatches(); return 'FAILED' } const detail = (batch.items || []).find((item) => item.task?.id === taskId); const status = detail?.task?.status || ''; if (detail) { const prev = taskSnapshots.value[taskId]; taskSnapshots.value = { ...taskSnapshots.value, [taskId]: prev ? { ...prev, task: { ...(prev.task || {}), ...(detail.task || {}) } } : detail }; saveTaskSnapshotsToStorage() } if (status) { taskDetails.value[taskId] = status; saveTaskDetailsToStorage() } if (isTaskTerminalStatus(status)) { removePollingTask(taskId); await refreshTaskViewsBestEffort(); restoreScheduledDispatches(); return status } } catch (error) { if (!isTransientBackendError(error)) throw error; transientErrorCount += 1; if (transientErrorCount >= maxTransientErrors) throw new Error(`任务 ${taskId} 等待后端恢复超时,请稍后手动刷新查看状态`); queuePushResult.value = `任务 ${taskId} 运行中,正在等待后端服务恢复(${transientErrorCount}/${maxTransientErrors})...`; if (transientErrorCount === 1 || transientErrorCount % 5 === 0) ElMessage.warning(`任务 ${taskId} 运行中,后端服务暂时不可用,正在自动重试`); await sleep(getPollIntervalMs()); continue } await sleep(getPollIntervalMs()) } }
|
||||
function stopPolling() { if (pollTimer.value) { timers.clearTimer('task-poll', pollTimer.value); pollTimer.value = null } }
|
||||
async function waitForTaskTerminal(taskId: number) { let transientErrorCount = 0; const maxTransientErrors = 30; while (true) { if (disposed) return 'STOPPED'; try { const batch = await getShopMatchTaskProgressBatch([taskId]); if (transientErrorCount > 0) queuePushResult.value = `任务 ${taskId} 后端已恢复,继续等待执行结果...`; transientErrorCount = 0; if ((batch.missingTaskIds || []).includes(taskId)) { removeTaskLocally(taskId); await refreshTaskViewsBestEffort(); restoreScheduledDispatches(); return 'FAILED' } const detail = (batch.items || []).find((item) => item.task?.id === taskId); const status = detail?.task?.status || ''; if (detail) { const prev = taskSnapshots.value[taskId]; taskSnapshots.value = { ...taskSnapshots.value, [taskId]: prev ? { ...prev, task: { ...(prev.task || {}), ...(detail.task || {}) } } : detail }; saveTaskSnapshotsToStorage() } if (status) { taskDetails.value[taskId] = status; saveTaskDetailsToStorage() } if (isTaskTerminalStatus(status)) { removePollingTask(taskId); await refreshTaskViewsBestEffort(); restoreScheduledDispatches(); return status } } catch (error) { if (!isTransientBackendError(error)) throw error; transientErrorCount += 1; if (transientErrorCount >= maxTransientErrors) throw new Error(`任务 ${taskId} 等待后端恢复超时,请稍后手动刷新查看状态`); queuePushResult.value = `任务 ${taskId} 运行中,正在等待后端服务恢复(${transientErrorCount}/${maxTransientErrors})...`; if (transientErrorCount === 1 || transientErrorCount % 5 === 0) ElMessage.warning(`任务 ${taskId} 运行中,后端服务暂时不可用,正在自动重试`); await sleep(getPollIntervalMs()); continue } await sleep(getPollIntervalMs()) } }
|
||||
async function waitForScheduledTaskStageExit(taskId: number) {
|
||||
let transientErrorCount = 0
|
||||
const maxTransientErrors = 30
|
||||
while (true) {
|
||||
if (disposed) return 'STOPPED'
|
||||
try {
|
||||
const batch = await getShopMatchTaskProgressBatch([taskId])
|
||||
if (transientErrorCount > 0) queuePushResult.value = `任务 ${taskId} 后端已恢复,继续等待当前轮次结束...`
|
||||
@@ -429,9 +434,9 @@ function formatMatchStatus(status?: string) { const value = (status || '').trim(
|
||||
function formatMatchRemark(row: ShopMatchShopQueueItem) { const message = (row.matchMessage || '').trim(); if (message) return message; if (row.matched && row.matchStatus === 'MATCHED') return '紫鸟索引已命中,可推送队列'; if (row.matched) return '已关联索引,请结合状态列查看是否可推送'; return '未命中或未就绪,请检查店铺名与索引刷新' }
|
||||
async function pushToPythonQueue() { autoQueueEnabled.value = true; await processMatchedQueue() }
|
||||
function nextMatchedQueueItem() { return matchedItems.value.find((item) => item.matched) }
|
||||
async function processMatchedQueue() { if (queueWorkerRunning.value) return; const api = getPywebviewApi(); if (!api?.enqueue_json) { ElMessage.error('当前客户端未提供 enqueue_json'); return } const matched = matchedItems.value.filter((item) => item.matched); if (!matched.length) { ElMessage.warning('请先匹配可用店铺'); return } if (!orderedCountryCodes.value.length) { ElMessage.warning('请至少勾选一个国家'); return } let scheduleValues: string[] | undefined; try { scheduleValues = parseScheduleValues() } catch (error) { ElMessage.error(error instanceof Error ? error.message : '定时配置无效'); return } queueWorkerRunning.value = true; pushing.value = true; queuePayloadText.value = ''; let successCount = 0; let failedCount = 0; let index = 0; try { while (autoQueueEnabled.value) { const item = nextMatchedQueueItem(); if (!item) break; index += 1; try { const created = await withTransientRetry(() => createShopMatchTask([item], orderedCountryCodes.value, scheduleValues), (attempt, maxAttempts) => { queuePushResult.value = `后端服务暂时不可用,正在重试创建任务(${attempt}/${maxAttempts})...` }); const taskId = created.taskId; const initialStatus = scheduleValues?.length ? 'SCHEDULED' : 'RUNNING'; const snapshot: ShopMatchTaskDetailVo = { task: { id: taskId, status: initialStatus, scheduledAt: scheduleValues?.[0], countryCodes: [...orderedCountryCodes.value], currentStageIndex: scheduleValues?.length ? 0 : undefined, activeStageIndex: undefined, scheduleStages: (scheduleValues || []).map((value, stageIndex) => ({ stageIndex, scheduledAt: value, status: stageIndex === 0 ? 'SCHEDULED' : 'PENDING' })) }, items: created.items }; taskSnapshots.value = { ...taskSnapshots.value, [taskId]: snapshot }; taskDetails.value = { ...taskDetails.value, [taskId]: initialStatus }; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); const createdItem = created.items?.[0]; if (!createdItem) { removeMatchedRowsLocally([item]); continue } if (scheduleValues?.length) { successCount += 1; restoreScheduledDispatches(); queuePushResult.value = `任务 ${taskId} 已创建,共 ${scheduleValues.length} 个执行时间点`; removeMatchedRowsLocally([item]); continue } const payload = buildQueuePayload(taskId, item, orderedCountryCodes.value); queuePayloadText.value = JSON.stringify(payload, null, 2); const result = await api.enqueue_json(payload); if (!result?.success) { failedCount += 1; queuePushResult.value = `任务 ${taskId} 推送失败,已自动继续下一条:${result?.error || '未知错误'}`; ElMessage.error(queuePushResult.value); removeMatchedRowsLocally([item]); continue } addPollingTask(taskId); ensurePolling(true); removeMatchedRowsLocally([item]); queuePushResult.value = `任务 ${taskId} 已入队,等待执行完成`; const finalStatus = await waitForTaskTerminal(taskId); if (finalStatus !== 'SUCCESS') { failedCount += 1; queuePushResult.value = `任务 ${taskId} 执行失败,已自动继续下一条`; ElMessage.error(queuePushResult.value); continue } successCount += 1; queuePushResult.value = `任务 ${taskId} 已完成` } catch (error) { failedCount += 1; queuePushResult.value = `第 ${index} 条任务处理失败,已自动继续下一条:${error instanceof Error ? error.message : '未知错误'}`; ElMessage.error(queuePushResult.value); removeMatchedRowsLocally([item]); continue } } restoreScheduledDispatches(); if (!scheduleValues?.length) ensurePolling(true); if (successCount > 0 || failedCount > 0) ElMessage.success(scheduleValues?.length ? `定时任务已创建:成功 ${successCount} 条,失败 ${failedCount} 条` : `店铺推送已完成:成功 ${successCount} 条,失败 ${failedCount} 条`) } catch (error) { const message = error instanceof Error ? error.message : '推送失败'; queuePushResult.value = message; ElMessage.error(message) } finally { queueWorkerRunning.value = false; pushing.value = false } }
|
||||
onMounted(async () => { loadPollingIdsFromStorage(); loadTaskDetailsFromStorage(); loadTaskSnapshotsFromStorage(); loadMatchedItemsFromStorage(); schedulePickerValues.value = sanitizeSchedulePickerValues(schedulePickerValues.value, scheduleEnabled.value); syncPollingIdsWithTaskState(); await Promise.allSettled([loadCandidates(), loadCountryPreference(), loadHistory(), loadDashboard()]); startScheduleHeartbeat(); window.addEventListener('focus', handleScheduleRecovery); document.addEventListener('visibilitychange', handleScheduleRecovery); restoreScheduledDispatches(); if (pollingTaskIds.value.length) ensurePolling(true) })
|
||||
onUnmounted(() => { stopPolling(); stopScheduleHeartbeat(); window.removeEventListener('focus', handleScheduleRecovery); document.removeEventListener('visibilitychange', handleScheduleRecovery); if (countryPrefSaveTimer) clearTimeout(countryPrefSaveTimer); for (const timer of dispatchTimers.values()) window.clearTimeout(timer); dispatchTimers.clear() })
|
||||
async function processMatchedQueue() { if (disposed || queueWorkerRunning.value) return; const api = getPywebviewApi(); if (!api?.enqueue_json) { ElMessage.error('当前客户端未提供 enqueue_json'); return } const matched = matchedItems.value.filter((item) => item.matched); if (!matched.length) { ElMessage.warning('请先匹配可用店铺'); return } if (!orderedCountryCodes.value.length) { ElMessage.warning('请至少勾选一个国家'); return } let scheduleValues: string[] | undefined; try { scheduleValues = parseScheduleValues() } catch (error) { ElMessage.error(error instanceof Error ? error.message : '定时配置无效'); return } queueWorkerRunning.value = true; pushing.value = true; queuePayloadText.value = ''; let successCount = 0; let failedCount = 0; let index = 0; try { while (!disposed && autoQueueEnabled.value) { const item = nextMatchedQueueItem(); if (!item) break; index += 1; try { const created = await withTransientRetry(() => createShopMatchTask([item], orderedCountryCodes.value, scheduleValues), (attempt, maxAttempts) => { queuePushResult.value = `后端服务暂时不可用,正在重试创建任务(${attempt}/${maxAttempts})...` }); const taskId = created.taskId; const initialStatus = scheduleValues?.length ? 'SCHEDULED' : 'RUNNING'; const snapshot: ShopMatchTaskDetailVo = { task: { id: taskId, status: initialStatus, scheduledAt: scheduleValues?.[0], countryCodes: [...orderedCountryCodes.value], currentStageIndex: scheduleValues?.length ? 0 : undefined, activeStageIndex: undefined, scheduleStages: (scheduleValues || []).map((value, stageIndex) => ({ stageIndex, scheduledAt: value, status: stageIndex === 0 ? 'SCHEDULED' : 'PENDING' })) }, items: created.items }; taskSnapshots.value = { ...taskSnapshots.value, [taskId]: snapshot }; taskDetails.value = { ...taskDetails.value, [taskId]: initialStatus }; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); const createdItem = created.items?.[0]; if (!createdItem) { removeMatchedRowsLocally([item]); continue } if (scheduleValues?.length) { successCount += 1; restoreScheduledDispatches(); queuePushResult.value = `任务 ${taskId} 已创建,共 ${scheduleValues.length} 个执行时间点`; removeMatchedRowsLocally([item]); continue } const payload = buildQueuePayload(taskId, item, orderedCountryCodes.value); queuePayloadText.value = JSON.stringify(payload, null, 2); const result = await api.enqueue_json(payload); if (!result?.success) { failedCount += 1; queuePushResult.value = `任务 ${taskId} 推送失败,已自动继续下一条:${result?.error || '未知错误'}`; ElMessage.error(queuePushResult.value); removeMatchedRowsLocally([item]); continue } addPollingTask(taskId); ensurePolling(true); removeMatchedRowsLocally([item]); queuePushResult.value = `任务 ${taskId} 已入队,等待执行完成`; const finalStatus = await waitForTaskTerminal(taskId); if (finalStatus !== 'SUCCESS') { failedCount += 1; queuePushResult.value = `任务 ${taskId} 执行失败,已自动继续下一条`; ElMessage.error(queuePushResult.value); continue } successCount += 1; queuePushResult.value = `任务 ${taskId} 已完成` } catch (error) { if (disposed) break; failedCount += 1; queuePushResult.value = `第 ${index} 条任务处理失败,已自动继续下一条:${error instanceof Error ? error.message : '未知错误'}`; ElMessage.error(queuePushResult.value); removeMatchedRowsLocally([item]); continue } } restoreScheduledDispatches(); if (!scheduleValues?.length) ensurePolling(true); if (!disposed && (successCount > 0 || failedCount > 0)) ElMessage.success(scheduleValues?.length ? `定时任务已创建:成功 ${successCount} 条,失败 ${failedCount} 条` : `店铺推送已完成:成功 ${successCount} 条,失败 ${failedCount} 条`) } catch (error) { if (disposed) return; const message = error instanceof Error ? error.message : '推送失败'; queuePushResult.value = message; ElMessage.error(message) } finally { queueWorkerRunning.value = false; pushing.value = false } }
|
||||
onMounted(async () => { loadPollingIdsFromStorage(); loadTaskDetailsFromStorage(); loadTaskSnapshotsFromStorage(); loadMatchedItemsFromStorage(); schedulePickerValues.value = sanitizeSchedulePickerValues(schedulePickerValues.value, scheduleEnabled.value); syncPollingIdsWithTaskState(); await Promise.allSettled([loadCandidates(), loadCountryPreference(), loadHistory(), loadDashboard()]); window.addEventListener('focus', handleScheduleRecovery); document.addEventListener('visibilitychange', handleScheduleRecovery); restoreScheduledDispatches(); if (pollingTaskIds.value.length) ensurePolling(true) })
|
||||
onUnmounted(() => { disposed = true; stopPolling(); stopScheduleHeartbeat(); clearSleepTimers(); window.removeEventListener('focus', handleScheduleRecovery); document.removeEventListener('visibilitychange', handleScheduleRecovery); if (countryPrefSaveTimer) timers.clearTimer('preference-save', countryPrefSaveTimer); for (const timer of dispatchTimers.values()) timers.clearTimer('scheduled-dispatch', timer); dispatchTimers.clear(); timers.clearScope() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -521,6 +521,7 @@ export function runDeleteBrand(
|
||||
...request,
|
||||
user_id: getCurrentUserId(),
|
||||
},
|
||||
{ timeout: 180000 },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
113
frontend-vue/src/shared/utils/categorized-timers.ts
Normal file
113
frontend-vue/src/shared/utils/categorized-timers.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
type TimerKind = 'timeout' | 'interval'
|
||||
type TimerCancel = () => void
|
||||
|
||||
interface TimerEntry {
|
||||
id: number
|
||||
kind: TimerKind
|
||||
category: string
|
||||
cancel?: TimerCancel
|
||||
}
|
||||
|
||||
const timerBuckets = new Map<string, Map<number, TimerEntry>>()
|
||||
|
||||
function bucketOf(category: string) {
|
||||
let bucket = timerBuckets.get(category)
|
||||
if (!bucket) {
|
||||
bucket = new Map<number, TimerEntry>()
|
||||
timerBuckets.set(category, bucket)
|
||||
}
|
||||
return bucket
|
||||
}
|
||||
|
||||
function removeTimer(category: string, id: number) {
|
||||
const bucket = timerBuckets.get(category)
|
||||
if (!bucket) return
|
||||
bucket.delete(id)
|
||||
if (!bucket.size) timerBuckets.delete(category)
|
||||
}
|
||||
|
||||
export function setCategorizedTimeout(category: string, handler: () => void, delayMs: number) {
|
||||
const id = window.setTimeout(() => {
|
||||
removeTimer(category, id)
|
||||
handler()
|
||||
}, delayMs)
|
||||
bucketOf(category).set(id, { id, kind: 'timeout', category })
|
||||
return id
|
||||
}
|
||||
|
||||
export function setCategorizedInterval(category: string, handler: () => void, delayMs: number) {
|
||||
const id = window.setInterval(handler, delayMs)
|
||||
bucketOf(category).set(id, { id, kind: 'interval', category })
|
||||
return id
|
||||
}
|
||||
|
||||
export function clearCategorizedTimer(category: string, id: number | null | undefined) {
|
||||
if (id == null) return
|
||||
const bucket = timerBuckets.get(category)
|
||||
const entry = bucket?.get(id)
|
||||
if (entry?.kind === 'interval') window.clearInterval(id)
|
||||
else window.clearTimeout(id)
|
||||
entry?.cancel?.()
|
||||
removeTimer(category, id)
|
||||
}
|
||||
|
||||
export function clearCategorizedTimers(category: string) {
|
||||
const bucket = timerBuckets.get(category)
|
||||
if (!bucket) return
|
||||
for (const entry of bucket.values()) {
|
||||
if (entry.kind === 'interval') window.clearInterval(entry.id)
|
||||
else window.clearTimeout(entry.id)
|
||||
entry.cancel?.()
|
||||
}
|
||||
timerBuckets.delete(category)
|
||||
}
|
||||
|
||||
export function sleepWithCategory(category: string, delayMs: number) {
|
||||
return new Promise<void>((resolve) => {
|
||||
const id = window.setTimeout(() => {
|
||||
removeTimer(category, id)
|
||||
resolve()
|
||||
}, delayMs)
|
||||
bucketOf(category).set(id, { id, kind: 'timeout', category, cancel: resolve })
|
||||
})
|
||||
}
|
||||
|
||||
export function clearTimerScope(scope: string) {
|
||||
const prefix = `${scope}:`
|
||||
for (const category of Array.from(timerBuckets.keys())) {
|
||||
if (category === scope || category.startsWith(prefix)) {
|
||||
clearCategorizedTimers(category)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createCategorizedTimers(scope: string) {
|
||||
const key = (category: string) => `${scope}:${category}`
|
||||
return {
|
||||
setTimeout(category: string, handler: () => void, delayMs: number) {
|
||||
return setCategorizedTimeout(key(category), handler, delayMs)
|
||||
},
|
||||
setInterval(category: string, handler: () => void, delayMs: number) {
|
||||
return setCategorizedInterval(key(category), handler, delayMs)
|
||||
},
|
||||
clearTimer(category: string, id: number | null | undefined) {
|
||||
clearCategorizedTimer(key(category), id)
|
||||
},
|
||||
clearCategory(category: string) {
|
||||
clearCategorizedTimers(key(category))
|
||||
},
|
||||
clearScope() {
|
||||
clearTimerScope(scope)
|
||||
},
|
||||
sleep(category: string, delayMs: number) {
|
||||
return sleepWithCategory(key(category), delayMs)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function getCategorizedTimerStats() {
|
||||
return Array.from(timerBuckets.entries()).map(([category, bucket]) => ({
|
||||
category,
|
||||
count: bucket.size,
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user