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 client_name=ShuFuAI
java_api_base=http://47.111.163.154:18080 # java_api_base=http://47.111.163.154:18080
# java_api_base=http://127.0.0.1:18080 java_api_base=http://127.0.0.1:18080
# java_api_base=http://8.136.19.173: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

@@ -1082,6 +1082,68 @@ class ApproveTask:
del runing_shop[shop_name] del runing_shop[shop_name]
self.log(f"店铺 {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): def process_country(self, driver: AmzoneApprove, country_code: str, task_id: int, shop_name: str, risk_listing_filter: str):
"""处理单个国家的审批任务 """处理单个国家的审批任务
@@ -1108,81 +1170,92 @@ class ApproveTask:
max_retries = 3 max_retries = 3
switch_success = False switch_success = False
for retry in range(max_retries): # for retry in range(max_retries):
try: # try:
self.log(f"尝试切换到国家 {country_name} (第 {retry + 1}/{max_retries} 次)") # self.log(f"尝试切换到国家 {country_name} (第 {retry + 1}/{max_retries} 次)")
if retry > 1: # if retry > 1:
# 刷新不行就重新打开店铺 # # 刷新不行就重新打开店铺
self.log("重试前重新打开店铺...") # self.log("重试前重新打开店铺...")
try: # try:
driver.close_store() # driver.close_store()
time.sleep(3) # time.sleep(3)
driver.open_shop(shop_name) # driver.open_shop(shop_name)
except Exception as e: # except Exception as e:
self.log(f"关闭重新打开店铺: {str(e)}", "WARNING") # self.log(f"关闭重新打开店铺: {str(e)}", "WARNING")
#
# 如果不是第一次尝试,先刷新页面 # # 如果不是第一次尝试,先刷新页面
if retry > 0: # if retry > 0:
self.log("重试前刷新页面...") # self.log("重试前刷新页面...")
try: # try:
driver.tab.refresh() # driver.tab.refresh()
time.sleep(3) # time.sleep(3)
except Exception as e: # except Exception as e:
self.log(f"刷新页面失败: {str(e)}", "WARNING") # self.log(f"刷新页面失败: {str(e)}", "WARNING")
#
switch_success = driver.SwitchingCountries(country_name) # switch_success = driver.SwitchingCountries(country_name)
if switch_success: # if switch_success:
self.log(f"成功切换到国家 {country_name}") # self.log(f"成功切换到国家 {country_name}")
break # break
else: # else:
self.log(f"切换到国家 {country_name} 失败", "WARNING") # self.log(f"切换到国家 {country_name} 失败", "WARNING")
#
except Exception as e: # except Exception as e:
import traceback # import traceback
self.log(f"切换国家 {country_name} 异常: {str(e)}", "ERROR") # self.log(f"切换国家 {country_name} 异常: {str(e)}", "ERROR")
self.log(traceback.format_exc(), "ERROR") # self.log(traceback.format_exc(), "ERROR")
#
# 如果还有重试机会,等待后继续 # # 如果还有重试机会,等待后继续
if retry < max_retries - 1: # if retry < max_retries - 1:
time.sleep(2) # 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: if not switch_success or not switch_success_pg or not search_success:
error_message = f"切换到国家 {country_name} 失败,已重试 {max_retries} 次,跳过该国家" error_message = f"切换到国家({switch_success})/库存页面({switch_success_pg})/搜索筛选到指定选项({search_success}) {country_name} 失败,已重试 {max_retries} 次,跳过该国家"
self.log(error_message, "ERROR") self.log(error_message, "ERROR")
if task_id in runing_task: if task_id in runing_task:
runing_task[task_id]["processed_countries"] += 1 runing_task[task_id]["processed_countries"] += 1
return 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: if len(sku_ls) == 0:
self.log(f"国家 {country_name} 没有需要审批的商品") self.log(f"国家 {country_name} 没有需要审批的商品")
if task_id in runing_task: if task_id in runing_task:

View File

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

View File

@@ -8,11 +8,12 @@ import json
import os import os
from typing import Literal from typing import Literal
from DrissionPage import Chromium from DrissionPage import Chromium,ChromiumOptions
from DrissionPage.common import By from DrissionPage.common import By
from DrissionPage._pages.chromium_tab import ChromiumTab from DrissionPage._pages.chromium_tab import ChromiumTab
def kill_process(version: Literal["v5", "v6"]): def kill_process(version: Literal["v5", "v6"]):
"""杀紫鸟客户端进程(独立函数版本)""" """杀紫鸟客户端进程(独立函数版本)"""
driver = ZiniaoDriver({}) driver = ZiniaoDriver({})
@@ -205,7 +206,11 @@ class ZiniaoDriver:
Returns: Returns:
Chromium: DrissionPage浏览器实例 Chromium: DrissionPage浏览器实例
""" """
browser = Chromium(port) co = ChromiumOptions()
# 设置不加载图片、静音
co.set_local_port(port)
co.no_imgs(True).mute(True)
browser = Chromium(co)
return browser return browser
def start_client(self): def start_client(self):

View File

@@ -468,6 +468,70 @@ class MatchTak:
del runing_shop[shop_name] del runing_shop[shop_name]
self.log(f"店铺 {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): def process_country(self, driver: AmzoneMatchAction, country_code: str, task_id: int, shop_name: str, risk_listing_filter: str,limit:str=None):
"""处理单个国家的审批任务 """处理单个国家的审批任务
@@ -491,86 +555,104 @@ class MatchTak:
runing_task[task_id]["current_country"] = country_name runing_task[task_id]["current_country"] = country_name
# 切换国家最多重试3次 # 切换国家最多重试3次
max_retries = 3 # max_retries = 3
switch_success = False # switch_success = False
#
for retry in range(max_retries): # for retry in range(max_retries):
try: # try:
self.log(f"尝试切换到国家 {country_name} (第 {retry + 1}/{max_retries} 次)") # self.log(f"尝试切换到国家 {country_name} (第 {retry + 1}/{max_retries} 次)")
if retry > 1: # if retry > 1:
# 刷新不行就重新打开店铺 # # 刷新不行就重新打开店铺
self.log("重试前重新打开店铺...") # self.log("重试前重新打开店铺...")
try: # try:
driver.close_store() # driver.close_store()
time.sleep(3) # time.sleep(3)
driver.open_shop(shop_name) # driver.open_shop(shop_name)
except Exception as e: # except Exception as e:
self.log(f"关闭重新打开店铺: {str(e)}", "WARNING") # self.log(f"关闭重新打开店铺: {str(e)}", "WARNING")
#
# 如果不是第一次尝试,先刷新页面 # # 如果不是第一次尝试,先刷新页面
if retry > 0: # if retry > 0:
self.log("重试前刷新页面...") # self.log("重试前刷新页面...")
try: # try:
driver.tab.refresh() # driver.tab.refresh()
time.sleep(3) # time.sleep(3)
except Exception as e: # except Exception as e:
self.log(f"刷新页面失败: {str(e)}", "WARNING") # self.log(f"刷新页面失败: {str(e)}", "WARNING")
#
switch_success = driver.SwitchingCountries(country_name) # switch_success = driver.SwitchingCountries(country_name)
if switch_success: # if switch_success:
self.log(f"成功切换到国家 {country_name}") # self.log(f"成功切换到国家 {country_name}")
break # break
else: # else:
self.log(f"切换到国家 {country_name} 失败", "WARNING") # self.log(f"切换到国家 {country_name} 失败", "WARNING")
#
except Exception as e: # except Exception as e:
import traceback # import traceback
self.log(f"切换国家 {country_name} 异常: {str(e)}", "ERROR") # self.log(f"切换国家 {country_name} 异常: {str(e)}", "ERROR")
self.log(traceback.format_exc(), "ERROR") # self.log(traceback.format_exc(), "ERROR")
#
# 如果还有重试机会,等待后继续 # # 如果还有重试机会,等待后继续
if retry < max_retries - 1: # if retry < max_retries - 1:
time.sleep(2) # 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: if not switch_success or not switch_success_pg or not search_success:
error_message = f"切换到国家 {country_name} 失败,已重试 {max_retries} 次,跳过该国家" error_message = f"切换到国家({switch_success})/库存页面({switch_success_pg})/搜索筛选到指定选项({search_success}) {country_name} 失败,已重试 {max_retries} 次,跳过该国家"
self.log(error_message, "ERROR") self.log(error_message, "ERROR")
if task_id in runing_task: if task_id in runing_task:
runing_task[task_id]["processed_countries"] += 1 runing_task[task_id]["processed_countries"] += 1
return 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: if len(sku_ls) == 0:
self.log(f"国家 {country_name} 没有搜索出的商品") self.log(f"国家 {country_name} 没有需要审批的商品")
if task_id in runing_task: if task_id in runing_task:
runing_task[task_id]["processed_countries"] += 1 runing_task[task_id]["processed_countries"] += 1
return return

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 from config import runing_task, runing_shop,base_dir,DELETE_BRAND_API_BASE
class RepricingLogic: class RepricingLogic:
@staticmethod @staticmethod
def situation_1_general_decrease(my_price, rank1_price, is_first_time=False): def situation_1_general_decrease(my_price, rank1_price, is_first_time=False):
""" """
情况1常规递减跟价 (最左侧独立长图块) 情况1常规递减跟价 (最左侧独立长图块)
当不是自己购物车时,按总价当作第一名递减。
""" """
if is_first_time: if is_first_time:
return rank1_price - 0.3 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: if diff <= 0.3:
return rank1_price - 0.5 return rank1_price - (diff + 0.5)
elif diff <= 0.8: elif diff <= 0.8:
return rank1_price - 0.7 return rank1_price - (diff + 0.7)
elif diff <= 10.5: elif diff <= 10.5:
# 0.8 到 10.5 之间,原图逻辑全部是减1 # 0.8 到 10.5 之间全部是减1
return rank1_price - 1.0 return rank1_price - (diff + 1.0)
else: else:
return None # 相差10.5以上,跳过 return None # 相差10.5以上,跳过
@@ -45,55 +49,56 @@ class RepricingLogic:
def situation_2_own_buybox(my_price, rank2_price): def situation_2_own_buybox(my_price, rank2_price):
""" """
情况2已经是自己购物车 情况2已经是自己购物车
逻辑:判断与第二名的差价,如果拉开足够差距,则稍微降一点点(减0.5)保持优势,防止卖太便宜。
""" """
diff = rank2_price - my_price diff = rank2_price - my_price
# 1. 绝对差价2欧以上
if diff >= 2.0: if diff >= 2.0:
return rank2_price - 0.3 return rank2_price - 0.3
# 2. 产品售价区间判定 (是否要提高价格)
if (20 <= my_price < 30 and diff >= 4) or \ if (20 <= my_price < 30 and diff >= 4) or \
(30 <= my_price < 60 and diff >= 8) or \ (30 <= my_price < 60 and diff >= 8) or \
(60 <= my_price <= 150 and diff >= 15): (60 <= my_price <= 150 and diff >= 15):
return rank2_price - 0.3 # 满足区间大差价,提价至第二名之下 return rank2_price - 0.3
return my_price # 不满足条件则保持原价 return my_price
@staticmethod @staticmethod
def situation_3_not_own_buybox(my_price, rank1_price): 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:
return rank1_price - 0.3
# 逻辑更清晰的写法:第一名价格 - (差价 + 对应阶梯值)
if diff <= 0.3: if diff <= 0.3:
return rank1_price - 0.5 return rank1_price - (diff + 0.5)
elif 0.5 <= diff <= 0.8: elif diff <= 0.8:
return rank1_price - 0.7 return rank1_price - (diff + 0.7)
elif 0.8 < diff <= 2.5: elif diff <= 2.5:
return rank1_price - 1.0 return rank1_price - (diff + 1.0)
elif diff > 2.5: # 2.5-3.5以上跳过 else:
return None return None # 2.5-3.5以上跳过
return rank1_price - 0.3 # 默认按第一名减0.3
@staticmethod @staticmethod
def situation_4_encounter_amz_us_1st(my_price, amz_us_price): 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: if diff <= 0:
return amz_us_price - 2
elif 4 <= diff <= 6:
return amz_us_price - 3 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 @staticmethod
def situation_5_im_1st_amz_us_2nd(my_price, amz_us_price): 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 diff = amz_us_price - my_price
if diff <= 7: if diff <= 7:
return None # 相差5以内跳过 (保持原价) return None # 相差7以内跳过 (保持原价)
else: else:
return amz_us_price - 5 return amz_us_price - 5
@@ -133,16 +138,6 @@ def calculate_target_price(
price_col = recommended_price + recommended_shipping price_col = recommended_price + recommended_shipping
print(f"【紫鸟后台价格】{is_my_buybox} 推荐价格: {recommended_price},推荐运费: {recommended_shipping},总和: {price_col}") print(f"【紫鸟后台价格】{is_my_buybox} 推荐价格: {recommended_price},推荐运费: {recommended_shipping},总和: {price_col}")
return RepricingLogic.situation_1_general_decrease(my_price,price_col,is_my_buybox) 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 卖家 的情况 # # 第一名是自己, 第二名 Amazon US 卖家 的情况
shop_name_1 = front_end_data.get("top_sellers")[0].get("shop_name") 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) return RepricingLogic.situation_5_im_1st_amz_us_2nd(my_price,price_2)
# --- Step 3: 常规核心分流逻辑 (如果没有紫鸟后台数据) --- # --- Step 3: 常规核心分流逻辑 (如果没有紫鸟后台数据) ---
# 分支 A第一名是 Amazon US 卖家 (特殊强敌优先) # 分支 A第一名是 Amazon US 卖家 (特殊强敌优先)
price_1 = float(front_end_data.get("top_sellers")[0].get("price")) # 实际第一名价格 price_1 = float(front_end_data.get("top_sellers")[0].get("price")) # 实际第一名价格
if "Amazon" in shop_name_1: if "Amazon" in shop_name_1:
return RepricingLogic.situation_4_encounter_amz_us_1st(my_price,price_1) return RepricingLogic.situation_4_encounter_amz_us_1st(my_price,price_1)
# 分支 B不是 Amazon US且目前是自己的购物车 # 分支 B不是 Amazon US且目前是自己的购物车
elif is_my_buybox: elif is_my_buybox:
price_2 = float(front_end_data.get("top_sellers")[1].get("price")) # 实际第二名价格 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)}") print(f"{self.mark_name}】获取到 {len(sku_ls)}")
if len(sku_ls) == 0: if len(sku_ls) == 0:
if mode == "asin":
print(f"asin 模式查询不到,{asin}")
yield (asin, {
"statu": "查询不到"
})
break
retry_num += 1 retry_num += 1
print(f"没有获取到 ASIN 商品,重试{retry_num}/{max_retry_num}") print(f"没有获取到 ASIN 商品,重试{retry_num}/{max_retry_num}")
self.tab.refresh() self.tab.refresh()
@@ -680,19 +680,16 @@ class AmzonePriceMatch(AmamzonBase):
bottom_price = f"{sum(split_currency_values(bottom_price_text))}" bottom_price = f"{sum(split_currency_values(bottom_price_text))}"
except Exception as e: except Exception as e:
print("最低价提取失败",e) print("最低价提取失败",e)
current_price_ele = sku_ele.eles('xpath:.//b[text()="价格"]/../..//kat-input',timeout=5) current_price_ele = sku_ele.eles('xpath:.//b[text()="价格"]/../..//kat-input',timeout=5)
if len(current_price_ele) > 0: if len(current_price_ele) > 0:
current_price = current_price_ele[0].attr("value") current_price = current_price_ele[0].attr("value")
print("获取到当前价格为 ->",current_price) 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)) backend_price = float(miniprice_info.get(asin))
if float(current_price) <= backend_price: if float(current_price) <= backend_price:
# yield (asin, {
# "statu": "删除(当前价格低于最低价)",
# "deleteSkipAsin": True,
# "removeAsin": asin,
# })
yield (asin, { yield (asin, {
"statu": "跳过(当前价格低于或等于最低价)", "statu": "跳过(当前价格低于或等于最低价)",
"currentPrice": current_price, "currentPrice": current_price,
@@ -764,6 +761,9 @@ class AmzonePriceMatch(AmamzonBase):
print(asin,"-->>",f"【逻辑计算后的价格】",adjust_prices) print(asin,"-->>",f"【逻辑计算后的价格】",adjust_prices)
if adjust_prices is not None and adjust_prices != float(current_price): 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]) self.tab.actions.click(current_price_ele[0])
time.sleep(1) time.sleep(1)
current_price_ele[0].input(f"{adjust_prices}", clear=True) current_price_ele[0].input(f"{adjust_prices}", clear=True)
@@ -784,12 +784,12 @@ class AmzonePriceMatch(AmamzonBase):
"statu" : "改价成功", "statu" : "改价成功",
"currentPrice" : current_price, "currentPrice" : current_price,
"recommendedPrice" : f"{recommendedPrice}", "recommendedPrice" : f"{recommendedPrice}",
"minimumPrice" : bottom_price, #最低价格 "minimumPrice" : f"{miniprice_info.get(asin)}" if miniprice_info.get(asin) else "",#bottom_price, #最低价格
"firstPlace": price_1, "firstPlace": price_1,
"secondPlace": price_2, "secondPlace": price_2,
"cartShopName" : cartShopName, "cartShopName" : cartShopName,
"shippingFee" : f"{shipping_fee}", "shippingFee" : f"{shipping_fee}",
"priceChangeStatus" : f"{adjust_prices}", "priceChangePrice" : f"{adjust_prices}",
"priceChangeStatus" : "改价成功", "priceChangeStatus" : "改价成功",
}) })
else: else:
@@ -803,12 +803,14 @@ class AmzonePriceMatch(AmamzonBase):
"statu": "跳过,无需改价", "statu": "跳过,无需改价",
"currentPrice": current_price, "currentPrice": current_price,
"recommendedPrice": f"{recommendedPrice}", "recommendedPrice": f"{recommendedPrice}",
"minimumPrice": bottom_price, # 最低价格 # "minimumPrice": bottom_price, # 最低价格
"minimumPrice": f"{miniprice_info.get(asin)}" if miniprice_info.get(asin) else "",
"firstPlace": price_1, "firstPlace": price_1,
"secondPlace": price_2, "secondPlace": price_2,
"cartShopName": cartShopName, "cartShopName": cartShopName,
"shippingFee": f"{shipping_fee}", "shippingFee": f"{shipping_fee}",
"priceChangeStatus": "跳过,无需改价", "priceChangeStatus": "跳过,无需改价",
"priceChangePrice": f"{adjust_prices}".replace("None",""),
}) })
already_asin.add(asin) already_asin.add(asin)
@@ -1029,6 +1031,68 @@ class PriceTask:
return driver return driver
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, 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): 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 runing_task[task_id]["current_country"] = country_name
# 切换国家最多重试3次 # 切换国家最多重试3次
max_retries = 3 max_retries = 5
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)
switch_success,switch_success_pg,search_success = self.action_init( driver, country_name, shop_name, risk_listing_filter)
# 如果切换失败,直接返回 # 如果切换失败,直接返回
if not switch_success: if not switch_success or not switch_success_pg or not search_success:
error_message = f"切换到国家 {country_name} 失败,已重试 {max_retries} 次,跳过该国家" error_message = f"切换到国家({switch_success})/库存页面({switch_success_pg})/搜索筛选到指定选项({search_success}) {country_name} 失败,已重试 {max_retries} 次,跳过该国家"
self.log(error_message, "ERROR") self.log(error_message, "ERROR")
if task_id in runing_task: if task_id in runing_task:
runing_task[task_id]["processed_countries"] += 1 runing_task[task_id]["processed_countries"] += 1
return 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次 # 搜索需要审批的商品最多重试3次
sku_ls = [] # sku_ls = []
for retry in range(max_retries): # for retry in range(max_retries):
try: # try:
self.log(f"尝试搜索匹配操作商品 (第 {retry + 1}/{max_retries} 次)") # self.log(f"尝试搜索匹配操作商品 (第 {retry + 1}/{max_retries} 次)")
sku_ls = driver.search(filter_type=risk_listing_filter) # sku_ls = driver.search(filter_type=risk_listing_filter)
break # break
except Exception as e: # except Exception as e:
self.log(f"搜索商品异常: {str(e)}", "ERROR") # self.log(f"搜索商品异常: {str(e)}", "ERROR")
if retry < max_retries - 1: # if retry < max_retries - 1:
try: # try:
driver.tab.refresh() # driver.tab.refresh()
time.sleep(3) # driver.tab.wait.doc_loaded()
except Exception as refresh_error: # time.sleep(3)
self.log(f"刷新页面失败: {str(refresh_error)}", "WARNING") # 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} ,开始处理...")
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获取结果 # 处理所有需要审批的商品通过yield获取结果
@@ -1350,13 +1362,14 @@ class PriceTask:
"firstPlace": status.get("firstPlace") if status.get("firstPlace") else "", "firstPlace": status.get("firstPlace") if status.get("firstPlace") else "",
"secondPlace": status.get("secondPlace") if status.get("secondPlace") else "", "secondPlace": status.get("secondPlace") if status.get("secondPlace") else "",
"cartShopName": status.get("cartShopName") if status.get("cartShopName") else "", "cartShopName": status.get("cartShopName") if status.get("cartShopName") else "",
"priceChangeStatus": "UPDATED", # "priceChangeStatus": "UPDATED",
"deleteSkipAsin": status.get("deleteSkipAsin",False), "deleteSkipAsin": status.get("deleteSkipAsin",False),
"removeAsin": status.get("removeAsin") if status.get("removeAsin") else "", "removeAsin": status.get("removeAsin") if status.get("removeAsin") else "",
"modifyCount": "1", "modifyCount": "1",
"shippingFee" : status.get("shippingFee") if status.get("shippingFee") else "", "shippingFee" : status.get("shippingFee") if status.get("shippingFee") else "",
"status": status.get("statu"), "status": status.get("statu"),
"priceChangeStatus" : status.get("priceChangeStatus") if status.get("priceChangeStatus") else "", "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__': if __name__ == '__main__':
# 使用示例 # 使用示例
# user_info = { # user_info = {
# "company": "rongchuang123", # "company": "尾号5578的公司115",
# "username": "自动化_Robot", # "username": "自动化_Robot",
# "password": "#20zsg25" # "password": "#20zsg25"
# } # }
# shop_name = "郭亚芳" # shop_name = "刘建煌"
# country = "国" # country = "国"
# kill_process('v6') # kill_process('v6')
# driver = AmzonePriceMatch(user_info) # driver = AmzonePriceMatch(user_info)
# browser = driver.open_shop(shop_name) # browser = driver.open_shop(shop_name)
# sw_suc = driver.SwitchingCountries(country) # sw_suc = driver.SwitchingCountries(country)
# driver.SwitchPage() # driver.SwitchPage()
# risk_listing_filter = "Active" # risk_listing_filter = "Active"
# _shopMallName = "yafang123" # _shopMallName = "Jianhuang 888"
# ap_asin ="B0BTHBJDKG" # ap_asin ="B0F25Y52PH"
# skip_asin = [] # skip_asin = []
# for _ in range(3): # for _ in range(3):
# try: # try:
@@ -1436,14 +1449,19 @@ if __name__ == '__main__':
# print(f"ASIN {asin} 的处理结果: {status}") # print(f"ASIN {asin} 的处理结果: {status}")
# print("已完成操作") # 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_Price = 52.51
current_shop_name = "yafang123" current_shop_name = "Jianhuang 888"
recommended_price = 44.35 recommended_price = 52.51
recommended_shipping =0.0 recommended_shipping =0.0
res = calculate_target_price( res = calculate_target_price(
front_end_data, current_Price, current_shop_name, front_end_data, current_Price, current_shop_name,
recommended_price, recommended_shipping 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

@@ -196,9 +196,12 @@ def proxy(path):
continue continue
headers[key] = value headers[key] = value
ignore_url = [f"{JAVA_API_BASE}/api/delete-brand/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/delete-brand/history",
f"{JAVA_API_BASE}/api/product-risk-resolve/tasks/batch", 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: if target_url not in ignore_url:
try: try:
print("=============================") print("=============================")
@@ -219,7 +222,7 @@ def proxy(path):
data=request.get_data() if request.get_data() else None, data=request.get_data() if request.get_data() else None,
cookies=request.cookies, cookies=request.cookies,
stream=True, # 启用流式传输 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, 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) 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( webview.start(
debug=False, debug=True,
storage_path=cache_path, storage_path=cache_path,
private_mode=False private_mode=False
) )

View File

@@ -25,8 +25,19 @@
<hutool.version>5.8.36</hutool.version> <hutool.version>5.8.36</hutool.version>
<rocketmq-spring.version>2.3.5</rocketmq-spring.version> <rocketmq-spring.version>2.3.5</rocketmq-spring.version>
<minio.version>8.5.17</minio.version> <minio.version>8.5.17</minio.version>
<javassist.version>3.28.0-GA</javassist.version>
</properties> </properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.javassist</groupId>
<artifactId>javassist</artifactId>
<version>${javassist.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies> <dependencies>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>

View File

@@ -12,4 +12,8 @@ public class TransientStorageProperties {
private String accessKeyId; private String accessKeyId;
private String accessKeySecret; private String accessKeySecret;
private String region = "us-east-1"; 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) { 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) { private String rowKey(AppearancePatentResultRowDto row) {

View File

@@ -83,7 +83,10 @@ public class AppearancePatentController {
public ApiResponse<Void> result( public ApiResponse<Void> result(
@Parameter(description = "外观专利检测任务 ID。任务必须处于 RUNNING 状态。", required = true, example = "3938") @Parameter(description = "外观专利检测任务 ID。任务必须处于 RUNNING 状态。", required = true, example = "3938")
@PathVariable Long taskId, @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); service.submitResult(taskId, request);
return ApiResponse.success(null); return ApiResponse.success(null);
} }

View File

@@ -121,7 +121,7 @@ public class AppearancePatentTaskService {
.filter(Objects::nonNull) .filter(Objects::nonNull)
.toList(); .toList();
if (sourceFiles.isEmpty()) { if (sourceFiles.isEmpty()) {
throw new BusinessException("璇峰厛涓婁紶 Excel 鏂囦欢"); throw new BusinessException("请先上传 Excel 文件");
} }
List<AppearancePatentParsedRowVo> allRows = new ArrayList<>(); List<AppearancePatentParsedRowVo> allRows = new ArrayList<>();
List<String> mergedHeaders = new ArrayList<>(); List<String> mergedHeaders = new ArrayList<>();
@@ -461,10 +461,10 @@ public class AppearancePatentTaskService {
private SubmitContext persistSubmittedChunk(Long taskId, AppearancePatentSubmitResultRequest request) { private SubmitContext persistSubmittedChunk(Long taskId, AppearancePatentSubmitResultRequest request) {
FileTaskEntity task = fileTaskMapper.selectById(taskId); FileTaskEntity task = fileTaskMapper.selectById(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) { if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
throw new BusinessException("浠诲姟涓嶅瓨鍦?"); throw new BusinessException("任务不存在");
} }
if (!STATUS_RUNNING.equals(task.getStatus())) { if (!STATUS_RUNNING.equals(task.getStatus())) {
throw new BusinessException("浠诲姟涓嶆槸杩愯涓姸鎬?"); throw new BusinessException("任务不是运行中状态");
} }
int chunkIndex = request.getChunkIndex() == null ? 0 : request.getChunkIndex(); int chunkIndex = request.getChunkIndex() == null ? 0 : request.getChunkIndex();
@@ -482,7 +482,7 @@ public class AppearancePatentTaskService {
.last("limit 1")); .last("limit 1"));
if (existing == null) { if (existing == null) {
List<AppearancePatentResultRowDto> rawRows = flattenSubmittedRows(request); List<AppearancePatentResultRowDto> rawRows = flattenSubmittedRows(request);
String payloadJson = writeJson(rawRows, "缁撴灉搴忓垪鍖栧け璐?"); String payloadJson = writeJson(rawRows, "结果序列化失败");
TaskChunkEntity chunk = new TaskChunkEntity(); TaskChunkEntity chunk = new TaskChunkEntity();
chunk.setTaskId(taskId); chunk.setTaskId(taskId);
@@ -514,7 +514,7 @@ public class AppearancePatentTaskService {
private void completeSubmittedChunk(SubmitContext context) { private void completeSubmittedChunk(SubmitContext context) {
FileTaskEntity task = fileTaskMapper.selectById(context.task().getId()); FileTaskEntity task = fileTaskMapper.selectById(context.task().getId());
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) { if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
throw new BusinessException("浠诲姟涓嶅瓨鍦?"); throw new BusinessException("任务不存在");
} }
if (!STATUS_RUNNING.equals(task.getStatus())) { if (!STATUS_RUNNING.equals(task.getStatus())) {
log.info("[appearance-patent] skip completion because task already finalized taskId={} status={}", log.info("[appearance-patent] skip completion because task already finalized taskId={} status={}",
@@ -1379,7 +1379,7 @@ public class AppearancePatentTaskService {
} }
private String normalize(String val) { 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) { 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) { if (value == null) {
return ""; return "";
} }
return value.replace("\ufeff", "") return value.replace(String.valueOf((char) 0xFEFF), "")
.replace("\u3000", " ") .replace((char) 0x3000, ' ')
.replace("\r\n", " ") .replace("\r\n", " ")
.replace("\r", " ") .replace("\r", " ")
.replace("\n", " ") .replace("\n", " ")

View File

@@ -475,8 +475,8 @@ public class ConvertRunService {
if (value == null) { if (value == null) {
return ""; return "";
} }
return value.replace("\ufeff", "") return value.replace(String.valueOf((char) 0xFEFF), "")
.replace("\u3000", " ") .replace((char) 0x3000, ' ')
.replace("\r\n", " ") .replace("\r\n", " ")
.replace("\r", " ") .replace("\r", " ")
.replace("\n", " ") .replace("\n", " ")

View File

@@ -47,8 +47,8 @@ import java.util.zip.ZipOutputStream;
@Slf4j @Slf4j
public class DedupeRunService { public class DedupeRunService {
private static final String HEADER_SPLIT_MARKER = "idASIN\u56fd\u5bb6\u72b6\u6001\u4ef7\u683c\u53d8\u4f53\u6570\u91cf"; private static final String HEADER_SPLIT_MARKER = "idASIN国家状态价格变体数量";
private static final String HEADER_STOP_COLUMN = "\u7f29\u7565\u56fe\u5730\u57408"; private static final String HEADER_STOP_COLUMN = "缩略图地址8";
private final FileTaskMapper fileTaskMapper; private final FileTaskMapper fileTaskMapper;
private final FileResultMapper fileResultMapper; private final FileResultMapper fileResultMapper;
@@ -59,7 +59,7 @@ public class DedupeRunService {
public DedupeRunVo run(DedupeRunRequest request) { public DedupeRunVo run(DedupeRunRequest request) {
long runStartNs = System.nanoTime(); long runStartNs = System.nanoTime();
if (!request.isKeepIntegerIds() && !request.isKeepUnderscoreIds() && !request.isKeepIntegerMainIdsWhenNoSubIds()) { 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(); FileTaskEntity task = new FileTaskEntity();
@@ -90,7 +90,7 @@ public class DedupeRunService {
File inputFile = findLocalSourceFile(sourceFile.getFileKey()); File inputFile = findLocalSourceFile(sourceFile.getFileKey());
long findFileNs = elapsedNs(fileStartNs); long findFileNs = elapsedNs(fileStartNs);
if (inputFile == null || !inputFile.exists()) { 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(); String inputName = sourceFile.getOriginalFilename() == null ? inputFile.getName() : sourceFile.getOriginalFilename();
@@ -240,7 +240,7 @@ public class DedupeRunService {
public void deleteHistory(Long resultId, Long userId) { public void deleteHistory(Long resultId, Long userId) {
FileResultEntity entity = fileResultMapper.selectById(resultId); FileResultEntity entity = fileResultMapper.selectById(resultId);
if (entity == null || !"DEDUPE".equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) { if (entity == null || !"DEDUPE".equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
throw new BusinessException("\u8bb0\u5f55\u4e0d\u5b58\u5728"); throw new BusinessException("记录不存在");
} }
fileResultMapper.deleteById(resultId); fileResultMapper.deleteById(resultId);
} }
@@ -248,11 +248,11 @@ public class DedupeRunService {
public Resource getResultFile(Long resultId, Long userId) { public Resource getResultFile(Long resultId, Long userId) {
FileResultEntity entity = fileResultMapper.selectById(resultId); FileResultEntity entity = fileResultMapper.selectById(resultId);
if (entity == null || entity.getResultFileUrl() == null || !"DEDUPE".equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) { if (entity == null || entity.getResultFileUrl() == null || !"DEDUPE".equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
throw new BusinessException("\u7ed3\u679c\u6587\u4ef6\u4e0d\u5b58\u5728"); throw new BusinessException("结果文件不存在");
} }
File file = new File(entity.getResultFileUrl()); File file = new File(entity.getResultFileUrl());
if (!file.exists()) { if (!file.exists()) {
throw new BusinessException("\u7ed3\u679c\u6587\u4ef6\u4e0d\u5b58\u5728"); throw new BusinessException("结果文件不存在");
} }
return new FileSystemResource(file); return new FileSystemResource(file);
} }
@@ -342,18 +342,18 @@ public class DedupeRunService {
Sheet sheet = workbook.getSheetAt(0); Sheet sheet = workbook.getSheetAt(0);
Row headerRow = sheet.getRow(0); Row headerRow = sheet.getRow(0);
if (headerRow == null) { if (headerRow == null) {
throw new BusinessException("Excel \u8868\u5934\u4e3a\u7a7a"); throw new BusinessException("Excel 表头为空");
} }
Map<String, Integer> headerMap = buildHeaderMap(headerRow, formatter); Map<String, Integer> headerMap = buildHeaderMap(headerRow, formatter);
if (headerMap.isEmpty()) { if (headerMap.isEmpty()) {
throw new BusinessException("Excel \u8868\u5934\u4e3a\u7a7a"); throw new BusinessException("Excel 表头为空");
} }
List<String> missing = selectedColumns.stream() List<String> missing = selectedColumns.stream()
.filter(column -> !headerMap.containsKey(column)) .filter(column -> !headerMap.containsKey(column))
.toList(); .toList();
if (!missing.isEmpty()) { 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()); List<Integer> selectedIndexes = new ArrayList<>(selectedColumns.size());
@@ -478,7 +478,7 @@ public class DedupeRunService {
zos.closeEntry(); zos.closeEntry();
} }
} catch (Exception ex) { } catch (Exception ex) {
throw new BusinessException("\u53bb\u91cd\u7ed3\u679c\u6253\u5305\u5931\u8d25\uff1a" + ex.getMessage()); throw new BusinessException("去重结果打包失败:" + ex.getMessage());
} }
return zipFile; return zipFile;
} }
@@ -602,7 +602,7 @@ public class DedupeRunService {
boolean previousWhitespace = false; boolean previousWhitespace = false;
for (int i = start; i < end; i++) { for (int i = start; i < end; i++) {
char ch = value.charAt(i); char ch = value.charAt(i);
if (ch == '\uFEFF') { if (ch == 0xFEFF) {
if (builder == null) { if (builder == null) {
builder = new StringBuilder(value.length()); builder = new StringBuilder(value.length());
builder.append(value, start, i); builder.append(value, start, i);
@@ -629,11 +629,11 @@ public class DedupeRunService {
} }
private boolean isTrimmedWhitespace(char ch) { 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) { 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) { 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); DeleteBrandResultFileDto dto = objectMapper.readValue(payloadJson, DeleteBrandResultFileDto.class);
grouped.computeIfAbsent(entity.getScopeKey(), ignored -> new ArrayList<>()).add(dto); grouped.computeIfAbsent(entity.getScopeKey(), ignored -> new ArrayList<>()).add(dto);
} catch (Exception ex) { } 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()) { for (List<DeleteBrandResultFileDto> chunks : grouped.values()) {

View File

@@ -109,8 +109,8 @@ public class LocalFileStorageService {
if (value == null) { if (value == null) {
return ""; return "";
} }
return value.replace("\ufeff", "") return value.replace(String.valueOf((char) 0xFEFF), "")
.replace("\u3000", " ") .replace((char) 0x3000, ' ')
.replace("\r\n", " ") .replace("\r\n", " ")
.replace("\r", " ") .replace("\r", " ")
.replace("\n", " ") .replace("\n", " ")

View File

@@ -5,18 +5,24 @@ import io.minio.GetObjectArgs;
import io.minio.MinioClient; import io.minio.MinioClient;
import io.minio.PutObjectArgs; import io.minio.PutObjectArgs;
import io.minio.RemoveObjectArgs; import io.minio.RemoveObjectArgs;
import io.minio.StatObjectArgs;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import okhttp3.OkHttpClient;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.Objects; import java.util.Objects;
import java.util.concurrent.TimeUnit;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j
public class RustfsObjectStorageService { public class RustfsObjectStorageService {
private final TransientStorageProperties properties; private final TransientStorageProperties properties;
private volatile OkHttpClient httpClient;
public boolean isConfigured() { public boolean isConfigured() {
return notBlank(properties.getEndpoint()) return notBlank(properties.getEndpoint())
@@ -29,19 +35,29 @@ public class RustfsObjectStorageService {
if (!isConfigured()) { if (!isConfigured()) {
throw new IllegalStateException("transient storage is not configured"); throw new IllegalStateException("transient storage is not configured");
} }
try (ByteArrayInputStream stream = new ByteArrayInputStream( byte[] bytes = Objects.requireNonNullElse(content, "").getBytes(StandardCharsets.UTF_8);
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() buildClient().putObject(PutObjectArgs.builder()
.bucket(properties.getBucket()) .bucket(properties.getBucket())
.object(objectKey) .object(objectKey)
.stream(stream, stream.available(), -1) .stream(stream, bytes.length, -1)
.contentType("application/json") .contentType("application/json")
.build()); .build());
verifyObjectVisible(objectKey);
return objectKey; return objectKey;
} catch (Exception ex) { } catch (Exception ex) {
throw new IllegalStateException("failed to upload payload to transient storage", 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) { public String readObjectAsString(String objectKey) {
if (!isConfigured()) { if (!isConfigured()) {
@@ -76,9 +92,57 @@ public class RustfsObjectStorageService {
.endpoint(properties.getEndpoint()) .endpoint(properties.getEndpoint())
.credentials(properties.getAccessKeyId(), properties.getAccessKeySecret()) .credentials(properties.getAccessKeyId(), properties.getAccessKeySecret())
.region(properties.getRegion()) .region(properties.getRegion())
.httpClient(getHttpClient())
.build(); .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) { private boolean notBlank(String value) {
return value != null && !value.isBlank(); return value != null && !value.isBlank();
} }

View File

@@ -281,7 +281,6 @@ public class PatrolDeleteTaskService {
return vo; return vo;
} }
@Transactional
public void submitResult(Long taskId, PatrolDeleteSubmitResultRequest request) { public void submitResult(Long taskId, PatrolDeleteSubmitResultRequest request) {
if (taskId == null || taskId <= 0) { if (taskId == null || taskId <= 0) {
throw new BusinessException("taskId 不合法"); throw new BusinessException("taskId 不合法");
@@ -334,7 +333,6 @@ public class PatrolDeleteTaskService {
tryFinalizeTask(taskId, false); tryFinalizeTask(taskId, false);
} }
@Transactional
public boolean tryFinalizeTask(Long taskId, boolean fromCompensation) { public boolean tryFinalizeTask(Long taskId, boolean fromCompensation) {
if (taskId == null || taskId <= 0) { if (taskId == null || taskId <= 0) {
return false; return false;

View File

@@ -1,5 +1,6 @@
package com.nanri.aiimage.modules.pricetrack.model.dto; package com.nanri.aiimage.modules.pricetrack.model.dto;
import com.fasterxml.jackson.annotation.JsonAlias;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
@@ -88,6 +89,10 @@ public class PriceTrackSubmitResultRequest {
@Schema(description = "购物车店铺名", example = "店铺A") @Schema(description = "购物车店铺名", example = "店铺A")
private String cartShopName; private String cartShopName;
@JsonAlias({"changedPrice", "changePrice", "modifiedPrice", "newPrice", "targetPrice", "updatedPrice", "改价价格"})
@Schema(description = "改价价格", example = "18.99")
private String priceChangePrice;
@Schema(description = "改价情况", example = "UPDATED") @Schema(description = "改价情况", example = "UPDATED")
private String priceChangeStatus; private String priceChangeStatus;

View File

@@ -29,6 +29,7 @@ public class PriceTrackExcelAssemblyService {
"第一名", "第一名",
"第二名", "第二名",
"购物车店铺名", "购物车店铺名",
"改价价格",
"改价情况", "改价情况",
"修改次数", "修改次数",
"状态" "状态"
@@ -40,13 +41,14 @@ public class PriceTrackExcelAssemblyService {
workbook.setCompressTempFiles(true); workbook.setCompressTempFiles(true);
try (FileOutputStream fos = new FileOutputStream(outputXlsx)) { try (FileOutputStream fos = new FileOutputStream(outputXlsx)) {
for (ProductRiskCountryCode code : ProductRiskCountryCode.values()) { for (ProductRiskCountryCode code : ProductRiskCountryCode.values()) {
String sheetName = code.name(); String countryCode = code.name();
String sheetName = sheetNameOf(code);
Sheet sheet = workbook.createSheet(sheetName); Sheet sheet = workbook.createSheet(sheetName);
Row headerRow = sheet.createRow(0); Row headerRow = sheet.createRow(0);
for (int i = 0; i < RESULT_HEADER.length; i++) { for (int i = 0; i < RESULT_HEADER.length; i++) {
headerRow.createCell(i).setCellValue(RESULT_HEADER[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; int rowIdx = 1;
for (PriceTrackSubmitResultRequest.AsinResult item : rows) { for (PriceTrackSubmitResultRequest.AsinResult item : rows) {
if (item == null) { if (item == null) {
@@ -62,9 +64,10 @@ public class PriceTrackExcelAssemblyService {
row.createCell(6).setCellValue(valueOf(item.getFirstPlace())); row.createCell(6).setCellValue(valueOf(item.getFirstPlace()));
row.createCell(7).setCellValue(valueOf(item.getSecondPlace())); row.createCell(7).setCellValue(valueOf(item.getSecondPlace()));
row.createCell(8).setCellValue(valueOf(item.getCartShopName())); row.createCell(8).setCellValue(valueOf(item.getCartShopName()));
row.createCell(9).setCellValue(valueOf(item.getPriceChangeStatus())); row.createCell(9).setCellValue(valueOf(item.getPriceChangePrice()));
row.createCell(10).setCellValue(valueOf(item.getModifyCount())); row.createCell(10).setCellValue(valueOf(item.getPriceChangeStatus()));
row.createCell(11).setCellValue(valueOf(item.getStatus())); row.createCell(11).setCellValue(valueOf(item.getModifyCount()));
row.createCell(12).setCellValue(valueOf(item.getStatus()));
} }
applyDefaultColumnWidths(sheet); applyDefaultColumnWidths(sheet);
} }
@@ -113,8 +116,18 @@ public class PriceTrackExcelAssemblyService {
return value == null ? "" : value; 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) { 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++) { for (int i = 0; i < widths.length; i++) {
sheet.setColumnWidth(i, widths[i] * 256); sheet.setColumnWidth(i, widths[i] * 256);
} }

View File

@@ -127,6 +127,21 @@ public class PriceTrackTaskService {
return tasks; 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) { public PriceTrackDashboardVo dashboard(Long userId) {
if (userId == null || userId <= 0) throw new BusinessException("user_id 不合法"); if (userId == null || userId <= 0) throw new BusinessException("user_id 不合法");
PriceTrackDashboardVo vo = new PriceTrackDashboardVo(); PriceTrackDashboardVo vo = new PriceTrackDashboardVo();
@@ -166,7 +181,7 @@ public class PriceTrackTaskService {
.distinct() .distinct()
.toList(); .toList();
if (!taskIds.isEmpty()) { if (!taskIds.isEmpty()) {
List<FileTaskEntity> tasks = selectTasksByIdsInBatches(taskIds); List<FileTaskEntity> tasks = selectTaskStatusesByIdsInBatches(taskIds);
for (FileTaskEntity t : tasks) { for (FileTaskEntity t : tasks) {
if (t != null) statusByTaskId.put(t.getId(), t.getStatus()); if (t != null) statusByTaskId.put(t.getId(), t.getStatus());
} }
@@ -383,7 +398,6 @@ public class PriceTrackTaskService {
return vo; return vo;
} }
@Transactional
public void submitResult(Long taskId, PriceTrackSubmitResultRequest request) { public void submitResult(Long taskId, PriceTrackSubmitResultRequest request) {
if (taskId == null || taskId <= 0) throw new BusinessException("taskId 不合法"); if (taskId == null || taskId <= 0) throw new BusinessException("taskId 不合法");
if (request == null || request.getShops() == null || request.getShops().isEmpty()) { if (request == null || request.getShops() == null || request.getShops().isEmpty()) {
@@ -458,7 +472,6 @@ public class PriceTrackTaskService {
updateTaskStatusFromLatestRows(task, latest); updateTaskStatusFromLatestRows(task, latest);
} }
@Transactional
public boolean tryFinalizeTask(Long taskId, boolean fromCompensation) { public boolean tryFinalizeTask(Long taskId, boolean fromCompensation) {
if (taskId == null || taskId <= 0) return false; if (taskId == null || taskId <= 0) return false;
FileTaskEntity task = loadTaskForExecution(taskId); FileTaskEntity task = loadTaskForExecution(taskId);
@@ -1139,6 +1152,7 @@ public class PriceTrackTaskService {
merged.setFirstPlace(firstNonBlank(incoming.getFirstPlace(), merged.getFirstPlace())); merged.setFirstPlace(firstNonBlank(incoming.getFirstPlace(), merged.getFirstPlace()));
merged.setSecondPlace(firstNonBlank(incoming.getSecondPlace(), merged.getSecondPlace())); merged.setSecondPlace(firstNonBlank(incoming.getSecondPlace(), merged.getSecondPlace()));
merged.setCartShopName(firstNonBlank(incoming.getCartShopName(), merged.getCartShopName())); merged.setCartShopName(firstNonBlank(incoming.getCartShopName(), merged.getCartShopName()));
merged.setPriceChangePrice(firstNonBlank(incoming.getPriceChangePrice(), merged.getPriceChangePrice()));
merged.setPriceChangeStatus(firstNonBlank(incoming.getPriceChangeStatus(), merged.getPriceChangeStatus())); merged.setPriceChangeStatus(firstNonBlank(incoming.getPriceChangeStatus(), merged.getPriceChangeStatus()));
merged.setModifyCount(firstNonBlank(incoming.getModifyCount(), merged.getModifyCount())); merged.setModifyCount(firstNonBlank(incoming.getModifyCount(), merged.getModifyCount()));
merged.setStatus(firstNonBlank(incoming.getStatus(), merged.getStatus())); merged.setStatus(firstNonBlank(incoming.getStatus(), merged.getStatus()));
@@ -1163,6 +1177,7 @@ public class PriceTrackTaskService {
out.setFirstPlace(row.getFirstPlace()); out.setFirstPlace(row.getFirstPlace());
out.setSecondPlace(row.getSecondPlace()); out.setSecondPlace(row.getSecondPlace());
out.setCartShopName(row.getCartShopName()); out.setCartShopName(row.getCartShopName());
out.setPriceChangePrice(row.getPriceChangePrice());
out.setPriceChangeStatus(row.getPriceChangeStatus()); out.setPriceChangeStatus(row.getPriceChangeStatus());
out.setModifyCount(row.getModifyCount()); out.setModifyCount(row.getModifyCount());
out.setStatus(row.getStatus()); out.setStatus(row.getStatus());

View File

@@ -124,6 +124,21 @@ public class ProductRiskTaskService {
return tasks; 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) { public ProductRiskDashboardVo dashboard(Long userId) {
if (userId == null || userId <= 0) { if (userId == null || userId <= 0) {
throw new BusinessException("user_id 不合法"); throw new BusinessException("user_id 不合法");
@@ -166,7 +181,7 @@ public class ProductRiskTaskService {
.distinct() .distinct()
.toList(); .toList();
if (!taskIds.isEmpty()) { if (!taskIds.isEmpty()) {
List<FileTaskEntity> tasks = selectTasksByIdsInBatches(taskIds); List<FileTaskEntity> tasks = selectTaskStatusesByIdsInBatches(taskIds);
for (FileTaskEntity t : tasks) { for (FileTaskEntity t : tasks) {
if (t != null) { if (t != null) {
statusByTaskId.put(t.getId(), t.getStatus()); statusByTaskId.put(t.getId(), t.getStatus());
@@ -465,7 +480,6 @@ public class ProductRiskTaskService {
return vo; return vo;
} }
@Transactional
public void submitResult(Long taskId, ProductRiskSubmitResultRequest request) { public void submitResult(Long taskId, ProductRiskSubmitResultRequest request) {
if (taskId == null || taskId <= 0) { if (taskId == null || taskId <= 0) {
throw new BusinessException("taskId 不合法"); throw new BusinessException("taskId 不合法");
@@ -630,7 +644,6 @@ public class ProductRiskTaskService {
cleanupTaskCacheIfTerminal(taskId, task.getStatus()); cleanupTaskCacheIfTerminal(taskId, task.getStatus());
} }
@Transactional
public boolean tryFinalizeTask(Long taskId, boolean fromCompensation) { public boolean tryFinalizeTask(Long taskId, boolean fromCompensation) {
if (taskId == null || taskId <= 0) { if (taskId == null || taskId <= 0) {
return false; return false;

View File

@@ -287,7 +287,6 @@ public class QueryAsinTaskService {
return vo; return vo;
} }
@Transactional
public void submitResult(Long taskId, QueryAsinSubmitResultRequest request) { public void submitResult(Long taskId, QueryAsinSubmitResultRequest request) {
if (taskId == null || taskId <= 0) { if (taskId == null || taskId <= 0) {
throw new BusinessException("taskId 不合法"); throw new BusinessException("taskId 不合法");
@@ -340,7 +339,6 @@ public class QueryAsinTaskService {
tryFinalizeTask(taskId, false); tryFinalizeTask(taskId, false);
} }
@Transactional
public boolean tryFinalizeTask(Long taskId, boolean fromCompensation) { public boolean tryFinalizeTask(Long taskId, boolean fromCompensation) {
if (taskId == null || taskId <= 0) { if (taskId == null || taskId <= 0) {
return false; 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.common.api.ApiResponse;
import com.nanri.aiimage.modules.shopkey.model.dto.SkipPriceAsinCountryUpdateRequest; 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.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.SkipPriceAsinItemVo;
import com.nanri.aiimage.modules.shopkey.model.vo.SkipPriceAsinPageVo; import com.nanri.aiimage.modules.shopkey.model.vo.SkipPriceAsinPageVo;
import com.nanri.aiimage.modules.shopkey.service.SkipPriceAsinService; 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.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@RestController @RestController
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -59,6 +62,40 @@ public class SkipPriceAsinController {
skipPriceAsinService.createOrUpdate(request, operatorId, Boolean.TRUE.equals(superAdmin))); 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}") @PutMapping("/{id}/countries/{country}")
@Operation(summary = "编辑指定国家的跳过跟价 ASIN") @Operation(summary = "编辑指定国家的跳过跟价 ASIN")
public ApiResponse<SkipPriceAsinItemVo> updateCountry( 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.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageGroupEntity; import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageGroupEntity;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper @Mapper
public interface ShopManageGroupMapper extends BaseMapper<ShopManageGroupEntity> { 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; package com.nanri.aiimage.modules.shopkey.model.entity;
import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
@@ -25,31 +26,31 @@ public class SkipPriceAsinEntity {
@TableField("asin_de") @TableField("asin_de")
private String asinDe; private String asinDe;
@TableField("minimum_price_de") @TableField(value = "minimum_price_de", updateStrategy = FieldStrategy.ALWAYS)
private BigDecimal minimumPriceDe; private BigDecimal minimumPriceDe;
@TableField("asin_uk") @TableField("asin_uk")
private String asinUk; private String asinUk;
@TableField("minimum_price_uk") @TableField(value = "minimum_price_uk", updateStrategy = FieldStrategy.ALWAYS)
private BigDecimal minimumPriceUk; private BigDecimal minimumPriceUk;
@TableField("asin_fr") @TableField("asin_fr")
private String asinFr; private String asinFr;
@TableField("minimum_price_fr") @TableField(value = "minimum_price_fr", updateStrategy = FieldStrategy.ALWAYS)
private BigDecimal minimumPriceFr; private BigDecimal minimumPriceFr;
@TableField("asin_it") @TableField("asin_it")
private String asinIt; private String asinIt;
@TableField("minimum_price_it") @TableField(value = "minimum_price_it", updateStrategy = FieldStrategy.ALWAYS)
private BigDecimal minimumPriceIt; private BigDecimal minimumPriceIt;
@TableField("asin_es") @TableField("asin_es")
private String asinEs; private String asinEs;
@TableField("minimum_price_es") @TableField(value = "minimum_price_es", updateStrategy = FieldStrategy.ALWAYS)
private BigDecimal minimumPriceEs; private BigDecimal minimumPriceEs;
@TableField("created_at") @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 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 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 QueryAsinMapper queryAsinMapper;
private final ShopManageGroupService shopManageGroupService; private final ShopManageGroupService shopManageGroupService;
@@ -79,7 +79,7 @@ public class QueryAsinService {
List<QueryAsinEntity> rows = queryAsinMapper List<QueryAsinEntity> rows = queryAsinMapper
.selectList(query.last("LIMIT " + ((safePage - 1) * safePageSize) + ", " + safePageSize)); .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) .map(QueryAsinEntity::getGroupId)
.filter(id -> id != null && id > 0) .filter(id -> id != null && id > 0)
.distinct() .distinct()
@@ -316,22 +316,6 @@ public class QueryAsinService {
return vo; 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) { private Set<String> normalizeCountries(List<String> countries) {
LinkedHashSet<String> normalized = new LinkedHashSet<>(); LinkedHashSet<String> normalized = new LinkedHashSet<>();
if (countries != null) { if (countries != null) {
@@ -384,11 +368,11 @@ public class QueryAsinService {
return upper; return upper;
} }
return switch (normalizeHeaderKey(normalized)) { return switch (normalizeHeaderKey(normalized)) {
case "\u5fb7\u56fd", "\u5fb7", "de", "germany", "german", "deutschland" -> "DE"; case "德国", "", "de", "germany", "german", "deutschland" -> "DE";
case "\u82f1\u56fd", "\u82f1", "uk", "gb", "greatbritain", "britain", "unitedkingdom" -> "UK"; case "英国", "", "uk", "gb", "greatbritain", "britain", "unitedkingdom" -> "UK";
case "\u6cd5\u56fd", "\u6cd5", "fr", "france", "french" -> "FR"; case "法国", "", "fr", "france", "french" -> "FR";
case "\u610f\u5927\u5229", "\u610f", "it", "italy", "italian" -> "IT"; case "意大利", "", "it", "italy", "italian" -> "IT";
case "\u897f\u73ed\u7259", "\u897f", "es", "spain", "spanish", "espana" -> "ES"; case "西班牙", "西", "es", "spain", "spanish", "espana" -> "ES";
default -> ""; default -> "";
}; };
} }
@@ -860,11 +844,11 @@ public class QueryAsinService {
if (index != null) { if (index != null) {
return index; return index;
} }
index = firstHeader("groupname", "\u5206\u7ec4id", "\u5206\u7ec4"); index = firstHeader("groupname", "分组id", "分组");
if (index != null) { if (index != null) {
return index; return index;
} }
return findHeaderByPredicate(key -> key.contains("\u5206\u7ec4") || key.contains("group")); return findHeaderByPredicate(key -> key.contains("分组") || key.contains("group"));
} }
private Integer resolveShopHeaderIndex() { private Integer resolveShopHeaderIndex() {
@@ -873,13 +857,13 @@ public class QueryAsinService {
return index; return index;
} }
index = firstHeader( index = firstHeader(
"\u5e97\u94fa\u540d", "\u5e97\u94fa\u540d\u79f0", "\u5e97\u94fa\u8d26\u53f7", "\u8d26\u53f7", "店铺名", "店铺名称", "店铺账号", "账号",
"account", "selleraccount", "storeaccount", "storename"); "account", "selleraccount", "storeaccount", "storename");
if (index != null) { if (index != null) {
return index; return index;
} }
return findHeaderByPredicate(key -> 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("shop") && (key.contains("name") || key.contains("account")))
|| key.contains("selleraccount") || key.contains("selleraccount")
|| key.contains("storeaccount")); || key.contains("storeaccount"));
@@ -891,32 +875,32 @@ public class QueryAsinService {
return index; return index;
} }
return switch (country) { return switch (country) {
case "DE" -> firstHeader("\u5fb7\u56fd", "\u5fb7", "\u5fb7\u56fdasin"); case "DE" -> firstHeader("德国", "", "德国asin");
case "UK" -> firstHeader("\u82f1\u56fd", "\u82f1", "\u82f1\u56fdasin"); case "UK" -> firstHeader("英国", "", "英国asin");
case "FR" -> firstHeader("\u6cd5\u56fd", "\u6cd5", "\u6cd5\u56fdasin"); case "FR" -> firstHeader("法国", "", "法国asin");
case "IT" -> firstHeader("\u610f\u5927\u5229", "\u610f", "\u610f\u5927\u5229asin"); case "IT" -> firstHeader("意大利", "", "意大利asin");
case "ES" -> firstHeader("\u897f\u73ed\u7259", "\u897f", "\u897f\u73ed\u7259asin"); case "ES" -> firstHeader("西班牙", "西", "西班牙asin");
default -> null; default -> null;
}; };
} }
private Integer findSingleCountryIndex() { private Integer findSingleCountryIndex() {
Integer index = firstHeader( 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) { if (index != null) {
return index; return index;
} }
return findHeaderByPredicate(key -> return findHeaderByPredicate(key ->
key.contains("\u56fd\u5bb6") key.contains("国家")
|| key.contains("\u7ad9\u70b9") || key.contains("站点")
|| key.contains("country") || key.contains("country")
|| key.contains("marketplace") || key.contains("marketplace")
|| key.equals("site")); || key.equals("site"));
} }
private Integer findSingleAsinIndex() { private Integer findSingleAsinIndex() {
return firstHeader("asin", "\u67e5\u8be2asin", "\u4ea7\u54c1asin", "\u94fe\u63a5asin"); return firstHeader("asin", "查询asin", "产品asin", "链接asin");
} }
private boolean hasWideCountryColumns() { private boolean hasWideCountryColumns() {

View File

@@ -48,6 +48,7 @@ public class ShopManageGroupService {
public List<ShopManageGroupItemVo> list(Long createdById) { public List<ShopManageGroupItemVo> list(Long createdById) {
return toVoList(groupMapper.selectList(new LambdaQueryWrapper<ShopManageGroupEntity>() return toVoList(groupMapper.selectList(new LambdaQueryWrapper<ShopManageGroupEntity>()
.eq(createdById != null && createdById > 0, ShopManageGroupEntity::getCreatedById, createdById) .eq(createdById != null && createdById > 0, ShopManageGroupEntity::getCreatedById, createdById)
.or(createdById != null && createdById > 0, wrapper -> wrapper.eq(ShopManageGroupEntity::getUserId, createdById))
.orderByAsc(ShopManageGroupEntity::getId))); .orderByAsc(ShopManageGroupEntity::getId)));
} }
@@ -74,40 +75,44 @@ public class ShopManageGroupService {
.collect(Collectors.toCollection(LinkedHashSet::new)); .collect(Collectors.toCollection(LinkedHashSet::new));
} }
Long normalizedOperatorId = normalizePositiveId(operatorId, "操作人不能为空"); Long normalizedOperatorId = normalizePositiveId(operatorId, "操作人不能为空");
LinkedHashSet<Long> groupIds = groupMapper.selectList(new LambdaQueryWrapper<ShopManageGroupEntity>() return groupMapper.selectAccessibleGroupIds(normalizedOperatorId)
.select(ShopManageGroupEntity::getId)
.eq(ShopManageGroupEntity::getCreatedById, normalizedOperatorId))
.stream() .stream()
.map(ShopManageGroupEntity::getId)
.filter(id -> id != null && id > 0) .filter(id -> id != null && id > 0)
.collect(Collectors.toCollection(LinkedHashSet::new)); .collect(Collectors.toCollection(LinkedHashSet::new));
List<Long> memberGroupIds = groupMemberMapper.selectList(new LambdaQueryWrapper<ShopManageGroupMemberEntity>() }
.select(ShopManageGroupMemberEntity::getGroupId)
.eq(ShopManageGroupMemberEntity::getUserId, normalizedOperatorId)) public Set<Long> listAccessibleCreatorUserIds(Long operatorId, boolean superAdmin) {
if (superAdmin) {
return Set.of();
}
Long normalizedOperatorId = normalizePositiveId(operatorId, "操作人不能为空");
return groupMapper.selectAccessibleCreatorUserIds(normalizedOperatorId)
.stream() .stream()
.map(ShopManageGroupMemberEntity::getGroupId)
.filter(id -> id != null && id > 0) .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(); .toList();
groupIds.addAll(memberGroupIds); if (normalizedGroupIds.isEmpty()) {
if (!memberGroupIds.isEmpty()) { return Map.of();
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());
} }
} return groupMapper.selectList(new LambdaQueryWrapper<ShopManageGroupEntity>()
return groupIds; .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 @Transactional
@@ -207,8 +212,9 @@ public class ShopManageGroupService {
return; return;
} }
Long normalizedOperatorId = normalizePositiveId(operatorId, "操作人不能为空"); Long normalizedOperatorId = normalizePositiveId(operatorId, "操作人不能为空");
boolean createdBySelf = entity.getCreatedById() != null && entity.getCreatedById().equals(normalizedOperatorId); boolean leaderBySelf = entity.getCreatedById() != null && entity.getCreatedById().equals(normalizedOperatorId);
if (!createdBySelf) { boolean legacyLeaderBySelf = entity.getUserId() != null && entity.getUserId().equals(normalizedOperatorId);
if (!leaderBySelf && !legacyLeaderBySelf) {
throw new BusinessException("只有组长才能维护分组"); throw new BusinessException("只有组长才能维护分组");
} }
} }
@@ -332,8 +338,9 @@ public class ShopManageGroupService {
LinkedHashSet<Long> userIds = new LinkedHashSet<>(); LinkedHashSet<Long> userIds = new LinkedHashSet<>();
for (ShopManageGroupEntity group : groups) { for (ShopManageGroupEntity group : groups) {
if (group.getCreatedById() != null && group.getCreatedById() > 0) { Long leaderUserId = resolveGroupLeaderUserId(group);
userIds.add(group.getCreatedById()); if (leaderUserId != null && leaderUserId > 0) {
userIds.add(leaderUserId);
} }
} }
for (List<ShopManageGroupMemberEntity> members : membersByGroupId.values()) { for (List<ShopManageGroupMemberEntity> members : membersByGroupId.values()) {
@@ -355,8 +362,9 @@ public class ShopManageGroupService {
ShopManageGroupItemVo vo = new ShopManageGroupItemVo(); ShopManageGroupItemVo vo = new ShopManageGroupItemVo();
vo.setId(entity.getId()); vo.setId(entity.getId());
vo.setGroupName(entity.getGroupName()); vo.setGroupName(entity.getGroupName());
vo.setLeaderUserId(entity.getCreatedById()); Long leaderUserId = resolveGroupLeaderUserId(entity);
AdminUserEntity leaderUser = entity.getCreatedById() == null ? null : userById.get(entity.getCreatedById()); vo.setLeaderUserId(leaderUserId);
AdminUserEntity leaderUser = leaderUserId == null ? null : userById.get(leaderUserId);
vo.setLeaderUsername(leaderUser == null ? "" : normalizeBlank(leaderUser.getUsername())); vo.setLeaderUsername(leaderUser == null ? "" : normalizeBlank(leaderUser.getUsername()));
List<ShopManageGroupMemberEntity> memberEntities = membersByGroupId.getOrDefault(entity.getId(), Collections.emptyList()); List<ShopManageGroupMemberEntity> memberEntities = membersByGroupId.getOrDefault(entity.getId(), Collections.emptyList());
@@ -380,4 +388,14 @@ public class ShopManageGroupService {
} }
return result; 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.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
@@ -33,6 +32,7 @@ public class ShopManageService {
long safePageSize = Math.min(Math.max(pageSize, 1), 100); long safePageSize = Math.min(Math.max(pageSize, 1), 100);
String safeShopName = shopName == null ? "" : shopName.trim(); String safeShopName = shopName == null ? "" : shopName.trim();
Set<Long> accessibleGroupIds = superAdmin ? Set.of() : shopManageGroupService.listAccessibleGroupIds(operatorId, false); 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>() LambdaQueryWrapper<ShopManageEntity> query = new LambdaQueryWrapper<ShopManageEntity>()
.eq(groupId != null && groupId > 0, ShopManageEntity::getGroupId, groupId) .eq(groupId != null && groupId > 0, ShopManageEntity::getGroupId, groupId)
@@ -40,20 +40,30 @@ public class ShopManageService {
.orderByDesc(ShopManageEntity::getId); .orderByDesc(ShopManageEntity::getId);
if (!superAdmin) { if (!superAdmin) {
Long normalizedOperatorId = normalizePositiveId(operatorId, "操作人不能为空"); normalizePositiveId(operatorId, "操作人不能为空");
if (accessibleGroupIds.isEmpty() && accessibleCreatorUserIds.isEmpty()) {
query.eq(ShopManageEntity::getId, -1L);
} else {
query.and(wrapper -> { query.and(wrapper -> {
wrapper.eq(ShopManageEntity::getCreatedById, normalizedOperatorId); boolean hasCreatorFilter = !accessibleCreatorUserIds.isEmpty();
if (hasCreatorFilter) {
wrapper.in(ShopManageEntity::getCreatedById, accessibleCreatorUserIds);
}
if (!accessibleGroupIds.isEmpty()) { if (!accessibleGroupIds.isEmpty()) {
wrapper.or().in(ShopManageEntity::getGroupId, accessibleGroupIds); if (hasCreatorFilter) {
wrapper.or();
}
wrapper.in(ShopManageEntity::getGroupId, accessibleGroupIds);
} }
}); });
} }
}
Long total = shopManageMapper.selectCount(query); Long total = shopManageMapper.selectCount(query);
List<ShopManageEntity> rows = shopManageMapper List<ShopManageEntity> rows = shopManageMapper
.selectList(query.last("LIMIT " + ((safePage - 1) * safePageSize) + ", " + safePageSize)); .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) .map(ShopManageEntity::getGroupId)
.filter(id -> id != null && id > 0) .filter(id -> id != null && id > 0)
.distinct() .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) { private ShopManageItemVo toItemVo(ShopManageEntity entity, String groupName) {
ShopManageItemVo vo = new ShopManageItemVo(); ShopManageItemVo vo = new ShopManageItemVo();
vo.setId(entity.getId()); vo.setId(entity.getId());

View File

@@ -1,17 +1,33 @@
package com.nanri.aiimage.modules.shopkey.service; package com.nanri.aiimage.modules.shopkey.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import cn.hutool.core.util.IdUtil;
import com.nanri.aiimage.common.exception.BusinessException; import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.shopkey.mapper.SkipPriceAsinMapper; import com.nanri.aiimage.modules.shopkey.mapper.SkipPriceAsinMapper;
import com.nanri.aiimage.modules.shopkey.mapper.ShopManageMapper;
import com.nanri.aiimage.modules.shopkey.model.dto.SkipPriceAsinCreateRequest; import com.nanri.aiimage.modules.shopkey.model.dto.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.ShopManageGroupEntity;
import com.nanri.aiimage.modules.shopkey.model.entity.SkipPriceAsinEntity; 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.SkipPriceAsinItemVo;
import com.nanri.aiimage.modules.shopkey.model.vo.SkipPriceAsinPageVo; import com.nanri.aiimage.modules.shopkey.model.vo.SkipPriceAsinPageVo;
import lombok.RequiredArgsConstructor; 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.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; 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.BigDecimal;
import java.math.RoundingMode; import java.math.RoundingMode;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@@ -21,15 +37,20 @@ import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j
public class SkipPriceAsinService { public class SkipPriceAsinService {
private static final List<String> SUPPORTED_COUNTRIES = List.of("DE", "UK", "FR", "IT", "ES"); private static final List<String> SUPPORTED_COUNTRIES = List.of("DE", "UK", "FR", "IT", "ES");
private final SkipPriceAsinMapper skipPriceAsinMapper; private final SkipPriceAsinMapper skipPriceAsinMapper;
private final ShopManageMapper shopManageMapper;
private final ShopManageGroupService shopManageGroupService; 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, public SkipPriceAsinPageVo page(long page, long pageSize, Long groupId, String shopName, String asin,
Long operatorId, boolean superAdmin) { Long operatorId, boolean superAdmin) {
@@ -62,7 +83,7 @@ public class SkipPriceAsinService {
List<SkipPriceAsinEntity> rows = skipPriceAsinMapper List<SkipPriceAsinEntity> rows = skipPriceAsinMapper
.selectList(query.last("LIMIT " + ((safePage - 1) * safePageSize) + ", " + safePageSize)); .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) .map(SkipPriceAsinEntity::getGroupId)
.filter(id -> id != null && id > 0) .filter(id -> id != null && id > 0)
.distinct() .distinct()
@@ -141,6 +162,486 @@ public class SkipPriceAsinService {
return toItemVo(saved, groupName); 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) { private SkipPriceAsinEntity getById(Long id) {
SkipPriceAsinEntity entity = skipPriceAsinMapper.selectById(id); SkipPriceAsinEntity entity = skipPriceAsinMapper.selectById(id);
if (entity == null) { if (entity == null) {
@@ -174,22 +675,6 @@ public class SkipPriceAsinService {
return vo; 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) { private Set<String> normalizeCountries(List<String> countries) {
LinkedHashSet<String> normalized = new LinkedHashSet<>(); LinkedHashSet<String> normalized = new LinkedHashSet<>();
if (countries != null) { if (countries != null) {

View File

@@ -132,6 +132,21 @@ public class ShopMatchTaskService {
return tasks; 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) { public ProductRiskDashboardVo dashboard(Long userId) {
if (userId == null || userId <= 0) { if (userId == null || userId <= 0) {
throw new BusinessException("user_id 不合法"); throw new BusinessException("user_id 不合法");
@@ -174,7 +189,7 @@ public class ShopMatchTaskService {
.distinct() .distinct()
.toList(); .toList();
if (!taskIds.isEmpty()) { if (!taskIds.isEmpty()) {
for (FileTaskEntity task : selectTasksByIdsInBatches(taskIds)) { for (FileTaskEntity task : selectTaskStatusesByIdsInBatches(taskIds)) {
if (task != null) { if (task != null) {
statusByTaskId.put(task.getId(), task.getStatus()); statusByTaskId.put(task.getId(), task.getStatus());
} }
@@ -465,7 +480,6 @@ public class ShopMatchTaskService {
} }
@Transactional
public void submitResult(Long taskId, ShopMatchSubmitResultRequest request) { public void submitResult(Long taskId, ShopMatchSubmitResultRequest request) {
if (taskId == null || taskId <= 0) { if (taskId == null || taskId <= 0) {
throw new BusinessException("taskId 不合法"); throw new BusinessException("taskId 不合法");
@@ -540,7 +554,6 @@ public class ShopMatchTaskService {
updateTaskStatusFromLatestRows(task, latest); updateTaskStatusFromLatestRows(task, latest);
} }
@Transactional
public boolean tryFinalizeTask(Long taskId, boolean fromCompensation) { public boolean tryFinalizeTask(Long taskId, boolean fromCompensation) {
if (taskId == null || taskId <= 0) { if (taskId == null || taskId <= 0) {
return false; return false;

View File

@@ -456,8 +456,8 @@ public class SplitRunService {
if (value == null) { if (value == null) {
return ""; return "";
} }
return value.replace("\ufeff", "") return value.replace(String.valueOf((char) 0xFEFF), "")
.replace("\u3000", " ") .replace((char) 0x3000, ' ')
.replace("\r\n", " ") .replace("\r\n", " ")
.replace("\r", " ") .replace("\r", " ")
.replace("\n", " ") .replace("\n", " ")

View File

@@ -23,7 +23,6 @@ public class TaskFileJobService {
private final TaskFileJobMapper taskFileJobMapper; private final TaskFileJobMapper taskFileJobMapper;
private final ApplicationEventPublisher applicationEventPublisher; private final ApplicationEventPublisher applicationEventPublisher;
@Transactional
public TaskFileJobEntity enqueueAssembleResult(Long taskId, String moduleType, Long resultId, String scopeKey) { public TaskFileJobEntity enqueueAssembleResult(Long taskId, String moduleType, Long resultId, String scopeKey) {
if (taskId == null || taskId <= 0 || moduleType == null || moduleType.isBlank() || resultId == null || resultId <= 0) { if (taskId == null || taskId <= 0 || moduleType == null || moduleType.isBlank() || resultId == null || resultId <= 0) {
return null; return null;
@@ -146,7 +145,6 @@ public class TaskFileJobService {
return reset; return reset;
} }
@Transactional
public boolean markRunning(Long jobId) { public boolean markRunning(Long jobId) {
if (jobId == null || jobId <= 0) { if (jobId == null || jobId <= 0) {
return false; return false;
@@ -158,7 +156,6 @@ public class TaskFileJobService {
.set(TaskFileJobEntity::getUpdatedAt, LocalDateTime.now())) > 0; .set(TaskFileJobEntity::getUpdatedAt, LocalDateTime.now())) > 0;
} }
@Transactional
public void touchRunning(Long jobId) { public void touchRunning(Long jobId) {
if (jobId == null || jobId <= 0) { if (jobId == null || jobId <= 0) {
return; return;
@@ -169,7 +166,6 @@ public class TaskFileJobService {
.set(TaskFileJobEntity::getUpdatedAt, LocalDateTime.now())); .set(TaskFileJobEntity::getUpdatedAt, LocalDateTime.now()));
} }
@Transactional
public void markSuccess(TaskFileJobEntity job, String resultFileUrl) { public void markSuccess(TaskFileJobEntity job, String resultFileUrl) {
taskFileJobMapper.update(null, new LambdaUpdateWrapper<TaskFileJobEntity>() taskFileJobMapper.update(null, new LambdaUpdateWrapper<TaskFileJobEntity>()
.eq(TaskFileJobEntity::getId, job.getId()) .eq(TaskFileJobEntity::getId, job.getId())
@@ -180,7 +176,6 @@ public class TaskFileJobService {
.set(TaskFileJobEntity::getFinishedAt, LocalDateTime.now())); .set(TaskFileJobEntity::getFinishedAt, LocalDateTime.now()));
} }
@Transactional
public void markFailed(TaskFileJobEntity job, String message) { public void markFailed(TaskFileJobEntity job, String message) {
int retryCount = job.getRetryCount() == null ? 0 : job.getRetryCount() + 1; int retryCount = job.getRetryCount() == null ? 0 : job.getRetryCount() + 1;
taskFileJobMapper.update(null, new LambdaUpdateWrapper<TaskFileJobEntity>() taskFileJobMapper.update(null, new LambdaUpdateWrapper<TaskFileJobEntity>()

View File

@@ -25,7 +25,6 @@ public class TaskResultItemService {
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final TransientPayloadStorageService transientPayloadStorageService; private final TransientPayloadStorageService transientPayloadStorageService;
@Transactional
public void replaceResultSnapshot(Long taskId, String moduleType, Long resultId, String scopeKey, Object snapshot) { 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) { if (taskId == null || taskId <= 0 || isBlank(moduleType) || resultId == null || resultId <= 0 || snapshot == null) {
return; return;
@@ -35,7 +34,6 @@ public class TaskResultItemService {
upsert(taskId, moduleType, resultId, normalizedScopeKey, itemKey, null, null, null, snapshot); upsert(taskId, moduleType, resultId, normalizedScopeKey, itemKey, null, null, null, snapshot);
} }
@Transactional
public void replaceTaskSnapshots(Long taskId, String moduleType, List<? extends Object> snapshots, SnapshotKeyResolver resolver) { public void replaceTaskSnapshots(Long taskId, String moduleType, List<? extends Object> snapshots, SnapshotKeyResolver resolver) {
if (taskId == null || taskId <= 0 || isBlank(moduleType) || snapshots == null) { if (taskId == null || taskId <= 0 || isBlank(moduleType) || snapshots == null) {
return; return;

View File

@@ -25,7 +25,6 @@ public class TaskResultPayloadService {
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final TransientPayloadStorageService transientPayloadStorageService; private final TransientPayloadStorageService transientPayloadStorageService;
@Transactional
public void saveLatest(Long taskId, String moduleType, String scopeKey, Object payload) { public void saveLatest(Long taskId, String moduleType, String scopeKey, Object payload) {
if (taskId == null || taskId <= 0 || isBlank(moduleType) || isBlank(scopeKey) || payload == null) { if (taskId == null || taskId <= 0 || isBlank(moduleType) || isBlank(scopeKey) || payload == null) {
return; return;

View File

@@ -60,7 +60,7 @@ public class TransientPayloadStorageService {
return ossStorageService.readObjectAsString(pointer.substring(OSS_POINTER_PREFIX.length())); return ossStorageService.readObjectAsString(pointer.substring(OSS_POINTER_PREFIX.length()));
} }
} catch (Exception ex) { } catch (Exception ex) {
throw new IllegalStateException(errorMessage, ex); throw new IllegalStateException(errorMessage + ": " + pointer, ex);
} }
return value; return value;
} }

View File

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

View File

@@ -357,7 +357,7 @@ public class ZiniaoShopIndexService {
if (value == null) { if (value == null) {
return ""; return "";
} }
return value.replace("\u3000", " ").trim(); return value.replace((char) 0x3000, ' ').trim();
} }
private ZiniaoShopMatchResultVo pendingResult(String message) { private ZiniaoShopMatchResultVo pendingResult(String message) {

View File

@@ -19,6 +19,10 @@ spring:
maximum-pool-size: ${AIIMAGE_DB_POOL_MAX_SIZE:30} maximum-pool-size: ${AIIMAGE_DB_POOL_MAX_SIZE:30}
minimum-idle: ${AIIMAGE_DB_POOL_MIN_IDLE:5} minimum-idle: ${AIIMAGE_DB_POOL_MIN_IDLE:5}
connection-timeout: ${AIIMAGE_DB_POOL_CONNECTION_TIMEOUT_MS:30000} 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} leak-detection-threshold: ${AIIMAGE_DB_POOL_LEAK_DETECT_MS:0}
data: data:
redis: redis:
@@ -54,7 +58,7 @@ mybatis-plus:
mapper-locations: classpath*:mapper/**/*.xml mapper-locations: classpath*:mapper/**/*.xml
configuration: configuration:
map-underscore-to-camel-case: true map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
springdoc: springdoc:
api-docs: api-docs:
@@ -80,6 +84,10 @@ aiimage:
access-key-id: ${AIIMAGE_TRANSIENT_STORAGE_ACCESS_KEY_ID:} access-key-id: ${AIIMAGE_TRANSIENT_STORAGE_ACCESS_KEY_ID:}
access-key-secret: ${AIIMAGE_TRANSIENT_STORAGE_ACCESS_KEY_SECRET:} access-key-secret: ${AIIMAGE_TRANSIENT_STORAGE_ACCESS_KEY_SECRET:}
region: ${AIIMAGE_TRANSIENT_STORAGE_REGION:us-east-1} 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: storage:
local-temp-dir: ${AIIMAGE_STORAGE_LOCAL_TEMP_DIR:./data/tmp} local-temp-dir: ${AIIMAGE_STORAGE_LOCAL_TEMP_DIR:./data/tmp}
cleanup-enabled: ${AIIMAGE_STORAGE_CLEANUP_ENABLED:true} 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 json
import os import os
import re import re
import threading
import requests 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 import pymysql
from werkzeug.security import generate_password_hash 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('/') 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') 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 = { ADMIN_MENU_ACCESS_CONFIG = {
'dedupe-total-data': { 'dedupe-total-data': {
@@ -103,10 +108,22 @@ def _safe_version_key(version):
return s or 'unknown' 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): def _proxy_backend_java(method, path, *, params=None, json_data=None, files=None, data=None, timeout=10):
url = f"{backend_java_base_url}{path}" url = f"{backend_java_base_url}{path}"
try: try:
resp = requests.request(method=method, url=url, params=params, json=json_data, files=files, data=data, timeout=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: except requests.RequestException:
return None, jsonify({'success': False, 'error': 'backend-java 服务不可用'}), 502 return None, jsonify({'success': False, 'error': 'backend-java 服务不可用'}), 502
try: try:
@@ -146,6 +163,28 @@ def _parse_optional_int(value):
def _ensure_column_sort_schema(): 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() conn = get_db()
try: try:
with conn.cursor() as cur: with conn.cursor() as cur:
@@ -299,6 +338,10 @@ def _get_current_admin_permission_sets():
user_id = _get_current_admin_id() user_id = _get_current_admin_id()
if not user_id: if not user_id:
return set(), set() 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() conn = get_db()
try: try:
with conn.cursor() as cur: with conn.cursor() as cur:
@@ -314,7 +357,9 @@ def _get_current_admin_permission_sets():
conn.close() conn.close()
key_set = {(row.get('column_key') or '').strip() for row in rows if (row.get('column_key') or '').strip()} 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()} 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): def _ensure_admin_menu_access(*menu_names):
@@ -830,14 +875,6 @@ def list_columns():
if denied: if denied:
return denied return denied
menu_type = (request.args.get('menu_type') or '').strip() 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) local_rows = _load_local_columns(menu_type or None)
items = [ items = [
{ {
@@ -1484,32 +1521,7 @@ def _load_expanded_shop_manage_groups(role, current_row):
if error_response is not None: if error_response is not None:
return None, error_response, status return None, error_response, status
direct_groups = result.get('data') or [] return result.get('data') or [], None, 200
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
@admin_api.route('/shop-manages') @admin_api.route('/shop-manages')
@@ -1523,59 +1535,6 @@ def list_shop_manages():
group_id_raw = (request.args.get('group_id') or '').strip() group_id_raw = (request.args.get('group_id') or '').strip()
shop_name = (request.args.get('shop_name') 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} params = {'page': page, 'pageSize': page_size}
if current_row and current_row.get('id'): if current_row and current_row.get('id'):
params['operatorId'] = 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): def _format_query_asin_item(item):
return { return {
'id': item.get('id'), '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/" file_url_pre = f"https://{bucket}.oss-cn-hangzhou.aliyuncs.com/"
import os import os
# backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://127.0.0.1:18080').rstrip('/') backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://127.0.0.1:18080').rstrip('/')
# backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://8.136.19.173:18080').rstrip('/') # backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://8.136.19.173:18080').rstrip('/')
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_ID'] = accessKeyId
os.environ['OSS_ACCESS_KEY_SECRET'] = accessKeySecret os.environ['OSS_ACCESS_KEY_SECRET'] = accessKeySecret
os.environ['SECRET_KEY'] = "ddffc7c1d02121d9554d7b080b2511b6" os.environ['SECRET_KEY'] = "ddffc7c1d02121d9554d7b080b2511b6"

View File

@@ -3,7 +3,7 @@
""" """
from functools import wraps 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 from utils.db import get_db
@@ -13,15 +13,22 @@ def is_session_user_valid():
uid = session.get('user_id') uid = session.get('user_id')
if not uid: if not uid:
return False return False
cached_user = getattr(g, '_current_user_row', None)
if cached_user and cached_user.get('id') == uid:
return True
try: try:
conn = get_db() conn = get_db()
with conn.cursor() as cur: 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() row = cur.fetchone()
conn.close() conn.close()
if not row: if not row:
session.clear() session.clear()
return False return False
g._current_user_row = row
return True return True
except Exception: except Exception:
session.clear() session.clear()
@@ -34,6 +41,8 @@ def get_current_admin_role():
if not uid: if not uid:
return None, None return None, None
try: try:
row = getattr(g, '_current_user_row', None)
if not row or row.get('id') != uid:
conn = get_db() conn = get_db()
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute( cur.execute(
@@ -44,6 +53,7 @@ def get_current_admin_role():
conn.close() conn.close()
if not row: if not row:
return None, None return None, None
g._current_user_row = row
role = (row.get('role') or '').strip().lower() role = (row.get('role') or '').strip().lower()
if not role: if not role:
role = 'super_admin' if row.get('is_admin') and row.get('created_by_id') is None else ( 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 from werkzeug.security import generate_password_hash
try: 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: except ImportError:
mysql_host = os.environ.get('MYSQL_HOST', 'localhost') config_mysql_host = 'localhost'
mysql_user = os.environ.get('MYSQL_USER', 'root') config_mysql_user = 'root'
mysql_password = os.environ.get('MYSQL_PASSWORD', '') config_mysql_password = ''
mysql_database = os.environ.get('MYSQL_DATABASE', 'maixiang_ai') 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(): 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 { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
import { getPywebviewApi } from '@/shared/bridges/pywebview' import { getPywebviewApi } from '@/shared/bridges/pywebview'
import { getTaskPollIntervalMs } from '@/shared/task-progress-config' import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
const selectedFileNames = ref<string[]>([]) const selectedFileNames = ref<string[]>([])
const uploadedFiles = ref<UploadFileVo[]>([]) const uploadedFiles = ref<UploadFileVo[]>([])
@@ -169,8 +170,11 @@ const parsing = ref(false)
const pushing = ref(false) const pushing = ref(false)
const queuePayloadText = ref('') const queuePayloadText = ref('')
const pollingTaskIds = ref<number[]>([]) const pollingTaskIds = ref<number[]>([])
const pendingFileTaskIds = ref<number[]>([])
const pollTimer = ref<number | null>(null) const pollTimer = ref<number | null>(null)
const pollingInFlight = ref(false) const pollingInFlight = ref(false)
let disposed = false
const timers = createCategorizedTimers('appearance-patent')
const dashboard = ref<AppearancePatentDashboardVo>({ const dashboard = ref<AppearancePatentDashboardVo>({
pendingTaskCount: 0, pendingTaskCount: 0,
@@ -201,12 +205,14 @@ function pollingKey() {
function savePollingIds() { function savePollingIds() {
if (typeof window === 'undefined') return if (typeof window === 'undefined') return
if (!pollingTaskIds.value.length) window.localStorage.removeItem(pollingKey()) const ids = Array.from(new Set([...pollingTaskIds.value, ...pendingFileTaskIds.value]))
else window.localStorage.setItem(pollingKey(), JSON.stringify(pollingTaskIds.value)) if (!ids.length) window.localStorage.removeItem(pollingKey())
else window.localStorage.setItem(pollingKey(), JSON.stringify(ids))
} }
function clearPollingIds() { function clearPollingIds() {
pollingTaskIds.value = [] pollingTaskIds.value = []
pendingFileTaskIds.value = []
savePollingIds() savePollingIds()
stopPolling() stopPolling()
} }
@@ -388,65 +394,114 @@ function removePollingTask(taskId: number) {
savePollingIds() 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() { function ensurePolling() {
if (disposed) return
if (pollTimer.value != null) return if (pollTimer.value != null) return
scheduleNextPoll(true) scheduleNextPoll(true)
} }
function stopPolling() { function stopPolling() {
if (pollTimer.value != null) { if (pollTimer.value != null) {
window.clearTimeout(pollTimer.value) timers.clearTimer('task-poll', pollTimer.value)
pollTimer.value = null pollTimer.value = null
} }
} }
function scheduleNextPoll(immediate = false) { function scheduleNextPoll(immediate = false) {
if (disposed) return
if (pollTimer.value != null) { if (pollTimer.value != null) {
if (!immediate) return if (!immediate) return
window.clearTimeout(pollTimer.value) timers.clearTimer('task-poll', pollTimer.value)
pollTimer.value = null pollTimer.value = null
} }
const run = async () => { const run = async () => {
pollTimer.value = null pollTimer.value = null
if (!pollingTaskIds.value.length) return if (disposed) return
if (!pollingTaskIds.value.length && !pendingFileTaskIds.value.length) return
await refreshTaskProgress() await refreshTaskProgress()
if (pollingTaskIds.value.length) { if (!disposed && (pollingTaskIds.value.length || pendingFileTaskIds.value.length)) {
pollTimer.value = window.setTimeout(run, getTaskPollIntervalMs()) pollTimer.value = timers.setTimeout('task-poll', run, getTaskPollIntervalMs())
} }
} }
if (immediate) void run() if (immediate) void run()
else pollTimer.value = window.setTimeout(run, getTaskPollIntervalMs()) else pollTimer.value = timers.setTimeout('task-poll', run, getTaskPollIntervalMs())
} }
async function refreshTaskProgress() { async function refreshTaskProgress() {
if (pollingInFlight.value || !pollingTaskIds.value.length) { if (pollingInFlight.value || (!pollingTaskIds.value.length && !pendingFileTaskIds.value.length)) {
if (!pollingTaskIds.value.length) stopPolling() if (!pollingTaskIds.value.length && !pendingFileTaskIds.value.length) stopPolling()
return return
} }
pollingInFlight.value = true pollingInFlight.value = true
try { try {
let shouldRefreshHistory = pendingFileTaskIds.value.length > 0
if (pollingTaskIds.value.length) {
const batch = await getAppearancePatentTaskProgressBatch(pollingTaskIds.value) const batch = await getAppearancePatentTaskProgressBatch(pollingTaskIds.value)
let hasTerminalTask = false
for (const detail of batch.items || []) { for (const detail of batch.items || []) {
const task = detail.task const task = detail.task
if (!task?.id) continue if (!task?.id) continue
if (task.status === 'SUCCESS' || task.status === 'FAILED') { if (task.status === 'SUCCESS' || task.status === 'FAILED') {
removePollingTask(task.id) removePollingTask(task.id)
hasTerminalTask = true shouldRefreshHistory = true
if (task.status === 'SUCCESS') addPendingFileTask(task.id)
} }
} }
if (batch.missingTaskIds?.length) { if (batch.missingTaskIds?.length) {
batch.missingTaskIds.forEach((taskId) => removePollingTask(taskId)) batch.missingTaskIds.forEach((taskId) => {
removePollingTask(taskId)
removePendingFileTask(taskId)
})
shouldRefreshHistory = true
} }
if (hasTerminalTask) { }
if (shouldRefreshHistory) {
await loadDashboard() await loadDashboard()
await loadHistory() await loadHistory()
settlePendingFileTasks()
} }
} finally { } finally {
pollingInFlight.value = false 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() { async function loadDashboard() {
dashboard.value = await getAppearancePatentDashboard() dashboard.value = await getAppearancePatentDashboard()
} }
@@ -545,9 +600,14 @@ onMounted(async () => {
loadDashboard().catch(() => undefined), loadDashboard().catch(() => undefined),
loadHistory().catch(() => undefined), loadHistory().catch(() => undefined),
]) ])
seedPendingFileTasksFromHistory()
}) })
onUnmounted(() => stopPolling()) onUnmounted(() => {
disposed = true
stopPolling()
timers.clearScope()
})
</script> </script>
<style scoped> <style scoped>

View File

@@ -226,6 +226,7 @@ import {
} from '@/shared/api/java-modules' } from '@/shared/api/java-modules'
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview' import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
import { getTaskPollIntervalMs } from '@/shared/task-progress-config' import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
interface SessionDeleteBrandItem extends DeleteBrandResultItem { interface SessionDeleteBrandItem extends DeleteBrandResultItem {
_pushed?: boolean _pushed?: boolean
@@ -263,6 +264,8 @@ const activeItemKey = ref('')
const autoAdvancing = ref(false) const autoAdvancing = ref(false)
const autoRetryTimer = ref<number | null>(null) const autoRetryTimer = ref<number | null>(null)
const waitingForBackendRecovery = ref(false) const waitingForBackendRecovery = ref(false)
let disposed = false
const timers = createCategorizedTimers('delete-brand')
const displayPaths = computed(() => selectedPaths.value.slice(0, 10)) const displayPaths = computed(() => selectedPaths.value.slice(0, 10))
const currentSectionItems = computed(() => { const currentSectionItems = computed(() => {
@@ -892,14 +895,16 @@ function getPollIntervalMs() {
} }
function scheduleNextPoll(immediate = false) { function scheduleNextPoll(immediate = false) {
if (disposed) return
if (pollTimer.value) { if (pollTimer.value) {
if (!immediate) return if (!immediate) return
clearTimeout(pollTimer.value) timers.clearTimer('task-poll', pollTimer.value)
pollTimer.value = null pollTimer.value = null
} }
const executePoll = async () => { const executePoll = async () => {
pollTimer.value = null pollTimer.value = null
if (disposed) return
if (pollingInFlight.value) { if (pollingInFlight.value) {
scheduleNextPoll() scheduleNextPoll()
return return
@@ -933,7 +938,7 @@ function scheduleNextPoll(immediate = false) {
syncResultState() syncResultState()
} }
if (getPollingTaskIds().length > 0) { if (!disposed && getPollingTaskIds().length > 0) {
scheduleNextPoll() scheduleNextPoll()
} }
} }
@@ -941,7 +946,7 @@ function scheduleNextPoll(immediate = false) {
if (immediate) { if (immediate) {
executePoll() executePoll()
} else { } 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() { function stopPolling() {
if (pollTimer.value) { if (pollTimer.value) {
window.clearTimeout(pollTimer.value) timers.clearTimer('task-poll', pollTimer.value)
pollTimer.value = null pollTimer.value = null
} }
clearAutoRetryTimer() clearAutoRetryTimer()
@@ -1178,16 +1183,17 @@ function debugAutoAdvance(step: string, payload?: Record<string, unknown>) {
function clearAutoRetryTimer() { function clearAutoRetryTimer() {
if (autoRetryTimer.value) { if (autoRetryTimer.value) {
window.clearTimeout(autoRetryTimer.value) timers.clearTimer('auto-retry', autoRetryTimer.value)
autoRetryTimer.value = null autoRetryTimer.value = null
} }
} }
function scheduleAutoAdvanceRetry(delayMs = 3000) { function scheduleAutoAdvanceRetry(delayMs = 3000) {
if (disposed) return
clearAutoRetryTimer() clearAutoRetryTimer()
autoRetryTimer.value = window.setTimeout(() => { autoRetryTimer.value = timers.setTimeout('auto-retry', () => {
autoRetryTimer.value = null autoRetryTimer.value = null
maybeAutoAdvance().catch(() => undefined) if (!disposed) maybeAutoAdvance().catch(() => undefined)
}, delayMs) }, delayMs)
} }
@@ -1428,7 +1434,9 @@ onMounted(() => {
}) })
onUnmounted(() => { onUnmounted(() => {
disposed = true
stopPolling() stopPolling()
timers.clearScope()
}) })
</script> </script>

View File

@@ -334,6 +334,7 @@ import {
} from "@/shared/api/java-modules"; } from "@/shared/api/java-modules";
import { getPywebviewApi } from "@/shared/bridges/pywebview"; import { getPywebviewApi } from "@/shared/bridges/pywebview";
import { getTaskPollIntervalMs } from "@/shared/task-progress-config"; import { getTaskPollIntervalMs } from "@/shared/task-progress-config";
import { createCategorizedTimers } from "@/shared/utils/categorized-timers";
const COUNTRY_TEMPLATE = ["德国", "英国", "法国", "意大利", "西班牙"] as const; const COUNTRY_TEMPLATE = ["德国", "英国", "法国", "意大利", "西班牙"] as const;
const MAX_TRANSIENT_ERRORS = 30; const MAX_TRANSIENT_ERRORS = 30;
@@ -360,6 +361,7 @@ const activeQueueItem = ref<PatrolDeleteShopQueueItem | null>(null);
const queueWorkerRunning = ref(false); const queueWorkerRunning = ref(false);
const autoQueueEnabled = ref(false); const autoQueueEnabled = ref(false);
let historyPollTimer: number | null = null; let historyPollTimer: number | null = null;
const timers = createCategorizedTimers("patrol-delete");
const matchedRunnableItems = computed(() => const matchedRunnableItems = computed(() =>
matchedItems.value.filter((item) => item.matched), matchedItems.value.filter((item) => item.matched),
@@ -399,8 +401,15 @@ function historyItemKey(item: PatrolDeleteHistoryItem) {
return `${item.taskId ?? 0}:${item.resultId ?? 0}`; return `${item.taskId ?? 0}:${item.resultId ?? 0}`;
} }
let disposed = false;
function sleep(ms: number) { 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) { function isTransientBackendError(error: unknown) {
@@ -425,6 +434,7 @@ async function withTransientRetry<T>(
) { ) {
let attempt = 0; let attempt = 0;
while (true) { while (true) {
if (disposed) throw new Error("component disposed");
try { try {
return await action(); return await action();
} catch (error) { } catch (error) {
@@ -716,21 +726,21 @@ async function refreshActiveTaskProgress(taskIds?: number[]) {
function startHistoryPolling() { function startHistoryPolling() {
stopHistoryPolling(); stopHistoryPolling();
if (!hasQueueWork.value) return; if (disposed || !hasQueueWork.value) return;
const run = async () => { const run = async () => {
historyPollTimer = null; historyPollTimer = null;
if (!hasQueueWork.value) return; if (disposed || !hasQueueWork.value) return;
await refreshActiveTaskProgress(); await refreshActiveTaskProgress();
if (hasQueueWork.value) { if (!disposed && hasQueueWork.value) {
historyPollTimer = window.setTimeout(run, getTaskPollIntervalMs() * 2); historyPollTimer = timers.setTimeout("history-poll", run, getTaskPollIntervalMs() * 2);
} }
}; };
historyPollTimer = window.setTimeout(run, getTaskPollIntervalMs() * 2); historyPollTimer = timers.setTimeout("history-poll", run, getTaskPollIntervalMs() * 2);
} }
function stopHistoryPolling() { function stopHistoryPolling() {
if (historyPollTimer) { if (historyPollTimer) {
window.clearTimeout(historyPollTimer); timers.clearTimer("history-poll", historyPollTimer);
historyPollTimer = null; historyPollTimer = null;
} }
} }
@@ -865,6 +875,7 @@ function findHistoryItemByTaskId(taskId: number) {
async function waitForTaskTerminal(taskId: number) { async function waitForTaskTerminal(taskId: number) {
let transientErrorCount = 0; let transientErrorCount = 0;
while (true) { while (true) {
if (disposed) return "STOPPED";
try { try {
await refreshActiveTaskProgress([taskId]); await refreshActiveTaskProgress([taskId]);
if (transientErrorCount > 0) { if (transientErrorCount > 0) {
@@ -893,7 +904,7 @@ async function waitForTaskTerminal(taskId: number) {
} }
async function processQueue() { async function processQueue() {
if (queueWorkerRunning.value) return; if (disposed || queueWorkerRunning.value) return;
queueWorkerRunning.value = true; queueWorkerRunning.value = true;
pushing.value = true; pushing.value = true;
startHistoryPolling(); startHistoryPolling();
@@ -904,7 +915,7 @@ async function processQueue() {
throw new Error("当前客户端未提供 enqueue_json"); throw new Error("当前客户端未提供 enqueue_json");
} }
while (activeTaskId.value || pendingQueue.value.length) { while (!disposed && (activeTaskId.value || pendingQueue.value.length)) {
if (activeTaskId.value) { if (activeTaskId.value) {
const finalStatus = await waitForTaskTerminal(activeTaskId.value); const finalStatus = await waitForTaskTerminal(activeTaskId.value);
queuePushResult.value = queuePushResult.value =
@@ -975,6 +986,7 @@ async function processQueue() {
await refreshTaskViews(); await refreshTaskViews();
ElMessage.success("巡店删除队列已按顺序执行完成"); ElMessage.success("巡店删除队列已按顺序执行完成");
} catch (error) { } catch (error) {
if (disposed) return;
const message = error instanceof Error ? error.message : "队列执行失败"; const message = error instanceof Error ? error.message : "队列执行失败";
queuePushResult.value = message; queuePushResult.value = message;
ElMessage.error(message); ElMessage.error(message);
@@ -1061,7 +1073,10 @@ onMounted(async () => {
}); });
onUnmounted(() => { onUnmounted(() => {
disposed = true;
stopHistoryPolling(); stopHistoryPolling();
clearSleepTimers();
timers.clearScope();
}); });
</script> </script>

View File

@@ -265,6 +265,7 @@ import { LISTING_FILTER_OPTIONS, type ListingFilterValue } from '@/pages/brand/c
import { expandBrandFolderRecursive } from '@/shared/api/brand' import { expandBrandFolderRecursive } from '@/shared/api/brand'
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview' import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
import { getTaskPollIntervalMs } from '@/shared/task-progress-config' import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
import { import {
addPriceTrackCandidate, addPriceTrackCandidate,
completePriceTrackLoopChild, completePriceTrackLoopChild,
@@ -369,6 +370,17 @@ const taskDetails = ref<Record<number, string>>({})
const taskSnapshots = ref<Record<number, PriceTrackTaskDetailVo>>({}) const taskSnapshots = ref<Record<number, PriceTrackTaskDetailVo>>({})
const pollTimer = ref<number | null>(null) const pollTimer = ref<number | null>(null)
const pollingInFlight = ref(false) 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() { function uidForStorage() {
return typeof window !== 'undefined' ? window.localStorage.getItem('uid') || '0' : '0' return typeof window !== 'undefined' ? window.localStorage.getItem('uid') || '0' : '0'
@@ -649,8 +661,8 @@ function onCountryDrop(toIndex: number) {
} }
function scheduleSaveCountryPref() { function scheduleSaveCountryPref() {
if (countryPrefSaveTimer) clearTimeout(countryPrefSaveTimer) if (countryPrefSaveTimer) timers.clearTimer('preference-save', countryPrefSaveTimer)
countryPrefSaveTimer = setTimeout(() => { countryPrefSaveTimer = timers.setTimeout('preference-save', () => {
countryPrefSaveTimer = null countryPrefSaveTimer = null
void persistCountryPref() void persistCountryPref()
}, 450) }, 450)
@@ -1043,6 +1055,7 @@ async function waitForTaskTerminal(taskId: number) {
let transientErrorCount = 0 let transientErrorCount = 0
const maxTransientErrors = 30 const maxTransientErrors = 30
while (true) { while (true) {
if (disposed) return 'STOPPED'
try { try {
const batch = await getPriceTrackTaskProgressBatch([taskId]) const batch = await getPriceTrackTaskProgressBatch([taskId])
if (transientErrorCount > 0) { if (transientErrorCount > 0) {
@@ -1086,10 +1099,10 @@ async function waitForTaskTerminal(taskId: number) {
if (transientErrorCount === 1 || transientErrorCount % 5 === 0) { if (transientErrorCount === 1 || transientErrorCount % 5 === 0) {
ElMessage.warning(`任务 ${taskId} 运行中,后端服务暂时不可用,正在自动重试`) ElMessage.warning(`任务 ${taskId} 运行中,后端服务暂时不可用,正在自动重试`)
} }
await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) }) await sleep(getPollIntervalMs())
continue continue
} }
await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) }) await sleep(getPollIntervalMs())
} }
} }
@@ -1103,6 +1116,7 @@ function nextMatchedQueueItem() {
} }
async function processMatchedQueue() { async function processMatchedQueue() {
if (disposed) return
if (queueWorkerRunning.value) { if (queueWorkerRunning.value) {
return return
} }
@@ -1131,7 +1145,7 @@ async function processMatchedQueue() {
let failedCount = 0 let failedCount = 0
try { try {
let index = 0 let index = 0
while (autoQueueEnabled.value) { while (!disposed && autoQueueEnabled.value) {
const row = nextMatchedQueueItem() const row = nextMatchedQueueItem()
if (!row) { if (!row) {
break break
@@ -1141,6 +1155,7 @@ async function processMatchedQueue() {
let attempt = 0 let attempt = 0
const taskVo = await (async () => { const taskVo = await (async () => {
while (true) { while (true) {
if (disposed) throw new Error('component disposed')
try { try {
return await createPriceTrackTask({ return await createPriceTrackTask({
userId: 0, userId: 0,
@@ -1156,7 +1171,7 @@ async function processMatchedQueue() {
attempt += 1 attempt += 1
if (!transient || attempt >= 10) throw error if (!transient || attempt >= 10) throw error
queuePushResult.value = '后端服务暂时不可用,正在重试创建任务(' + attempt + '/10...' 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 successCount += 1
queuePushResult.value = '任务 ' + taskVo.taskId + ' 已完成' queuePushResult.value = '任务 ' + taskVo.taskId + ' 已完成'
} catch (error) { } catch (error) {
if (disposed) break
failedCount += 1 failedCount += 1
queuePushResult.value = '第 ' + index + ' 条任务处理失败,已自动继续下一条:' + (error instanceof Error ? error.message : '未知错误') queuePushResult.value = '第 ' + index + ' 条任务处理失败,已自动继续下一条:' + (error instanceof Error ? error.message : '未知错误')
ElMessage.error(queuePushResult.value) ElMessage.error(queuePushResult.value)
@@ -1208,6 +1224,7 @@ async function processMatchedQueue() {
ElMessage.success('店铺任务推送已完成:成功 ' + successCount + ' 条,失败 ' + failedCount + ' 条') ElMessage.success('店铺任务推送已完成:成功 ' + successCount + ' 条,失败 ' + failedCount + ' 条')
} }
} catch (e) { } catch (e) {
if (disposed) return
queuePushResult.value = e instanceof Error ? e.message : '推送异常' queuePushResult.value = e instanceof Error ? e.message : '推送异常'
ElMessage.error(queuePushResult.value) ElMessage.error(queuePushResult.value)
} finally { } finally {
@@ -1282,6 +1299,7 @@ async function stopLoopExecution() {
} }
async function runLoopExecution(loopId?: number) { async function runLoopExecution(loopId?: number) {
if (disposed) return
if (loopDispatching.value) return if (loopDispatching.value) return
if (loopId && activeLoopRunId.value !== loopId) { if (loopId && activeLoopRunId.value !== loopId) {
activeLoopRunId.value = loopId activeLoopRunId.value = loopId
@@ -1294,7 +1312,7 @@ async function runLoopExecution(loopId?: number) {
} }
loopDispatching.value = true loopDispatching.value = true
try { try {
while (activeLoopRunId.value) { while (!disposed && activeLoopRunId.value) {
const loop = await syncActiveLoopRun() const loop = await syncActiveLoopRun()
if (!loop || loop.status === 'SUCCESS' || loop.status === 'FAILED' || loop.status === 'STOPPED') break if (!loop || loop.status === 'SUCCESS' || loop.status === 'FAILED' || loop.status === 'STOPPED') break
@@ -1306,6 +1324,7 @@ async function runLoopExecution(loopId?: number) {
let completeAttempt = 0 let completeAttempt = 0
const updatedLoop = await (async () => { const updatedLoop = await (async () => {
while (true) { while (true) {
if (disposed) throw new Error('component disposed')
try { try {
return await completePriceTrackLoopChild(loop.id, activeTaskId) return await completePriceTrackLoopChild(loop.id, activeTaskId)
} catch (error) { } catch (error) {
@@ -1314,7 +1333,7 @@ async function runLoopExecution(loopId?: number) {
completeAttempt += 1 completeAttempt += 1
if (!transient || completeAttempt >= 10) throw error if (!transient || completeAttempt >= 10) throw error
queuePushResult.value = `循环任务 ${loop.id} 等待后端恢复后确认子任务完成(${completeAttempt}/10...` 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 let dispatchAttempt = 0
const dispatch = await (async () => { const dispatch = await (async () => {
while (true) { while (true) {
if (disposed) throw new Error('component disposed')
try { try {
return await dispatchNextPriceTrackLoopRun(loop.id) return await dispatchNextPriceTrackLoopRun(loop.id)
} catch (error) { } catch (error) {
@@ -1340,7 +1360,7 @@ async function runLoopExecution(loopId?: number) {
dispatchAttempt += 1 dispatchAttempt += 1
if (!transient || dispatchAttempt >= 10) throw error if (!transient || dispatchAttempt >= 10) throw error
queuePushResult.value = `循环任务 ${loop.id} 等待后端恢复后继续派发(${dispatchAttempt}/10...` 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 let createAttempt = 0
const taskVo = await (async () => { const taskVo = await (async () => {
while (true) { while (true) {
if (disposed) throw new Error('component disposed')
try { try {
return await createPriceTrackTask(childTaskRequest) return await createPriceTrackTask(childTaskRequest)
} catch (error) { } catch (error) {
@@ -1363,7 +1384,7 @@ async function runLoopExecution(loopId?: number) {
createAttempt += 1 createAttempt += 1
if (!transient || createAttempt >= 10) throw error if (!transient || createAttempt >= 10) throw error
queuePushResult.value = `循环任务 ${loop.id} 等待后端恢复后重试创建子任务(${createAttempt}/10...` 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) { function scheduleNextPoll(immediate = false) {
if (disposed) return
if (pollTimer.value) { if (pollTimer.value) {
if (!immediate) return if (!immediate) return
window.clearTimeout(pollTimer.value) timers.clearTimer('task-poll', pollTimer.value)
pollTimer.value = null pollTimer.value = null
} }
const run = async () => { const run = async () => {
pollTimer.value = null pollTimer.value = null
if (disposed) return
if (pollingInFlight.value) { scheduleNextPoll(); return } if (pollingInFlight.value) { scheduleNextPoll(); return }
if (!pollingTaskIds.value.length) return if (!pollingTaskIds.value.length) return
pollingInFlight.value = true pollingInFlight.value = true
try { await refreshTaskBatch() } finally { pollingInFlight.value = false } 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() if (immediate) void run()
else pollTimer.value = window.setTimeout(run, getPollIntervalMs()) else pollTimer.value = timers.setTimeout('task-poll', run, getPollIntervalMs())
} }
function stopPolling() { 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) { function addPollingTask(taskId: number) {
@@ -1658,8 +1681,11 @@ onMounted(() => {
}) })
onUnmounted(() => { onUnmounted(() => {
disposed = true
stopPolling() stopPolling()
if (countryPrefSaveTimer) clearTimeout(countryPrefSaveTimer) clearSleepTimers()
timers.clearScope()
if (countryPrefSaveTimer) timers.clearTimer('preference-save', countryPrefSaveTimer)
}) })
</script> </script>

View File

@@ -221,6 +221,7 @@ import {
} from '@/shared/api/java-modules' } from '@/shared/api/java-modules'
import { getPywebviewApi } from '@/shared/bridges/pywebview' import { getPywebviewApi } from '@/shared/bridges/pywebview'
import { getTaskPollIntervalMs } from '@/shared/task-progress-config' import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
const shopInput = ref('') const shopInput = ref('')
const candidates = ref<ProductRiskCandidateVo[]>([]) const candidates = ref<ProductRiskCandidateVo[]>([])
@@ -327,8 +328,8 @@ function onCountryDrop(toIndex: number) {
} }
function scheduleSaveCountryPref() { function scheduleSaveCountryPref() {
if (countryPrefSaveTimer) clearTimeout(countryPrefSaveTimer) if (countryPrefSaveTimer) timers.clearTimer('preference-save', countryPrefSaveTimer)
countryPrefSaveTimer = setTimeout(() => { countryPrefSaveTimer = timers.setTimeout('preference-save', () => {
countryPrefSaveTimer = null countryPrefSaveTimer = null
void persistCountryPref() void persistCountryPref()
}, 450) }, 450)
@@ -374,6 +375,8 @@ const taskDetails = ref<Record<number, string>>({})
const taskSnapshots = ref<Record<number, ProductRiskTaskDetailVo>>({}) const taskSnapshots = ref<Record<number, ProductRiskTaskDetailVo>>({})
const pollTimer = ref<number | null>(null) const pollTimer = ref<number | null>(null)
const pollingInFlight = ref(false) const pollingInFlight = ref(false)
let disposed = false
const timers = createCategorizedTimers('product-risk')
function uidForStorage() { function uidForStorage() {
return typeof window !== 'undefined' ? window.localStorage.getItem('uid') || '0' : '0' return typeof window !== 'undefined' ? window.localStorage.getItem('uid') || '0' : '0'
@@ -684,9 +687,12 @@ const TRANSIENT_BACKEND_ERROR_PATTERNS = [
] as const ] as const
function sleep(ms: number) { function sleep(ms: number) {
return new Promise<void>((resolve) => { if (disposed) return Promise.resolve()
window.setTimeout(() => resolve(), ms) return timers.sleep('queue-wait', ms)
}) }
function clearSleepTimers() {
timers.clearCategory('queue-wait')
} }
function isTransientBackendError(error: unknown) { function isTransientBackendError(error: unknown) {
@@ -697,6 +703,7 @@ function isTransientBackendError(error: unknown) {
async function createProductRiskTaskWithRetry(items: ProductRiskShopQueueItem[], maxAttempts = 10) { async function createProductRiskTaskWithRetry(items: ProductRiskShopQueueItem[], maxAttempts = 10) {
let attempt = 0 let attempt = 0
while (true) { while (true) {
if (disposed) throw new Error('component disposed')
try { try {
return await createProductRiskTask(items) return await createProductRiskTask(items)
} catch (error) { } catch (error) {
@@ -751,12 +758,13 @@ function getPollIntervalMs() {
function scheduleNextPoll(immediate = false) { function scheduleNextPoll(immediate = false) {
if (pollTimer.value) { if (pollTimer.value) {
if (!immediate) return if (!immediate) return
window.clearTimeout(pollTimer.value) timers.clearTimer('task-poll', pollTimer.value)
pollTimer.value = null pollTimer.value = null
} }
const run = async () => { const run = async () => {
pollTimer.value = null pollTimer.value = null
if (disposed) return
if (pollingInFlight.value) { if (pollingInFlight.value) {
scheduleNextPoll() scheduleNextPoll()
return return
@@ -768,13 +776,14 @@ function scheduleNextPoll(immediate = false) {
} finally { } finally {
pollingInFlight.value = false pollingInFlight.value = false
} }
if (pollingTaskIds.value.length > 0) { if (!disposed && pollingTaskIds.value.length > 0) {
pollTimer.value = window.setTimeout(run, getPollIntervalMs()) pollTimer.value = timers.setTimeout('task-poll', run, getPollIntervalMs())
} }
} }
if (disposed) return
if (immediate) void run() if (immediate) void run()
else pollTimer.value = window.setTimeout(run, getPollIntervalMs()) else pollTimer.value = timers.setTimeout('task-poll', run, getPollIntervalMs())
} }
function ensurePolling(immediate = false) { function ensurePolling(immediate = false) {
@@ -784,7 +793,7 @@ function ensurePolling(immediate = false) {
function stopPolling() { function stopPolling() {
if (pollTimer.value) { if (pollTimer.value) {
window.clearTimeout(pollTimer.value) timers.clearTimer('task-poll', pollTimer.value)
pollTimer.value = null pollTimer.value = null
} }
} }
@@ -793,6 +802,7 @@ async function waitForTaskTerminal(taskId: number) {
let transientErrorCount = 0 let transientErrorCount = 0
const maxTransientErrors = 30 const maxTransientErrors = 30
while (true) { while (true) {
if (disposed) return 'STOPPED'
try { try {
const batch = await getProductRiskTaskProgressBatch([taskId]) const batch = await getProductRiskTaskProgressBatch([taskId])
if (transientErrorCount > 0) { if (transientErrorCount > 0) {
@@ -930,9 +940,6 @@ async function runMatch() {
const batch = res.items || [] const batch = res.items || []
matchedItems.value = mergeMatchedItems(matchedItems.value, batch) matchedItems.value = mergeMatchedItems(matchedItems.value, batch)
saveMatchedItemsToStorage() saveMatchedItemsToStorage()
if (autoQueueEnabled.value) {
void processMatchedQueue()
}
if (!batch.length) { if (!batch.length) {
ElMessage.warning('未返回匹配结果') ElMessage.warning('未返回匹配结果')
} else { } else {
@@ -1027,7 +1034,7 @@ function nextMatchedQueueItem() {
} }
async function processMatchedQueue() { async function processMatchedQueue() {
if (queueWorkerRunning.value) { if (disposed || queueWorkerRunning.value) {
return return
} }
if (!matchedItems.value.length) { if (!matchedItems.value.length) {
@@ -1051,7 +1058,7 @@ async function processMatchedQueue() {
let failedCount = 0 let failedCount = 0
try { try {
let index = 0 let index = 0
while (autoQueueEnabled.value) { while (!disposed && autoQueueEnabled.value) {
const item = nextMatchedQueueItem() const item = nextMatchedQueueItem()
if (!item) { if (!item) {
break break
@@ -1102,6 +1109,7 @@ async function processMatchedQueue() {
successCount += 1 successCount += 1
queuePushResult.value = '任务 ' + taskId + ' 已完成' queuePushResult.value = '任务 ' + taskId + ' 已完成'
} catch (error) { } catch (error) {
if (disposed) break
failedCount += 1 failedCount += 1
queuePushResult.value = '第 ' + index + ' 条任务处理失败,已自动继续下一条:' + (error instanceof Error ? error.message : '未知错误') queuePushResult.value = '第 ' + index + ' 条任务处理失败,已自动继续下一条:' + (error instanceof Error ? error.message : '未知错误')
ElMessage.error(queuePushResult.value) ElMessage.error(queuePushResult.value)
@@ -1115,6 +1123,7 @@ async function processMatchedQueue() {
await loadHistory() await loadHistory()
ensurePolling(true) ensurePolling(true)
} catch (e) { } catch (e) {
if (disposed) return
queuePushResult.value = e instanceof Error ? e.message : '推送异常' queuePushResult.value = e instanceof Error ? e.message : '推送异常'
ElMessage.error(queuePushResult.value) ElMessage.error(queuePushResult.value)
} finally { } finally {
@@ -1141,8 +1150,11 @@ onMounted(() => {
}) })
onUnmounted(() => { onUnmounted(() => {
disposed = true
stopPolling() stopPolling()
if (countryPrefSaveTimer) clearTimeout(countryPrefSaveTimer) clearSleepTimers()
timers.clearScope()
if (countryPrefSaveTimer) timers.clearTimer('preference-save', countryPrefSaveTimer)
}) })
</script> </script>

View File

@@ -333,6 +333,7 @@ import {
} from "@/shared/api/java-modules"; } from "@/shared/api/java-modules";
import { getPywebviewApi } from "@/shared/bridges/pywebview"; import { getPywebviewApi } from "@/shared/bridges/pywebview";
import { getTaskPollIntervalMs } from "@/shared/task-progress-config"; import { getTaskPollIntervalMs } from "@/shared/task-progress-config";
import { createCategorizedTimers } from "@/shared/utils/categorized-timers";
const MAX_TRANSIENT_ERRORS = 30; const MAX_TRANSIENT_ERRORS = 30;
@@ -358,6 +359,7 @@ const activeQueueItem = ref<QueryAsinShopQueueItem | null>(null);
const queueWorkerRunning = ref(false); const queueWorkerRunning = ref(false);
const autoQueueEnabled = ref(false); const autoQueueEnabled = ref(false);
let historyPollTimer: number | null = null; let historyPollTimer: number | null = null;
const timers = createCategorizedTimers("query-asin");
const matchedRunnableItems = computed(() => const matchedRunnableItems = computed(() =>
matchedItems.value.filter((item) => item.matched && (item.queryAsins || []).some((row) => row.asins?.length)), 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}`; return `${item.taskId ?? 0}:${item.resultId ?? 0}`;
} }
let disposed = false;
function sleep(ms: number) { 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) { function isTransientBackendError(error: unknown) {
@@ -424,6 +433,7 @@ async function withTransientRetry<T>(
) { ) {
let attempt = 0; let attempt = 0;
while (true) { while (true) {
if (disposed) throw new Error("component disposed");
try { try {
return await action(); return await action();
} catch (error) { } catch (error) {
@@ -747,21 +757,21 @@ async function refreshActiveTaskProgress(taskIds?: number[]) {
function startHistoryPolling() { function startHistoryPolling() {
stopHistoryPolling(); stopHistoryPolling();
if (!hasQueueWork.value) return; if (disposed || !hasQueueWork.value) return;
const run = async () => { const run = async () => {
historyPollTimer = null; historyPollTimer = null;
if (!hasQueueWork.value) return; if (disposed || !hasQueueWork.value) return;
await refreshActiveTaskProgress(); await refreshActiveTaskProgress();
if (hasQueueWork.value) { if (!disposed && hasQueueWork.value) {
historyPollTimer = window.setTimeout(run, getTaskPollIntervalMs() * 2); historyPollTimer = timers.setTimeout("history-poll", run, getTaskPollIntervalMs() * 2);
} }
}; };
historyPollTimer = window.setTimeout(run, getTaskPollIntervalMs() * 2); historyPollTimer = timers.setTimeout("history-poll", run, getTaskPollIntervalMs() * 2);
} }
function stopHistoryPolling() { function stopHistoryPolling() {
if (historyPollTimer) { if (historyPollTimer) {
window.clearTimeout(historyPollTimer); timers.clearTimer("history-poll", historyPollTimer);
historyPollTimer = null; historyPollTimer = null;
} }
} }
@@ -893,6 +903,7 @@ function findHistoryItemByTaskId(taskId: number) {
async function waitForTaskTerminal(taskId: number) { async function waitForTaskTerminal(taskId: number) {
let transientErrorCount = 0; let transientErrorCount = 0;
while (true) { while (true) {
if (disposed) return "STOPPED";
try { try {
await refreshActiveTaskProgress([taskId]); await refreshActiveTaskProgress([taskId]);
if (activeTaskId.value !== taskId) { if (activeTaskId.value !== taskId) {
@@ -924,7 +935,7 @@ async function waitForTaskTerminal(taskId: number) {
} }
async function processQueue() { async function processQueue() {
if (queueWorkerRunning.value) return; if (disposed || queueWorkerRunning.value) return;
queueWorkerRunning.value = true; queueWorkerRunning.value = true;
pushing.value = true; pushing.value = true;
startHistoryPolling(); startHistoryPolling();
@@ -935,7 +946,7 @@ async function processQueue() {
throw new Error("当前客户端未提供 enqueue_json"); throw new Error("当前客户端未提供 enqueue_json");
} }
while (activeTaskId.value || pendingQueue.value.length) { while (!disposed && (activeTaskId.value || pendingQueue.value.length)) {
if (activeTaskId.value) { if (activeTaskId.value) {
const finalStatus = await waitForTaskTerminal(activeTaskId.value); const finalStatus = await waitForTaskTerminal(activeTaskId.value);
queuePushResult.value = queuePushResult.value =
@@ -1002,6 +1013,7 @@ async function processQueue() {
await refreshTaskViews(); await refreshTaskViews();
ElMessage.success("查询ASIN队列已按顺序执行完成"); ElMessage.success("查询ASIN队列已按顺序执行完成");
} catch (error) { } catch (error) {
if (disposed) return;
const message = error instanceof Error ? error.message : "队列执行失败"; const message = error instanceof Error ? error.message : "队列执行失败";
queuePushResult.value = message; queuePushResult.value = message;
ElMessage.error(message); ElMessage.error(message);
@@ -1092,7 +1104,10 @@ onMounted(async () => {
}); });
onUnmounted(() => { onUnmounted(() => {
disposed = true;
stopHistoryPolling(); stopHistoryPolling();
clearSleepTimers();
timers.clearScope();
}); });
</script> </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 { 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 { getPywebviewApi } from '@/shared/bridges/pywebview'
import { getTaskPollIntervalMs } from '@/shared/task-progress-config' 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 COUNTRY_OPTIONS = [{ code: 'DE', label: '德国' }, { code: 'UK', label: '英国' }, { code: 'FR', label: '法国' }, { code: 'IT', label: '意大利' }, { code: 'ES', label: '西班牙' }] as const
const shopInput = ref('') const shopInput = ref('')
@@ -144,6 +145,8 @@ const scheduledDispatchInFlight = ref(false)
let scheduleHeartbeatTimer: number | null = null let scheduleHeartbeatTimer: number | null = null
let lastScheduledErrorAt = 0 let lastScheduledErrorAt = 0
let lastScheduledErrorKey = '' 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 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 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) 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 isTaskMissingError(error: unknown) { const message = error instanceof Error ? error.message : String(error || ''); return /记录不存在|任务不存在|不存在|已删除|not\s*found|404/i.test(message) }
function syncPollingIdsWithTaskState() { const nextIds = pollingTaskIds.value.filter((taskId) => (taskDetails.value[taskId] || taskSnapshots.value[taskId]?.task?.status) === 'RUNNING'); if (nextIds.length === pollingTaskIds.value.length) return; pollingTaskIds.value = nextIds; savePollingIds() } function syncPollingIdsWithTaskState() { const nextIds = pollingTaskIds.value.filter((taskId) => (taskDetails.value[taskId] || taskSnapshots.value[taskId]?.task?.status) === 'RUNNING'); if (nextIds.length === pollingTaskIds.value.length) return; pollingTaskIds.value = nextIds; savePollingIds() }
function addPollingTask(taskId: number) { if (!pollingTaskIds.value.includes(taskId)) { pollingTaskIds.value = [...pollingTaskIds.value, taskId]; savePollingIds() } } function addPollingTask(taskId: number) { if (!pollingTaskIds.value.includes(taskId)) { pollingTaskIds.value = [...pollingTaskIds.value, taskId]; savePollingIds() } }
function clearDispatchTimer(taskId: number) { const timer = dispatchTimers.get(taskId); if (timer) { window.clearTimeout(timer); dispatchTimers.delete(taskId) } } function clearDispatchTimer(taskId: number) { const timer = dispatchTimers.get(taskId); if (timer) { timers.clearTimer('scheduled-dispatch', timer); dispatchTimers.delete(taskId); if (!dispatchTimers.size) stopScheduleHeartbeat() } }
function removePollingTask(taskId: number) { function removePollingTask(taskId: number) {
clearDispatchTimer(taskId) clearDispatchTimer(taskId)
pollingTaskIds.value = pollingTaskIds.value.filter((id) => id !== 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 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 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()]) } 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 } } 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 } 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 } } 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() 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 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() { 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 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 (scheduleHeartbeatTimer) return; scheduleHeartbeatTimer = window.setInterval(() => { void pumpScheduledQueue() }, 1000) } function startScheduleHeartbeat() { if (disposed || scheduleHeartbeatTimer || !dispatchTimers.size) return; scheduleHeartbeatTimer = timers.setInterval('schedule-heartbeat', () => { if (!disposed) void pumpScheduledQueue() }, 30000) }
function stopScheduleHeartbeat() { if (!scheduleHeartbeatTimer) return; window.clearInterval(scheduleHeartbeatTimer); scheduleHeartbeatTimer = null } function stopScheduleHeartbeat() { if (!scheduleHeartbeatTimer) return; timers.clearTimer('schedule-heartbeat', scheduleHeartbeatTimer); scheduleHeartbeatTimer = null }
function handleScheduleRecovery() { restoreScheduledDispatches(); if (pollingTaskIds.value.length) ensurePolling(true) } 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 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())) } 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[] } } } 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 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 ensurePolling(immediate = false) { scheduleNextPoll(immediate) }
function stopPolling() { if (pollTimer.value) { window.clearTimeout(pollTimer.value); pollTimer.value = null } } 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) { 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 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) { async function waitForScheduledTaskStageExit(taskId: number) {
let transientErrorCount = 0 let transientErrorCount = 0
const maxTransientErrors = 30 const maxTransientErrors = 30
while (true) { while (true) {
if (disposed) return 'STOPPED'
try { try {
const batch = await getShopMatchTaskProgressBatch([taskId]) const batch = await getShopMatchTaskProgressBatch([taskId])
if (transientErrorCount > 0) queuePushResult.value = `任务 ${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 '未命中或未就绪,请检查店铺名与索引刷新' } 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() } async function pushToPythonQueue() { autoQueueEnabled.value = true; await processMatchedQueue() }
function nextMatchedQueueItem() { return matchedItems.value.find((item) => item.matched) } 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 } } 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()]); startScheduleHeartbeat(); window.addEventListener('focus', handleScheduleRecovery); document.addEventListener('visibilitychange', handleScheduleRecovery); restoreScheduledDispatches(); if (pollingTaskIds.value.length) ensurePolling(true) }) 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(() => { 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() }) 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> </script>
<style scoped> <style scoped>

View File

@@ -521,6 +521,7 @@ export function runDeleteBrand(
...request, ...request,
user_id: getCurrentUserId(), 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,
}))
}