Merge origin master into local master

This commit is contained in:
koko
2026-04-30 21:46:00 +08:00
66 changed files with 20610 additions and 2733 deletions

17535
2026_04_30.log Normal file

File diff suppressed because one or more lines are too long

View File

@@ -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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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获取结果

View File

@@ -537,8 +537,6 @@ class StatusTask:
self.log(f"切换页面失败重试退出", "ERROR")
return
# 处理所有需要审批的商品通过yield获取结果
# 指定 asin

View File

@@ -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):

View File

@@ -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获取结果

View File

@@ -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)

View File

@@ -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
)
# 流式响应

View File

@@ -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
)

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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) {

View File

@@ -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);
}

View File

@@ -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) {

View File

@@ -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", " ")

View File

@@ -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", " ")

View File

@@ -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) {

View File

@@ -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()) {

View File

@@ -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", " ")

View File

@@ -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();
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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());

View File

@@ -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;

View File

@@ -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;

View File

@@ -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(

View File

@@ -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);
}

View File

@@ -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")

View File

@@ -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() {

View File

@@ -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();
}
}

View File

@@ -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());

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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", " ")

View File

@@ -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>()

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -212,7 +212,7 @@ public boolean isIpWhitelistError(BusinessException ex) {
if (value == null) {
return "";
}
return value.replace("\u3000", " ")
return value.replace((char) 0x3000, ' ')
.trim();
}

View File

@@ -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) {

View File

@@ -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}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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'),

View File

@@ -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"

View File

@@ -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 (

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -521,6 +521,7 @@ export function runDeleteBrand(
...request,
user_id: getCurrentUserId(),
},
{ timeout: 180000 },
),
);
}

View 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,
}))
}