diff --git a/app/.env b/app/.env index 152d342..ef77969 100644 --- a/app/.env +++ b/app/.env @@ -11,8 +11,8 @@ zn_username=%E8%87%AA%E5%8A%A8%E5%8C%96_Robot client_name=ShuFuAI -java_api_base=http://47.111.163.154:18080 -# java_api_base=http://127.0.0.1:18080 +# java_api_base=http://47.111.163.154:18080 +java_api_base=http://127.0.0.1:18080 # java_api_base=http://8.136.19.173:18080 diff --git a/app/amazon/__pycache__/approve.cpython-39.pyc b/app/amazon/__pycache__/approve.cpython-39.pyc new file mode 100644 index 0000000..84f4d46 Binary files /dev/null and b/app/amazon/__pycache__/approve.cpython-39.pyc differ diff --git a/app/amazon/__pycache__/asin_status.cpython-39.pyc b/app/amazon/__pycache__/asin_status.cpython-39.pyc new file mode 100644 index 0000000..370790b Binary files /dev/null and b/app/amazon/__pycache__/asin_status.cpython-39.pyc differ diff --git a/app/amazon/__pycache__/del_brand.cpython-39.pyc b/app/amazon/__pycache__/del_brand.cpython-39.pyc new file mode 100644 index 0000000..6eeeac4 Binary files /dev/null and b/app/amazon/__pycache__/del_brand.cpython-39.pyc differ diff --git a/app/amazon/__pycache__/match_action.cpython-39.pyc b/app/amazon/__pycache__/match_action.cpython-39.pyc new file mode 100644 index 0000000..726e545 Binary files /dev/null and b/app/amazon/__pycache__/match_action.cpython-39.pyc differ diff --git a/app/amazon/__pycache__/price_match.cpython-39.pyc b/app/amazon/__pycache__/price_match.cpython-39.pyc new file mode 100644 index 0000000..818c7a4 Binary files /dev/null and b/app/amazon/__pycache__/price_match.cpython-39.pyc differ diff --git a/app/amazon/approve.py b/app/amazon/approve.py index db65147..b18d407 100644 --- a/app/amazon/approve.py +++ b/app/amazon/approve.py @@ -1081,7 +1081,69 @@ class ApproveTask: if shop_name in runing_shop: del runing_shop[shop_name] self.log(f"店铺 {shop_name} 已从执行列表中移除") - + + def action_init(self,driver,country_name,shop_name,risk_listing_filter): + """切换到指定国际 -> 页面 -》 筛选好""" + max_retries = 5 + switch_success = False + switch_success_pg = False + search_success = False + sku_ls = [] + for retry in range(max_retries): + try: + self.log(f"尝试切换到国家 {country_name} (第 {retry + 1}/{max_retries} 次)") + if retry > 2: + # 刷新不行就重新打开店铺 + self.log("重试前重新打开店铺...") + try: + driver.close_store() + time.sleep(3) + driver.open_shop(shop_name) + except Exception as e: + self.log(f"关闭重新打开店铺: {str(e)}", "WARNING") + # 如果不是第一次尝试,先刷新页面 + if retry > 0: + self.log("重试前刷新页面...") + try: + driver.tab.refresh() + time.sleep(3) + except Exception as e: + self.log(f"刷新页面失败: {str(e)}", "WARNING") + + switch_success = driver.SwitchingCountries(country_name) + if switch_success: + self.log(f"成功切换到国家 {country_name}") + + # 切换到库存管理页面 + driver.SwitchPage() + self.log(f"已切换到库存管理页面") + time.sleep(3) + switch_success_pg = True + + sku_ls = driver.search(filter_type=risk_listing_filter) + search_success = True + self.log(f"国家 {country_name} 搜索出 {len(sku_ls)} 商品,开始处理...") + break + else: + self.log(f"切换到国家 {country_name} 失败", "WARNING") + + except Exception as e: + import traceback + self.log(f"切换国家 {country_name} 异常: {str(e)}", "ERROR") + self.log(traceback.format_exc(), "ERROR") + + # 如果还有重试机会,等待后继续 + if retry < max_retries - 1: + time.sleep(2) + + # 如果切换失败,直接返回 + # if not switch_success or not switch_success_pg or not search_success: + # error_message = f"切换到国家({switch_success})/库存页面({switch_success_pg})/搜索筛选到指定选项({search_success}) {country_name} 失败,已重试 {max_retries} 次,跳过该国家" + # self.log(error_message, "ERROR") + # if task_id in runing_task: + # runing_task[task_id]["processed_countries"] += 1 + return switch_success,switch_success_pg,search_success,sku_ls + def process_country(self, driver: AmzoneApprove, country_code: str, task_id: int, shop_name: str, risk_listing_filter: str): """处理单个国家的审批任务 @@ -1108,87 +1170,98 @@ class ApproveTask: max_retries = 3 switch_success = False - for retry in range(max_retries): - try: - self.log(f"尝试切换到国家 {country_name} (第 {retry + 1}/{max_retries} 次)") - if retry > 1: - # 刷新不行就重新打开店铺 - self.log("重试前重新打开店铺...") - try: - driver.close_store() - time.sleep(3) - driver.open_shop(shop_name) - except Exception as e: - self.log(f"关闭重新打开店铺: {str(e)}", "WARNING") - - # 如果不是第一次尝试,先刷新页面 - if retry > 0: - self.log("重试前刷新页面...") - try: - driver.tab.refresh() - time.sleep(3) - except Exception as e: - self.log(f"刷新页面失败: {str(e)}", "WARNING") - - switch_success = driver.SwitchingCountries(country_name) - if switch_success: - self.log(f"成功切换到国家 {country_name}") - break - else: - self.log(f"切换到国家 {country_name} 失败", "WARNING") - - except Exception as e: - import traceback - self.log(f"切换国家 {country_name} 异常: {str(e)}", "ERROR") - self.log(traceback.format_exc(), "ERROR") - - # 如果还有重试机会,等待后继续 - if retry < max_retries - 1: - time.sleep(2) - + # for retry in range(max_retries): + # try: + # self.log(f"尝试切换到国家 {country_name} (第 {retry + 1}/{max_retries} 次)") + # if retry > 1: + # # 刷新不行就重新打开店铺 + # self.log("重试前重新打开店铺...") + # try: + # driver.close_store() + # time.sleep(3) + # driver.open_shop(shop_name) + # except Exception as e: + # self.log(f"关闭重新打开店铺: {str(e)}", "WARNING") + # + # # 如果不是第一次尝试,先刷新页面 + # if retry > 0: + # self.log("重试前刷新页面...") + # try: + # driver.tab.refresh() + # time.sleep(3) + # except Exception as e: + # self.log(f"刷新页面失败: {str(e)}", "WARNING") + # + # switch_success = driver.SwitchingCountries(country_name) + # if switch_success: + # self.log(f"成功切换到国家 {country_name}") + # break + # else: + # self.log(f"切换到国家 {country_name} 失败", "WARNING") + # + # except Exception as e: + # import traceback + # self.log(f"切换国家 {country_name} 异常: {str(e)}", "ERROR") + # self.log(traceback.format_exc(), "ERROR") + # + # # 如果还有重试机会,等待后继续 + # if retry < max_retries - 1: + # time.sleep(2) + # + # # 如果切换失败,直接返回 + # if not switch_success: + # error_message = f"切换到国家 {country_name} 失败,已重试 {max_retries} 次,跳过该国家" + # self.log(error_message, "ERROR") + # if task_id in runing_task: + # runing_task[task_id]["processed_countries"] += 1 + # return + # + # # 切换到库存管理页面 + # try: + # driver.SwitchPage() + # self.log(f"已切换到库存管理页面") + # except Exception as e: + # import traceback + # self.log(f"切换页面失败: {str(e)}", "ERROR") + # self.log(traceback.format_exc(), "ERROR") + # if task_id in runing_task: + # runing_task[task_id]["processed_countries"] += 1 + # return + # + # # 搜索需要审批的商品,最多重试3次 + # sku_ls = [] + # for retry in range(max_retries): + # try: + # self.log(f"尝试搜索需要审批的商品 (第 {retry + 1}/{max_retries} 次)") + # sku_ls = driver.search(filter_type=risk_listing_filter) + # break + # except Exception as e: + # self.log(f"搜索商品异常: {str(e)}", "ERROR") + # if retry < max_retries - 1: + # try: + # driver.tab.refresh() + # time.sleep(3) + # except Exception as refresh_error: + # self.log(f"刷新页面失败: {str(refresh_error)}", "WARNING") + # + max_retries = 5 + switch_success, switch_success_pg, search_success,sku_ls = self.action_init(driver, country_name, shop_name, + risk_listing_filter) # 如果切换失败,直接返回 - if not switch_success: - error_message = f"切换到国家 {country_name} 失败,已重试 {max_retries} 次,跳过该国家" + if not switch_success or not switch_success_pg or not search_success: + error_message = f"切换到国家({switch_success})/库存页面({switch_success_pg})/搜索筛选到指定选项({search_success}) {country_name} 失败,已重试 {max_retries} 次,跳过该国家" self.log(error_message, "ERROR") if task_id in runing_task: runing_task[task_id]["processed_countries"] += 1 return - - # 切换到库存管理页面 - try: - driver.SwitchPage() - self.log(f"已切换到库存管理页面") - except Exception as e: - import traceback - self.log(f"切换页面失败: {str(e)}", "ERROR") - self.log(traceback.format_exc(), "ERROR") - if task_id in runing_task: - runing_task[task_id]["processed_countries"] += 1 - return - - # 搜索需要审批的商品,最多重试3次 - sku_ls = [] - for retry in range(max_retries): - try: - self.log(f"尝试搜索需要审批的商品 (第 {retry + 1}/{max_retries} 次)") - sku_ls = driver.search(filter_type=risk_listing_filter) - break - except Exception as e: - self.log(f"搜索商品异常: {str(e)}", "ERROR") - if retry < max_retries - 1: - try: - driver.tab.refresh() - time.sleep(3) - except Exception as refresh_error: - self.log(f"刷新页面失败: {str(refresh_error)}", "WARNING") - - # 如果没有需要审批的商品,直接返回 + + # # 如果没有需要审批的商品,直接返回 if len(sku_ls) == 0: self.log(f"国家 {country_name} 没有需要审批的商品") if task_id in runing_task: runing_task[task_id]["processed_countries"] += 1 return - + self.log(f"国家 {country_name} 有 {len(sku_ls)} 个需要审批的商品,开始处理...") # 处理所有需要审批的商品(通过yield获取结果) diff --git a/app/amazon/asin_status.py b/app/amazon/asin_status.py index 9160f21..8a5ad46 100644 --- a/app/amazon/asin_status.py +++ b/app/amazon/asin_status.py @@ -537,8 +537,6 @@ class StatusTask: self.log(f"切换页面失败重试退出", "ERROR") return - - # 处理所有需要审批的商品(通过yield获取结果) # 指定 asin diff --git a/app/amazon/del_brand.py b/app/amazon/del_brand.py index af7c8ee..884b093 100644 --- a/app/amazon/del_brand.py +++ b/app/amazon/del_brand.py @@ -8,11 +8,12 @@ import json import os from typing import Literal -from DrissionPage import Chromium +from DrissionPage import Chromium,ChromiumOptions from DrissionPage.common import By from DrissionPage._pages.chromium_tab import ChromiumTab + def kill_process(version: Literal["v5", "v6"]): """杀紫鸟客户端进程(独立函数版本)""" driver = ZiniaoDriver({}) @@ -205,7 +206,11 @@ class ZiniaoDriver: Returns: Chromium: DrissionPage浏览器实例 """ - browser = Chromium(port) + co = ChromiumOptions() + # 设置不加载图片、静音 + co.set_local_port(port) + co.no_imgs(True).mute(True) + browser = Chromium(co) return browser def start_client(self): diff --git a/app/amazon/match_action.py b/app/amazon/match_action.py index 7745f20..07f2516 100644 --- a/app/amazon/match_action.py +++ b/app/amazon/match_action.py @@ -468,6 +468,70 @@ class MatchTak: del runing_shop[shop_name] self.log(f"店铺 {shop_name} 已从执行列表中移除") + def action_init(self,driver,country_name,shop_name,risk_listing_filter): + """切换到指定国际 -> 页面 -》 筛选好""" + max_retries = 5 + switch_success = False + switch_success_pg = False + search_success = False + sku_ls = [] + for retry in range(max_retries): + try: + self.log(f"尝试切换到国家 {country_name} (第 {retry + 1}/{max_retries} 次)") + if retry > 2: + # 刷新不行就重新打开店铺 + self.log("重试前重新打开店铺...") + try: + driver.close_store() + time.sleep(3) + driver.open_shop(shop_name) + except Exception as e: + self.log(f"关闭重新打开店铺: {str(e)}", "WARNING") + # 如果不是第一次尝试,先刷新页面 + if retry > 0: + self.log("重试前刷新页面...") + try: + driver.tab.refresh() + time.sleep(3) + except Exception as e: + self.log(f"刷新页面失败: {str(e)}", "WARNING") + + switch_success = driver.SwitchingCountries(country_name) + if switch_success: + self.log(f"成功切换到国家 {country_name}") + + # 切换到库存管理页面 + driver.SwitchPage() + self.log(f"已切换到库存管理页面") + time.sleep(3) + switch_success_pg = True + + sku_ls = driver.search(filter_type=risk_listing_filter) + search_success = True + self.log(f"国家 {country_name} 搜索出 {len(sku_ls)} 商品,开始处理...") + break + else: + self.log(f"切换到国家 {country_name} 失败", "WARNING") + + except Exception as e: + import traceback + self.log(f"切换国家 {country_name} 异常: {str(e)}", "ERROR") + self.log(traceback.format_exc(), "ERROR") + + # 如果还有重试机会,等待后继续 + if retry < max_retries - 1: + time.sleep(2) + + # 如果切换失败,直接返回 + # if not switch_success or not switch_success_pg or not search_success: + # error_message = f"切换到国家({switch_success})/库存页面({switch_success_pg})/搜索筛选到指定选项({search_success}) {country_name} 失败,已重试 {max_retries} 次,跳过该国家" + # self.log(error_message, "ERROR") + # if task_id in runing_task: + # runing_task[task_id]["processed_countries"] += 1 + return switch_success,switch_success_pg,search_success,sku_ls + + + def process_country(self, driver: AmzoneMatchAction, country_code: str, task_id: int, shop_name: str, risk_listing_filter: str,limit:str=None): """处理单个国家的审批任务 @@ -491,90 +555,108 @@ class MatchTak: runing_task[task_id]["current_country"] = country_name # 切换国家,最多重试3次 - max_retries = 3 - switch_success = False - - for retry in range(max_retries): - try: - self.log(f"尝试切换到国家 {country_name} (第 {retry + 1}/{max_retries} 次)") - if retry > 1: - # 刷新不行就重新打开店铺 - self.log("重试前重新打开店铺...") - try: - driver.close_store() - time.sleep(3) - driver.open_shop(shop_name) - except Exception as e: - self.log(f"关闭重新打开店铺: {str(e)}", "WARNING") - - # 如果不是第一次尝试,先刷新页面 - if retry > 0: - self.log("重试前刷新页面...") - try: - driver.tab.refresh() - time.sleep(3) - except Exception as e: - self.log(f"刷新页面失败: {str(e)}", "WARNING") - - switch_success = driver.SwitchingCountries(country_name) - if switch_success: - self.log(f"成功切换到国家 {country_name}") - break - else: - self.log(f"切换到国家 {country_name} 失败", "WARNING") - - except Exception as e: - import traceback - self.log(f"切换国家 {country_name} 异常: {str(e)}", "ERROR") - self.log(traceback.format_exc(), "ERROR") - - # 如果还有重试机会,等待后继续 - if retry < max_retries - 1: - time.sleep(2) - + # max_retries = 3 + # switch_success = False + # + # for retry in range(max_retries): + # try: + # self.log(f"尝试切换到国家 {country_name} (第 {retry + 1}/{max_retries} 次)") + # if retry > 1: + # # 刷新不行就重新打开店铺 + # self.log("重试前重新打开店铺...") + # try: + # driver.close_store() + # time.sleep(3) + # driver.open_shop(shop_name) + # except Exception as e: + # self.log(f"关闭重新打开店铺: {str(e)}", "WARNING") + # + # # 如果不是第一次尝试,先刷新页面 + # if retry > 0: + # self.log("重试前刷新页面...") + # try: + # driver.tab.refresh() + # time.sleep(3) + # except Exception as e: + # self.log(f"刷新页面失败: {str(e)}", "WARNING") + # + # switch_success = driver.SwitchingCountries(country_name) + # if switch_success: + # self.log(f"成功切换到国家 {country_name}") + # break + # else: + # self.log(f"切换到国家 {country_name} 失败", "WARNING") + # + # except Exception as e: + # import traceback + # self.log(f"切换国家 {country_name} 异常: {str(e)}", "ERROR") + # self.log(traceback.format_exc(), "ERROR") + # + # # 如果还有重试机会,等待后继续 + # if retry < max_retries - 1: + # time.sleep(2) + # + # # 如果切换失败,直接返回 + # if not switch_success: + # error_message = f"切换到国家 {country_name} 失败,已重试 {max_retries} 次,跳过该国家" + # self.log(error_message, "ERROR") + # if task_id in runing_task: + # runing_task[task_id]["processed_countries"] += 1 + # return + # + # # 切换到库存管理页面 + # try: + # driver.SwitchPage() + # self.log(f"已切换到库存管理页面") + # except Exception as e: + # import traceback + # self.log(f"切换页面失败: {str(e)}", "ERROR") + # self.log(traceback.format_exc(), "ERROR") + # if task_id in runing_task: + # runing_task[task_id]["processed_countries"] += 1 + # return + # + # # 搜索需要审批的商品,最多重试3次 + # sku_ls = [] + # for retry in range(max_retries): + # try: + # self.log(f"尝试搜索匹配操作商品 (第 {retry + 1}/{max_retries} 次)") + # sku_ls = driver.search(filter_type=risk_listing_filter) + # break + # except Exception as e: + # self.log(f"搜索商品异常: {str(e)}", "ERROR") + # if retry < max_retries - 1: + # try: + # driver.tab.refresh() + # time.sleep(3) + # except Exception as refresh_error: + # self.log(f"刷新页面失败: {str(refresh_error)}", "WARNING") + # + # # 如果没有需要审批的商品,直接返回 + # if len(sku_ls) == 0: + # self.log(f"国家 {country_name} 没有搜索出的商品") + # if task_id in runing_task: + # runing_task[task_id]["processed_countries"] += 1 + # return + # + max_retries = 5 + switch_success, switch_success_pg, search_success, sku_ls = self.action_init(driver, country_name, shop_name, + risk_listing_filter) # 如果切换失败,直接返回 - if not switch_success: - error_message = f"切换到国家 {country_name} 失败,已重试 {max_retries} 次,跳过该国家" + if not switch_success or not switch_success_pg or not search_success: + error_message = f"切换到国家({switch_success})/库存页面({switch_success_pg})/搜索筛选到指定选项({search_success}) {country_name} 失败,已重试 {max_retries} 次,跳过该国家" self.log(error_message, "ERROR") if task_id in runing_task: runing_task[task_id]["processed_countries"] += 1 return - - # 切换到库存管理页面 - try: - driver.SwitchPage() - self.log(f"已切换到库存管理页面") - except Exception as e: - import traceback - self.log(f"切换页面失败: {str(e)}", "ERROR") - self.log(traceback.format_exc(), "ERROR") - if task_id in runing_task: - runing_task[task_id]["processed_countries"] += 1 - return - - # 搜索需要审批的商品,最多重试3次 - sku_ls = [] - for retry in range(max_retries): - try: - self.log(f"尝试搜索匹配操作商品 (第 {retry + 1}/{max_retries} 次)") - sku_ls = driver.search(filter_type=risk_listing_filter) - break - except Exception as e: - self.log(f"搜索商品异常: {str(e)}", "ERROR") - if retry < max_retries - 1: - try: - driver.tab.refresh() - time.sleep(3) - except Exception as refresh_error: - self.log(f"刷新页面失败: {str(refresh_error)}", "WARNING") - - # 如果没有需要审批的商品,直接返回 + + # # 如果没有需要审批的商品,直接返回 if len(sku_ls) == 0: - self.log(f"国家 {country_name} 没有搜索出的商品") + self.log(f"国家 {country_name} 没有需要审批的商品") if task_id in runing_task: runing_task[task_id]["processed_countries"] += 1 return - + self.log(f"国家 {country_name} 搜索出 {len(sku_ls)} 商品,开始处理...") # 处理所有需要审批的商品(通过yield获取结果) diff --git a/app/amazon/price_match.py b/app/amazon/price_match.py index 4ce5d2b..fc57491 100644 --- a/app/amazon/price_match.py +++ b/app/amazon/price_match.py @@ -16,28 +16,32 @@ from amazon.tool import show_notification,get_shop_info,remove_special_character from config import runing_task, runing_shop,base_dir,DELETE_BRAND_API_BASE - class RepricingLogic: @staticmethod def situation_1_general_decrease(my_price, rank1_price, is_first_time=False): """ 情况1:常规递减跟价 (最左侧独立长图块) - 当不是自己购物车时,按总价当作第一名递减。 """ if is_first_time: return rank1_price - 0.3 - diff = my_price - rank1_price + # 这里的 diff 算出的是一个正数,代表“我比第一名低多少” + diff = rank1_price - my_price - # 精简了原图中长串的阶梯逻辑 + # 如果当前价格高于或等于第一名,默认按照第一名减0.3处理 + if diff <= 0: + return rank1_price - 0.3 + + # 逻辑更清晰的写法:第一名价格 - (差价 + 对应阶梯值) + # 例如差价0.7,则相当于 第一名价格 - (0.7 + 0.5) = 第一名价格 - 1.2 if diff <= 0.3: - return rank1_price - 0.5 + return rank1_price - (diff + 0.5) elif diff <= 0.8: - return rank1_price - 0.7 + return rank1_price - (diff + 0.7) elif diff <= 10.5: - # 0.8 到 10.5 之间,原图逻辑全部是“减1” - return rank1_price - 1.0 + # 0.8 到 10.5 之间全部是在减1 + return rank1_price - (diff + 1.0) else: return None # 相差10.5以上,跳过 @@ -45,55 +49,56 @@ class RepricingLogic: def situation_2_own_buybox(my_price, rank2_price): """ 情况2:已经是自己购物车 - 逻辑:判断与第二名的差价,如果拉开足够差距,则稍微降一点点(减0.5)保持优势,防止卖太便宜。 """ diff = rank2_price - my_price - # 1. 绝对差价2欧以上 if diff >= 2.0: return rank2_price - 0.3 - # 2. 产品售价区间判定 (是否要提高价格) if (20 <= my_price < 30 and diff >= 4) or \ (30 <= my_price < 60 and diff >= 8) or \ (60 <= my_price <= 150 and diff >= 15): - return rank2_price - 0.3 # 满足区间大差价,提价至第二名之下 + return rank2_price - 0.3 - return my_price # 不满足条件则保持原价 + return my_price @staticmethod def situation_3_not_own_buybox(my_price, rank1_price): """ - 情况3:不是自己购物车 (常规情况) + 情况3:不是自己购物车 (除Amazon US外) """ - diff = my_price - rank1_price + diff = rank1_price - my_price - if diff <= 0.3: - return rank1_price - 0.5 - elif 0.5 <= diff <= 0.8: - return rank1_price - 0.7 - elif 0.8 < diff <= 2.5: - return rank1_price - 1.0 - elif diff > 2.5: # 2.5-3.5以上跳过 - return None + if diff <= 0: + return rank1_price - 0.3 + + # 逻辑更清晰的写法:第一名价格 - (差价 + 对应阶梯值) + if diff <= 0.3: + return rank1_price - (diff + 0.5) + elif diff <= 0.8: + return rank1_price - (diff + 0.7) + elif diff <= 2.5: + return rank1_price - (diff + 1.0) + else: + return None # 2.5-3.5以上跳过 - return rank1_price - 0.3 # 默认按第一名减0.3 - @staticmethod def situation_4_encounter_amz_us_1st(my_price, amz_us_price): """ - 情况4:第一名是 Amazon US 卖家 + 情况4:遇到第一名是 Amazon US 卖家 """ - diff = my_price - amz_us_price + diff = amz_us_price - my_price - if diff <= 3: - return amz_us_price - 2 - elif 4 <= diff <= 6: + if diff <= 0: return amz_us_price - 3 - elif 8 <= diff <= 12: - return None # 跳过不跟价 - return amz_us_price - 3 # 默认直接按照 Amazon US 减去3 + # 逻辑更清晰的写法:第一名价格 - (差价 + 对应阶梯值) + if diff <= 3: + return amz_us_price - (diff + 2) + elif diff <= 6: + return amz_us_price - (diff + 3) + else: + return None # 超过6直接跳过 @staticmethod def situation_5_im_1st_amz_us_2nd(my_price, amz_us_price): @@ -103,7 +108,7 @@ class RepricingLogic: diff = amz_us_price - my_price if diff <= 7: - return None # 相差5以内跳过 (保持原价) + return None # 相差7以内跳过 (保持原价) else: return amz_us_price - 5 @@ -133,16 +138,6 @@ def calculate_target_price( price_col = recommended_price + recommended_shipping print(f"【紫鸟后台价格】{is_my_buybox} 推荐价格: {recommended_price},推荐运费: {recommended_shipping},总和: {price_col}") return RepricingLogic.situation_1_general_decrease(my_price,price_col,is_my_buybox) - # backend_base_price = recommended_price - # backend_shipping = recommended_shipping - # total_price = backend_base_price + backend_shipping - # - # if is_my_buybox: - # # 🌟【逻辑更新】:如果是自己的购物车,直接跳过,不做任何价格调整 - # return None - # else: - # # 不是自己的购物车:直接将紫鸟的总和作为"第一名"价格,强制抛入阶梯跟价逻辑 - # return calculate_standard_competitor_pricing(my_price, total_price) # # 第一名是自己, 第二名 Amazon US 卖家 的情况 shop_name_1 = front_end_data.get("top_sellers")[0].get("shop_name") @@ -152,13 +147,11 @@ def calculate_target_price( return RepricingLogic.situation_5_im_1st_amz_us_2nd(my_price,price_2) # --- Step 3: 常规核心分流逻辑 (如果没有紫鸟后台数据) --- - # 分支 A:第一名是 Amazon US 卖家 (特殊强敌优先) price_1 = float(front_end_data.get("top_sellers")[0].get("price")) # 实际第一名价格 if "Amazon" in shop_name_1: return RepricingLogic.situation_4_encounter_amz_us_1st(my_price,price_1) - # 分支 B:不是 Amazon US,且目前是自己的购物车 elif is_my_buybox: price_2 = float(front_end_data.get("top_sellers")[1].get("price")) # 实际第二名价格 @@ -650,6 +643,13 @@ class AmzonePriceMatch(AmamzonBase): print(f"【{self.mark_name}】获取到 {len(sku_ls)}") if len(sku_ls) == 0: + if mode == "asin": + print(f"asin 模式查询不到,{asin}") + yield (asin, { + "statu": "查询不到" + }) + break + retry_num += 1 print(f"没有获取到 ASIN 商品,重试{retry_num}/{max_retry_num}") self.tab.refresh() @@ -680,19 +680,16 @@ class AmzonePriceMatch(AmamzonBase): bottom_price = f"{sum(split_currency_values(bottom_price_text))}" except Exception as e: print("最低价提取失败",e) + current_price_ele = sku_ele.eles('xpath:.//b[text()="价格"]/../..//kat-input',timeout=5) if len(current_price_ele) > 0: current_price = current_price_ele[0].attr("value") print("获取到当前价格为 ->",current_price) # 如果紫鸟里当前价格低于最低价则删除,否则跳过 - if mode == "status" and miniprice_info.get(asin): + # if mode == "status" and miniprice_info.get(asin): + if miniprice_info.get(asin): backend_price = float(miniprice_info.get(asin)) if float(current_price) <= backend_price: - # yield (asin, { - # "statu": "删除(当前价格低于最低价)", - # "deleteSkipAsin": True, - # "removeAsin": asin, - # }) yield (asin, { "statu": "跳过(当前价格低于或等于最低价)", "currentPrice": current_price, @@ -764,6 +761,9 @@ class AmzonePriceMatch(AmamzonBase): print(asin,"-->>",f"【逻辑计算后的价格】",adjust_prices) if adjust_prices is not None and adjust_prices != float(current_price): #修改价格 + print("准备填入的原始值:",adjust_prices) + adjust_prices = round(adjust_prices, 2) + print("保留两位小数后的::",adjust_prices) self.tab.actions.click(current_price_ele[0]) time.sleep(1) current_price_ele[0].input(f"{adjust_prices}", clear=True) @@ -784,12 +784,12 @@ class AmzonePriceMatch(AmamzonBase): "statu" : "改价成功", "currentPrice" : current_price, "recommendedPrice" : f"{recommendedPrice}", - "minimumPrice" : bottom_price, #最低价格 + "minimumPrice" : f"{miniprice_info.get(asin)}" if miniprice_info.get(asin) else "",#bottom_price, #最低价格 "firstPlace": price_1, "secondPlace": price_2, "cartShopName" : cartShopName, "shippingFee" : f"{shipping_fee}", - "priceChangeStatus" : f"{adjust_prices}", + "priceChangePrice" : f"{adjust_prices}", "priceChangeStatus" : "改价成功", }) else: @@ -803,12 +803,14 @@ class AmzonePriceMatch(AmamzonBase): "statu": "跳过,无需改价", "currentPrice": current_price, "recommendedPrice": f"{recommendedPrice}", - "minimumPrice": bottom_price, # 最低价格 + # "minimumPrice": bottom_price, # 最低价格 + "minimumPrice": f"{miniprice_info.get(asin)}" if miniprice_info.get(asin) else "", "firstPlace": price_1, "secondPlace": price_2, "cartShopName": cartShopName, "shippingFee": f"{shipping_fee}", "priceChangeStatus": "跳过,无需改价", + "priceChangePrice": f"{adjust_prices}".replace("None",""), }) already_asin.add(asin) @@ -1029,6 +1031,68 @@ class PriceTask: return driver return driver + def action_init(self,driver,country_name,shop_name,risk_listing_filter): + """切换到指定国际 -> 页面 -》 筛选好""" + max_retries = 5 + switch_success = False + switch_success_pg = False + search_success = False + sku_ls = [] + for retry in range(max_retries): + try: + self.log(f"尝试切换到国家 {country_name} (第 {retry + 1}/{max_retries} 次)") + if retry > 2: + # 刷新不行就重新打开店铺 + self.log("重试前重新打开店铺...") + try: + driver.close_store() + time.sleep(3) + driver.open_shop(shop_name) + except Exception as e: + self.log(f"关闭重新打开店铺: {str(e)}", "WARNING") + # 如果不是第一次尝试,先刷新页面 + if retry > 0: + self.log("重试前刷新页面...") + try: + driver.tab.refresh() + time.sleep(3) + except Exception as e: + self.log(f"刷新页面失败: {str(e)}", "WARNING") + + switch_success = driver.SwitchingCountries(country_name) + if switch_success: + self.log(f"成功切换到国家 {country_name}") + + # 切换到库存管理页面 + driver.SwitchPage() + self.log(f"已切换到库存管理页面") + time.sleep(3) + switch_success_pg = True + + sku_ls = driver.search(filter_type=risk_listing_filter) + search_success = True + self.log(f"国家 {country_name} 搜索出 {len(sku_ls)} 商品,开始处理...") + break + else: + self.log(f"切换到国家 {country_name} 失败", "WARNING") + + except Exception as e: + import traceback + self.log(f"切换国家 {country_name} 异常: {str(e)}", "ERROR") + self.log(traceback.format_exc(), "ERROR") + + # 如果还有重试机会,等待后继续 + if retry < max_retries - 1: + time.sleep(2) + + # 如果切换失败,直接返回 + # if not switch_success or not switch_success_pg or not search_success: + # error_message = f"切换到国家({switch_success})/库存页面({switch_success_pg})/搜索筛选到指定选项({search_success}) {country_name} 失败,已重试 {max_retries} 次,跳过该国家" + # self.log(error_message, "ERROR") + # if task_id in runing_task: + # runing_task[task_id]["processed_countries"] += 1 + return switch_success,switch_success_pg,search_success + def process_shop(self, shop_item: dict, country_codes: list, task_id: int, risk_listing_filter: str, user_id=None, stage_index=None, final_stage: bool = True, limit: str = None): @@ -1149,94 +1213,42 @@ class PriceTask: runing_task[task_id]["current_country"] = country_name # 切换国家,最多重试3次 - max_retries = 3 - switch_success = False - - for retry in range(max_retries): - try: - self.log(f"尝试切换到国家 {country_name} (第 {retry + 1}/{max_retries} 次)") - if retry > 1: - # 刷新不行就重新打开店铺 - self.log("重试前重新打开店铺...") - try: - driver.close_store() - time.sleep(3) - driver.open_shop(shop_name) - except Exception as e: - self.log(f"关闭重新打开店铺: {str(e)}", "WARNING") - # 如果不是第一次尝试,先刷新页面 - if retry > 0: - self.log("重试前刷新页面...") - try: - driver.tab.refresh() - time.sleep(3) - except Exception as e: - self.log(f"刷新页面失败: {str(e)}", "WARNING") - - switch_success = driver.SwitchingCountries(country_name) - if switch_success: - self.log(f"成功切换到国家 {country_name}") - break - else: - self.log(f"切换到国家 {country_name} 失败", "WARNING") - - except Exception as e: - import traceback - self.log(f"切换国家 {country_name} 异常: {str(e)}", "ERROR") - self.log(traceback.format_exc(), "ERROR") - - # 如果还有重试机会,等待后继续 - if retry < max_retries - 1: - time.sleep(2) + max_retries = 5 + switch_success,switch_success_pg,search_success = self.action_init( driver, country_name, shop_name, risk_listing_filter) # 如果切换失败,直接返回 - if not switch_success: - error_message = f"切换到国家 {country_name} 失败,已重试 {max_retries} 次,跳过该国家" + if not switch_success or not switch_success_pg or not search_success: + error_message = f"切换到国家({switch_success})/库存页面({switch_success_pg})/搜索筛选到指定选项({search_success}) {country_name} 失败,已重试 {max_retries} 次,跳过该国家" self.log(error_message, "ERROR") if task_id in runing_task: runing_task[task_id]["processed_countries"] += 1 return - # 切换到库存管理页面 - for retry in range(max_retries): - try: - driver.SwitchPage() - self.log(f"已切换到库存管理页面") - except Exception as e: - import traceback - self.log(f"切换页面失败: {str(e)}", "ERROR") - self.log(traceback.format_exc(), "ERROR") - - if retry >= max_retries-1: - if task_id in runing_task: - runing_task[task_id]["processed_countries"] += 1 - self.log(f"切换页面失败重试退出", "ERROR") - return - # 搜索需要审批的商品,最多重试3次 - sku_ls = [] - for retry in range(max_retries): - try: - self.log(f"尝试搜索匹配操作商品 (第 {retry + 1}/{max_retries} 次)") - sku_ls = driver.search(filter_type=risk_listing_filter) - break - except Exception as e: - self.log(f"搜索商品异常: {str(e)}", "ERROR") - if retry < max_retries - 1: - try: - driver.tab.refresh() - time.sleep(3) - except Exception as refresh_error: - self.log(f"刷新页面失败: {str(refresh_error)}", "WARNING") + # sku_ls = [] + # for retry in range(max_retries): + # try: + # self.log(f"尝试搜索匹配操作商品 (第 {retry + 1}/{max_retries} 次)") + # sku_ls = driver.search(filter_type=risk_listing_filter) + # break + # except Exception as e: + # self.log(f"搜索商品异常: {str(e)}", "ERROR") + # if retry < max_retries - 1: + # try: + # driver.tab.refresh() + # driver.tab.wait.doc_loaded() + # time.sleep(3) + # except Exception as refresh_error: + # self.log(f"刷新页面失败: {str(refresh_error)}", "WARNING") + # + # # 如果没有需要审批的商品,直接返回 + # if len(sku_ls) == 0: + # self.log(f"国家 {country_name} 没有搜索出的商品") + # if task_id in runing_task: + # runing_task[task_id]["processed_countries"] += 1 + # return - # 如果没有需要审批的商品,直接返回 - if len(sku_ls) == 0: - self.log(f"国家 {country_name} 没有搜索出的商品") - if task_id in runing_task: - runing_task[task_id]["processed_countries"] += 1 - return - - self.log(f"国家 {country_name} 搜索出 {len(sku_ls)} 商品,开始处理...") + self.log(f"国家 {country_name} ,开始处理...") # 处理所有需要审批的商品(通过yield获取结果) @@ -1350,13 +1362,14 @@ class PriceTask: "firstPlace": status.get("firstPlace") if status.get("firstPlace") else "", "secondPlace": status.get("secondPlace") if status.get("secondPlace") else "", "cartShopName": status.get("cartShopName") if status.get("cartShopName") else "", - "priceChangeStatus": "UPDATED", + # "priceChangeStatus": "UPDATED", "deleteSkipAsin": status.get("deleteSkipAsin",False), "removeAsin": status.get("removeAsin") if status.get("removeAsin") else "", "modifyCount": "1", "shippingFee" : status.get("shippingFee") if status.get("shippingFee") else "", "status": status.get("statu"), "priceChangeStatus" : status.get("priceChangeStatus") if status.get("priceChangeStatus") else "", + "priceChangePrice" : status.get("priceChangePrice") if status.get("priceChangePrice") else "", } ] }, @@ -1405,20 +1418,20 @@ class PriceTask: if __name__ == '__main__': # 使用示例 # user_info = { - # "company": "rongchuang123", + # "company": "尾号5578的公司115", # "username": "自动化_Robot", # "password": "#20zsg25" # } - # shop_name = "郭亚芳" - # country = "德国" + # shop_name = "刘建煌" + # country = "法国" # kill_process('v6') # driver = AmzonePriceMatch(user_info) # browser = driver.open_shop(shop_name) # sw_suc = driver.SwitchingCountries(country) # driver.SwitchPage() # risk_listing_filter = "Active" - # _shopMallName = "yafang123" - # ap_asin ="B0BTHBJDKG" + # _shopMallName = "Jianhuang 888" + # ap_asin ="B0F25Y52PH" # skip_asin = [] # for _ in range(3): # try: @@ -1436,14 +1449,19 @@ if __name__ == '__main__': # print(f"ASIN {asin} 的处理结果: {status}") # print("已完成操作") - front_end_data = {'top_sellers': [{'rank': 1, 'price': '50.24', 'stock': '26', 'shop_name': 'yafang123'}, {'rank': 2, 'price': '44.35', 'stock': '100', 'shop_name': 'QinPimy'}], 'cart_seller': 'Amazon US', 'timestamp': '2026-04-27 16:17:42'} + front_end_data = {'top_sellers': [{'rank': 1, 'price': '52.51', 'stock': '100', 'shop_name': 'Amazon US'}, {'rank': 2, 'price': '62.58', 'stock': '30', 'shop_name': 'Amazon US'}], 'cart_seller': 'Jianhuang 888', 'timestamp': '2026-04-30 09:52:49'} - current_Price = 44.35 - current_shop_name = "yafang123" - recommended_price = 44.35 + current_Price = 52.51 + current_shop_name = "Jianhuang 888" + recommended_price = 52.51 recommended_shipping =0.0 res = calculate_target_price( front_end_data, current_Price, current_shop_name, recommended_price, recommended_shipping ) - print(res) \ No newline at end of file + 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) \ No newline at end of file diff --git a/app/blueprints/main.py b/app/blueprints/main.py index 6881dc0..0ab1d93 100644 --- a/app/blueprints/main.py +++ b/app/blueprints/main.py @@ -195,10 +195,13 @@ def proxy(path): if key.lower() in ['host', 'content-length', 'connection']: continue headers[key] = value - ignore_url = [f"{JAVA_API_BASE}/api/delete-brand/tasks/batch", - f"{JAVA_API_BASE}/api/delete-brand/history", - f"{JAVA_API_BASE}/api/product-risk-resolve/tasks/batch", - ] + ignore_url = [f"{JAVA_API_BASE}/api/delete-brand/tasks/batch", + f"{JAVA_API_BASE}/api/delete-brand/tasks/progress/batch", + f"{JAVA_API_BASE}/api/delete-brand/history", + f"{JAVA_API_BASE}/api/product-risk-resolve/tasks/batch", + f"{JAVA_API_BASE}/api/product-risk-resolve/tasks/progress/batch", + ] + proxy_timeout = 180 if target_url.endswith("/api/delete-brand/run") else 30 if target_url not in ignore_url: try: print("=============================") @@ -219,7 +222,7 @@ def proxy(path): data=request.get_data() if request.get_data() else None, cookies=request.cookies, stream=True, # 启用流式传输 - timeout=30 + timeout=proxy_timeout ) # 流式响应 diff --git a/app/main.py b/app/main.py index cd6403f..148cb5f 100644 --- a/app/main.py +++ b/app/main.py @@ -438,7 +438,7 @@ def main(): window.expose(api.close, api.minimize, api.maximize, api.toggle_maximize, generate_images, api.save_image, api.save_image_to_folder, api.select_folder, api.select_brand_xlsx_files, api.select_brand_folder, api.save_file_from_url, api.save_template_xlsx,api.save_template_zip,api.upload_file_to_java,api.open_external_url,api.enqueue_json,api.get_detail_del,api.save_file_from_url_new) webview.start( - debug=False, + debug=True, storage_path=cache_path, private_mode=False ) diff --git a/backend-java/pom.xml b/backend-java/pom.xml index 2fab57a..0c4fa85 100644 --- a/backend-java/pom.xml +++ b/backend-java/pom.xml @@ -25,8 +25,19 @@ 5.8.36 2.3.5 8.5.17 + 3.28.0-GA + + + + org.javassist + javassist + ${javassist.version} + + + + org.springframework.boot diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/TransientStorageProperties.java b/backend-java/src/main/java/com/nanri/aiimage/config/TransientStorageProperties.java index 8458f3e..e819631 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/TransientStorageProperties.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/TransientStorageProperties.java @@ -12,4 +12,8 @@ public class TransientStorageProperties { private String accessKeyId; private String accessKeySecret; private String region = "us-east-1"; + private int connectTimeoutSeconds = 10; + private int readTimeoutSeconds = 60; + private int writeTimeoutSeconds = 60; + private int uploadMaxRetries = 3; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClient.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClient.java index 12cd99d..bbc92db 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClient.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClient.java @@ -649,7 +649,7 @@ public class AppearancePatentCozeClient { } private String normalize(String value) { - return value == null ? "" : value.replace("\ufeff", "").replace("\u3000", " ").trim(); + return value == null ? "" : value.replace(String.valueOf((char) 0xFEFF), "").replace((char) 0x3000, ' ').trim(); } private String rowKey(AppearancePatentResultRowDto row) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/controller/AppearancePatentController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/controller/AppearancePatentController.java index 8a7157a..0eddd72 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/controller/AppearancePatentController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/controller/AppearancePatentController.java @@ -83,7 +83,10 @@ public class AppearancePatentController { public ApiResponse result( @Parameter(description = "外观专利检测任务 ID。任务必须处于 RUNNING 状态。", required = true, example = "3938") @PathVariable Long taskId, - @Valid @RequestBody AppearancePatentSubmitResultRequest request) { + @Valid @RequestBody AppearancePatentSubmitResultRequest request, + jakarta.servlet.http.HttpServletResponse response) { + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.setContentType("application/json;charset=UTF-8"); service.submitResult(taskId, request); return ApiResponse.success(null); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java index 9d751c2..2b6f85b 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java @@ -121,7 +121,7 @@ public class AppearancePatentTaskService { .filter(Objects::nonNull) .toList(); if (sourceFiles.isEmpty()) { - throw new BusinessException("璇峰厛涓婁紶 Excel 鏂囦欢"); + throw new BusinessException("请先上传 Excel 文件"); } List allRows = new ArrayList<>(); List mergedHeaders = new ArrayList<>(); @@ -461,10 +461,10 @@ public class AppearancePatentTaskService { private SubmitContext persistSubmittedChunk(Long taskId, AppearancePatentSubmitResultRequest request) { FileTaskEntity task = fileTaskMapper.selectById(taskId); if (task == null || !MODULE_TYPE.equals(task.getModuleType())) { - throw new BusinessException("浠诲姟涓嶅瓨鍦?"); + throw new BusinessException("任务不存在"); } if (!STATUS_RUNNING.equals(task.getStatus())) { - throw new BusinessException("浠诲姟涓嶆槸杩愯涓姸鎬?"); + throw new BusinessException("任务不是运行中状态"); } int chunkIndex = request.getChunkIndex() == null ? 0 : request.getChunkIndex(); @@ -482,7 +482,7 @@ public class AppearancePatentTaskService { .last("limit 1")); if (existing == null) { List rawRows = flattenSubmittedRows(request); - String payloadJson = writeJson(rawRows, "缁撴灉搴忓垪鍖栧け璐?"); + String payloadJson = writeJson(rawRows, "结果序列化失败"); TaskChunkEntity chunk = new TaskChunkEntity(); chunk.setTaskId(taskId); @@ -514,7 +514,7 @@ public class AppearancePatentTaskService { private void completeSubmittedChunk(SubmitContext context) { FileTaskEntity task = fileTaskMapper.selectById(context.task().getId()); if (task == null || !MODULE_TYPE.equals(task.getModuleType())) { - throw new BusinessException("浠诲姟涓嶅瓨鍦?"); + throw new BusinessException("任务不存在"); } if (!STATUS_RUNNING.equals(task.getStatus())) { log.info("[appearance-patent] skip completion because task already finalized taskId={} status={}", @@ -1379,7 +1379,7 @@ public class AppearancePatentTaskService { } private String normalize(String val) { - return val == null ? "" : val.replace("\ufeff", "").replace("\u3000", " ").trim().replaceAll("\\s+", " "); + return val == null ? "" : val.replace(String.valueOf((char) 0xFEFF), "").replace((char) 0x3000, ' ').trim().replaceAll("\\s+", " "); } private String buildParsedPayloadJson(String aiPrompt, List sourceFiles, List headers, List groups, List allRows) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/brand/service/BrandTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/brand/service/BrandTaskService.java index 6361298..5354ceb 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/brand/service/BrandTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/brand/service/BrandTaskService.java @@ -881,8 +881,8 @@ public class BrandTaskService { if (value == null) { return ""; } - return value.replace("\ufeff", "") - .replace("\u3000", " ") + return value.replace(String.valueOf((char) 0xFEFF), "") + .replace((char) 0x3000, ' ') .replace("\r\n", " ") .replace("\r", " ") .replace("\n", " ") diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/convert/service/ConvertRunService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/convert/service/ConvertRunService.java index a8161ed..6d2bf4a 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/convert/service/ConvertRunService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/convert/service/ConvertRunService.java @@ -475,8 +475,8 @@ public class ConvertRunService { if (value == null) { return ""; } - return value.replace("\ufeff", "") - .replace("\u3000", " ") + return value.replace(String.valueOf((char) 0xFEFF), "") + .replace((char) 0x3000, ' ') .replace("\r\n", " ") .replace("\r", " ") .replace("\n", " ") diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeRunService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeRunService.java index 804130e..360a1c4 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeRunService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeRunService.java @@ -47,8 +47,8 @@ import java.util.zip.ZipOutputStream; @Slf4j public class DedupeRunService { - private static final String HEADER_SPLIT_MARKER = "idASIN\u56fd\u5bb6\u72b6\u6001\u4ef7\u683c\u53d8\u4f53\u6570\u91cf"; - private static final String HEADER_STOP_COLUMN = "\u7f29\u7565\u56fe\u5730\u57408"; + private static final String HEADER_SPLIT_MARKER = "idASIN国家状态价格变体数量"; + private static final String HEADER_STOP_COLUMN = "缩略图地址8"; private final FileTaskMapper fileTaskMapper; private final FileResultMapper fileResultMapper; @@ -59,7 +59,7 @@ public class DedupeRunService { public DedupeRunVo run(DedupeRunRequest request) { long runStartNs = System.nanoTime(); if (!request.isKeepIntegerIds() && !request.isKeepUnderscoreIds() && !request.isKeepIntegerMainIdsWhenNoSubIds()) { - throw new BusinessException("\u8bf7\u81f3\u5c11\u9009\u62e9\u4e00\u79cd ID \u4fdd\u7559\u89c4\u5219"); + throw new BusinessException("请至少选择一种 ID 保留规则"); } FileTaskEntity task = new FileTaskEntity(); @@ -90,7 +90,7 @@ public class DedupeRunService { File inputFile = findLocalSourceFile(sourceFile.getFileKey()); long findFileNs = elapsedNs(fileStartNs); if (inputFile == null || !inputFile.exists()) { - throw new BusinessException("\u4e0a\u4f20\u6587\u4ef6\u4e0d\u5b58\u5728\uff0c\u8bf7\u91cd\u65b0\u4e0a\u4f20"); + throw new BusinessException("上传文件不存在,请重新上传"); } String inputName = sourceFile.getOriginalFilename() == null ? inputFile.getName() : sourceFile.getOriginalFilename(); @@ -240,7 +240,7 @@ public class DedupeRunService { public void deleteHistory(Long resultId, Long userId) { FileResultEntity entity = fileResultMapper.selectById(resultId); if (entity == null || !"DEDUPE".equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) { - throw new BusinessException("\u8bb0\u5f55\u4e0d\u5b58\u5728"); + throw new BusinessException("记录不存在"); } fileResultMapper.deleteById(resultId); } @@ -248,11 +248,11 @@ public class DedupeRunService { public Resource getResultFile(Long resultId, Long userId) { FileResultEntity entity = fileResultMapper.selectById(resultId); if (entity == null || entity.getResultFileUrl() == null || !"DEDUPE".equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) { - throw new BusinessException("\u7ed3\u679c\u6587\u4ef6\u4e0d\u5b58\u5728"); + throw new BusinessException("结果文件不存在"); } File file = new File(entity.getResultFileUrl()); if (!file.exists()) { - throw new BusinessException("\u7ed3\u679c\u6587\u4ef6\u4e0d\u5b58\u5728"); + throw new BusinessException("结果文件不存在"); } return new FileSystemResource(file); } @@ -342,18 +342,18 @@ public class DedupeRunService { Sheet sheet = workbook.getSheetAt(0); Row headerRow = sheet.getRow(0); if (headerRow == null) { - throw new BusinessException("Excel \u8868\u5934\u4e3a\u7a7a"); + throw new BusinessException("Excel 表头为空"); } Map headerMap = buildHeaderMap(headerRow, formatter); if (headerMap.isEmpty()) { - throw new BusinessException("Excel \u8868\u5934\u4e3a\u7a7a"); + throw new BusinessException("Excel 表头为空"); } List missing = selectedColumns.stream() .filter(column -> !headerMap.containsKey(column)) .toList(); if (!missing.isEmpty()) { - throw new BusinessException("\u7f3a\u5c11\u5217\uff1a" + String.join("\u3001", missing)); + throw new BusinessException("缺少列:" + String.join("、", missing)); } List selectedIndexes = new ArrayList<>(selectedColumns.size()); @@ -478,7 +478,7 @@ public class DedupeRunService { zos.closeEntry(); } } catch (Exception ex) { - throw new BusinessException("\u53bb\u91cd\u7ed3\u679c\u6253\u5305\u5931\u8d25\uff1a" + ex.getMessage()); + throw new BusinessException("去重结果打包失败:" + ex.getMessage()); } return zipFile; } @@ -602,7 +602,7 @@ public class DedupeRunService { boolean previousWhitespace = false; for (int i = start; i < end; i++) { char ch = value.charAt(i); - if (ch == '\uFEFF') { + if (ch == 0xFEFF) { if (builder == null) { builder = new StringBuilder(value.length()); builder.append(value, start, i); @@ -629,11 +629,11 @@ public class DedupeRunService { } private boolean isTrimmedWhitespace(char ch) { - return ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n' || ch == '\u3000' || Character.isWhitespace(ch); + return ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n' || ch == 0x3000 || Character.isWhitespace(ch); } private boolean isNormalizedWhitespace(char ch) { - return ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n' || ch == '\u3000' || Character.isWhitespace(ch); + return ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n' || ch == 0x3000 || Character.isWhitespace(ch); } private record DedupeArchiveEntry(String relativePath, String inputName, File outputFile) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandTaskStorageService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandTaskStorageService.java index 64388ff..40a59cf 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandTaskStorageService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandTaskStorageService.java @@ -240,7 +240,10 @@ public class DeleteBrandTaskStorageService { DeleteBrandResultFileDto dto = objectMapper.readValue(payloadJson, DeleteBrandResultFileDto.class); grouped.computeIfAbsent(entity.getScopeKey(), ignored -> new ArrayList<>()).add(dto); } catch (Exception ex) { - throw new BusinessException("failed to load delete-brand result chunks"); + log.warn("[delete-brand-storage] failed to load result chunk taskId={} scopeKey={} chunkIndex={} payload={}", + taskId, entity.getScopeKey(), entity.getChunkIndex(), entity.getPayloadJson(), ex); + throw new BusinessException("failed to load delete-brand result chunks: taskId=" + + taskId + ", scopeKey=" + entity.getScopeKey() + ", chunkIndex=" + entity.getChunkIndex()); } } for (List chunks : grouped.values()) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/LocalFileStorageService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/LocalFileStorageService.java index 1263a42..aec1237 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/LocalFileStorageService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/LocalFileStorageService.java @@ -109,8 +109,8 @@ public class LocalFileStorageService { if (value == null) { return ""; } - return value.replace("\ufeff", "") - .replace("\u3000", " ") + return value.replace(String.valueOf((char) 0xFEFF), "") + .replace((char) 0x3000, ' ') .replace("\r\n", " ") .replace("\r", " ") .replace("\n", " ") diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/object/RustfsObjectStorageService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/object/RustfsObjectStorageService.java index b2a9f07..8940235 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/object/RustfsObjectStorageService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/object/RustfsObjectStorageService.java @@ -5,18 +5,24 @@ import io.minio.GetObjectArgs; import io.minio.MinioClient; import io.minio.PutObjectArgs; import io.minio.RemoveObjectArgs; +import io.minio.StatObjectArgs; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import okhttp3.OkHttpClient; import org.springframework.stereotype.Service; import java.io.ByteArrayInputStream; import java.nio.charset.StandardCharsets; import java.util.Objects; +import java.util.concurrent.TimeUnit; @Service @RequiredArgsConstructor +@Slf4j public class RustfsObjectStorageService { private final TransientStorageProperties properties; + private volatile OkHttpClient httpClient; public boolean isConfigured() { return notBlank(properties.getEndpoint()) @@ -29,18 +35,28 @@ public class RustfsObjectStorageService { if (!isConfigured()) { throw new IllegalStateException("transient storage is not configured"); } - try (ByteArrayInputStream stream = new ByteArrayInputStream( - Objects.requireNonNullElse(content, "").getBytes(StandardCharsets.UTF_8))) { - buildClient().putObject(PutObjectArgs.builder() - .bucket(properties.getBucket()) - .object(objectKey) - .stream(stream, stream.available(), -1) - .contentType("application/json") - .build()); - return objectKey; - } catch (Exception ex) { - throw new IllegalStateException("failed to upload payload to transient storage", ex); + byte[] bytes = Objects.requireNonNullElse(content, "").getBytes(StandardCharsets.UTF_8); + Exception last = null; + int maxRetries = Math.max(1, properties.getUploadMaxRetries()); + for (int attempt = 1; attempt <= maxRetries; attempt++) { + try (ByteArrayInputStream stream = new ByteArrayInputStream(bytes)) { + buildClient().putObject(PutObjectArgs.builder() + .bucket(properties.getBucket()) + .object(objectKey) + .stream(stream, bytes.length, -1) + .contentType("application/json") + .build()); + verifyObjectVisible(objectKey); + return objectKey; + } catch (Exception ex) { + last = ex; + if (attempt < maxRetries) { + log.warn("[rustfs] upload failed, retrying objectKey={} attempt={}/{}", objectKey, attempt, maxRetries, ex); + sleepQuietly(500L * attempt); + } + } } + throw new IllegalStateException("failed to upload payload to transient storage", last); } public String readObjectAsString(String objectKey) { @@ -76,9 +92,57 @@ public class RustfsObjectStorageService { .endpoint(properties.getEndpoint()) .credentials(properties.getAccessKeyId(), properties.getAccessKeySecret()) .region(properties.getRegion()) + .httpClient(getHttpClient()) .build(); } + private OkHttpClient getHttpClient() { + OkHttpClient current = httpClient; + if (current != null) { + return current; + } + synchronized (this) { + if (httpClient == null) { + httpClient = new OkHttpClient.Builder() + .connectTimeout(Math.max(1, properties.getConnectTimeoutSeconds()), TimeUnit.SECONDS) + .readTimeout(Math.max(1, properties.getReadTimeoutSeconds()), TimeUnit.SECONDS) + .writeTimeout(Math.max(1, properties.getWriteTimeoutSeconds()), TimeUnit.SECONDS) + .build(); + } + return httpClient; + } + } + + private void verifyObjectVisible(String objectKey) { + RuntimeException last = null; + for (int attempt = 1; attempt <= 3; attempt++) { + try { + buildClient().statObject(StatObjectArgs.builder() + .bucket(properties.getBucket()) + .object(objectKey) + .build()); + return; + } catch (Exception ex) { + last = new IllegalStateException("transient payload is not visible after upload: " + objectKey, ex); + if (attempt < 3) { + sleepQuietly(100L * attempt); + } + } + } + throw last == null + ? new IllegalStateException("transient payload is not visible after upload: " + objectKey) + : last; + } + + private void sleepQuietly(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("interrupted while verifying transient payload upload", ex); + } + } + private boolean notBlank(String value) { return value != null && !value.isBlank(); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/service/PatrolDeleteTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/service/PatrolDeleteTaskService.java index 21298b0..8f6d54d 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/service/PatrolDeleteTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/service/PatrolDeleteTaskService.java @@ -281,7 +281,6 @@ public class PatrolDeleteTaskService { return vo; } - @Transactional public void submitResult(Long taskId, PatrolDeleteSubmitResultRequest request) { if (taskId == null || taskId <= 0) { throw new BusinessException("taskId 不合法"); @@ -334,7 +333,6 @@ public class PatrolDeleteTaskService { tryFinalizeTask(taskId, false); } - @Transactional public boolean tryFinalizeTask(Long taskId, boolean fromCompensation) { if (taskId == null || taskId <= 0) { return false; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackSubmitResultRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackSubmitResultRequest.java index e0aeff8..f3810a5 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackSubmitResultRequest.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackSubmitResultRequest.java @@ -1,5 +1,6 @@ package com.nanri.aiimage.modules.pricetrack.model.dto; +import com.fasterxml.jackson.annotation.JsonAlias; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @@ -88,6 +89,10 @@ public class PriceTrackSubmitResultRequest { @Schema(description = "购物车店铺名", example = "店铺A") private String cartShopName; + @JsonAlias({"changedPrice", "changePrice", "modifiedPrice", "newPrice", "targetPrice", "updatedPrice", "改价价格"}) + @Schema(description = "改价价格", example = "18.99") + private String priceChangePrice; + @Schema(description = "改价情况", example = "UPDATED") private String priceChangeStatus; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackExcelAssemblyService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackExcelAssemblyService.java index 6d313a1..c042541 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackExcelAssemblyService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackExcelAssemblyService.java @@ -29,6 +29,7 @@ public class PriceTrackExcelAssemblyService { "第一名", "第二名", "购物车店铺名", + "改价价格", "改价情况", "修改次数", "状态" @@ -40,13 +41,14 @@ public class PriceTrackExcelAssemblyService { workbook.setCompressTempFiles(true); try (FileOutputStream fos = new FileOutputStream(outputXlsx)) { for (ProductRiskCountryCode code : ProductRiskCountryCode.values()) { - String sheetName = code.name(); + String countryCode = code.name(); + String sheetName = sheetNameOf(code); Sheet sheet = workbook.createSheet(sheetName); Row headerRow = sheet.createRow(0); for (int i = 0; i < RESULT_HEADER.length; i++) { headerRow.createCell(i).setCellValue(RESULT_HEADER[i]); } - List rows = safe.getOrDefault(sheetName, List.of()); + List rows = safe.getOrDefault(countryCode, List.of()); int rowIdx = 1; for (PriceTrackSubmitResultRequest.AsinResult item : rows) { if (item == null) { @@ -62,9 +64,10 @@ public class PriceTrackExcelAssemblyService { row.createCell(6).setCellValue(valueOf(item.getFirstPlace())); row.createCell(7).setCellValue(valueOf(item.getSecondPlace())); row.createCell(8).setCellValue(valueOf(item.getCartShopName())); - row.createCell(9).setCellValue(valueOf(item.getPriceChangeStatus())); - row.createCell(10).setCellValue(valueOf(item.getModifyCount())); - row.createCell(11).setCellValue(valueOf(item.getStatus())); + row.createCell(9).setCellValue(valueOf(item.getPriceChangePrice())); + row.createCell(10).setCellValue(valueOf(item.getPriceChangeStatus())); + row.createCell(11).setCellValue(valueOf(item.getModifyCount())); + row.createCell(12).setCellValue(valueOf(item.getStatus())); } applyDefaultColumnWidths(sheet); } @@ -113,8 +116,18 @@ public class PriceTrackExcelAssemblyService { return value == null ? "" : value; } + private static String sheetNameOf(ProductRiskCountryCode code) { + return switch (code) { + case DE -> "德国"; + case FR -> "法国"; + case ES -> "西班牙"; + case IT -> "意大利"; + case UK -> "英国"; + }; + } + private void applyDefaultColumnWidths(Sheet sheet) { - int[] widths = {22, 18, 12, 12, 12, 12, 20, 20, 22, 18, 12, 12}; + int[] widths = {22, 18, 12, 12, 12, 12, 20, 20, 22, 14, 18, 12, 12}; for (int i = 0; i < widths.length; i++) { sheet.setColumnWidth(i, widths[i] * 256); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java index e9ece5c..34043cb 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java @@ -127,6 +127,21 @@ public class PriceTrackTaskService { return tasks; } + private List selectTaskStatusesByIdsInBatches(List taskIds) { + List 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() + .select(FileTaskEntity::getId, FileTaskEntity::getStatus) + .in(FileTaskEntity::getId, taskIds.subList(start, end)))); + } + return tasks; + } + public PriceTrackDashboardVo dashboard(Long userId) { if (userId == null || userId <= 0) throw new BusinessException("user_id 不合法"); PriceTrackDashboardVo vo = new PriceTrackDashboardVo(); @@ -166,7 +181,7 @@ public class PriceTrackTaskService { .distinct() .toList(); if (!taskIds.isEmpty()) { - List tasks = selectTasksByIdsInBatches(taskIds); + List tasks = selectTaskStatusesByIdsInBatches(taskIds); for (FileTaskEntity t : tasks) { if (t != null) statusByTaskId.put(t.getId(), t.getStatus()); } @@ -383,7 +398,6 @@ public class PriceTrackTaskService { return vo; } - @Transactional public void submitResult(Long taskId, PriceTrackSubmitResultRequest request) { if (taskId == null || taskId <= 0) throw new BusinessException("taskId 不合法"); if (request == null || request.getShops() == null || request.getShops().isEmpty()) { @@ -458,7 +472,6 @@ public class PriceTrackTaskService { updateTaskStatusFromLatestRows(task, latest); } - @Transactional public boolean tryFinalizeTask(Long taskId, boolean fromCompensation) { if (taskId == null || taskId <= 0) return false; FileTaskEntity task = loadTaskForExecution(taskId); @@ -1139,6 +1152,7 @@ public class PriceTrackTaskService { merged.setFirstPlace(firstNonBlank(incoming.getFirstPlace(), merged.getFirstPlace())); merged.setSecondPlace(firstNonBlank(incoming.getSecondPlace(), merged.getSecondPlace())); merged.setCartShopName(firstNonBlank(incoming.getCartShopName(), merged.getCartShopName())); + merged.setPriceChangePrice(firstNonBlank(incoming.getPriceChangePrice(), merged.getPriceChangePrice())); merged.setPriceChangeStatus(firstNonBlank(incoming.getPriceChangeStatus(), merged.getPriceChangeStatus())); merged.setModifyCount(firstNonBlank(incoming.getModifyCount(), merged.getModifyCount())); merged.setStatus(firstNonBlank(incoming.getStatus(), merged.getStatus())); @@ -1163,6 +1177,7 @@ public class PriceTrackTaskService { out.setFirstPlace(row.getFirstPlace()); out.setSecondPlace(row.getSecondPlace()); out.setCartShopName(row.getCartShopName()); + out.setPriceChangePrice(row.getPriceChangePrice()); out.setPriceChangeStatus(row.getPriceChangeStatus()); out.setModifyCount(row.getModifyCount()); out.setStatus(row.getStatus()); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskTaskService.java index 1744916..d0e55c8 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskTaskService.java @@ -124,6 +124,21 @@ public class ProductRiskTaskService { return tasks; } + private List selectTaskStatusesByIdsInBatches(List taskIds) { + List 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() + .select(FileTaskEntity::getId, FileTaskEntity::getStatus) + .in(FileTaskEntity::getId, taskIds.subList(start, end)))); + } + return tasks; + } + public ProductRiskDashboardVo dashboard(Long userId) { if (userId == null || userId <= 0) { throw new BusinessException("user_id 不合法"); @@ -166,7 +181,7 @@ public class ProductRiskTaskService { .distinct() .toList(); if (!taskIds.isEmpty()) { - List tasks = selectTasksByIdsInBatches(taskIds); + List tasks = selectTaskStatusesByIdsInBatches(taskIds); for (FileTaskEntity t : tasks) { if (t != null) { statusByTaskId.put(t.getId(), t.getStatus()); @@ -465,7 +480,6 @@ public class ProductRiskTaskService { return vo; } - @Transactional public void submitResult(Long taskId, ProductRiskSubmitResultRequest request) { if (taskId == null || taskId <= 0) { throw new BusinessException("taskId 不合法"); @@ -630,7 +644,6 @@ public class ProductRiskTaskService { cleanupTaskCacheIfTerminal(taskId, task.getStatus()); } - @Transactional public boolean tryFinalizeTask(Long taskId, boolean fromCompensation) { if (taskId == null || taskId <= 0) { return false; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/service/QueryAsinTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/service/QueryAsinTaskService.java index b266054..35c74a4 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/service/QueryAsinTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/service/QueryAsinTaskService.java @@ -287,7 +287,6 @@ public class QueryAsinTaskService { return vo; } - @Transactional public void submitResult(Long taskId, QueryAsinSubmitResultRequest request) { if (taskId == null || taskId <= 0) { throw new BusinessException("taskId 不合法"); @@ -340,7 +339,6 @@ public class QueryAsinTaskService { tryFinalizeTask(taskId, false); } - @Transactional public boolean tryFinalizeTask(Long taskId, boolean fromCompensation) { if (taskId == null || taskId <= 0) { return false; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/SkipPriceAsinController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/SkipPriceAsinController.java index 8b9e088..65044b4 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/SkipPriceAsinController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/SkipPriceAsinController.java @@ -3,6 +3,8 @@ package com.nanri.aiimage.modules.shopkey.controller; import com.nanri.aiimage.common.api.ApiResponse; import com.nanri.aiimage.modules.shopkey.model.dto.SkipPriceAsinCountryUpdateRequest; import com.nanri.aiimage.modules.shopkey.model.dto.SkipPriceAsinCreateRequest; +import com.nanri.aiimage.modules.shopkey.model.vo.QueryAsinImportProgressVo; +import com.nanri.aiimage.modules.shopkey.model.vo.QueryAsinImportStartVo; import com.nanri.aiimage.modules.shopkey.model.vo.SkipPriceAsinItemVo; import com.nanri.aiimage.modules.shopkey.model.vo.SkipPriceAsinPageVo; import com.nanri.aiimage.modules.shopkey.service.SkipPriceAsinService; @@ -20,6 +22,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; @RestController @RequiredArgsConstructor @@ -59,6 +62,40 @@ public class SkipPriceAsinController { skipPriceAsinService.createOrUpdate(request, operatorId, Boolean.TRUE.equals(superAdmin))); } + @PostMapping("/import") + @Operation(summary = "导入新增跳过跟价 ASIN") + public ApiResponse 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 importProgress(@PathVariable String importId) { + return ApiResponse.success(skipPriceAsinService.getImportProgress(importId)); + } + + @PostMapping("/delete-import") + @Operation(summary = "导入删除跳过跟价 ASIN") + public ApiResponse 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 deleteImportProgress(@PathVariable String importId) { + return ApiResponse.success(skipPriceAsinService.getDeleteImportProgress(importId)); + } + @PutMapping("/{id}/countries/{country}") @Operation(summary = "编辑指定国家的跳过跟价 ASIN") public ApiResponse updateCountry( diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/mapper/ShopManageGroupMapper.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/mapper/ShopManageGroupMapper.java index fb44b6c..39858ea 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/mapper/ShopManageGroupMapper.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/mapper/ShopManageGroupMapper.java @@ -3,7 +3,68 @@ package com.nanri.aiimage.modules.shopkey.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageGroupEntity; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; @Mapper public interface ShopManageGroupMapper extends BaseMapper { + + @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 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 selectAccessibleCreatorUserIds(@Param("operatorId") Long operatorId); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/entity/SkipPriceAsinEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/entity/SkipPriceAsinEntity.java index 874cb71..cc2e5aa 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/entity/SkipPriceAsinEntity.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/entity/SkipPriceAsinEntity.java @@ -1,6 +1,7 @@ package com.nanri.aiimage.modules.shopkey.model.entity; import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.FieldStrategy; import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; @@ -25,31 +26,31 @@ public class SkipPriceAsinEntity { @TableField("asin_de") private String asinDe; - @TableField("minimum_price_de") + @TableField(value = "minimum_price_de", updateStrategy = FieldStrategy.ALWAYS) private BigDecimal minimumPriceDe; @TableField("asin_uk") private String asinUk; - @TableField("minimum_price_uk") + @TableField(value = "minimum_price_uk", updateStrategy = FieldStrategy.ALWAYS) private BigDecimal minimumPriceUk; @TableField("asin_fr") private String asinFr; - @TableField("minimum_price_fr") + @TableField(value = "minimum_price_fr", updateStrategy = FieldStrategy.ALWAYS) private BigDecimal minimumPriceFr; @TableField("asin_it") private String asinIt; - @TableField("minimum_price_it") + @TableField(value = "minimum_price_it", updateStrategy = FieldStrategy.ALWAYS) private BigDecimal minimumPriceIt; @TableField("asin_es") private String asinEs; - @TableField("minimum_price_es") + @TableField(value = "minimum_price_es", updateStrategy = FieldStrategy.ALWAYS) private BigDecimal minimumPriceEs; @TableField("created_at") diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/QueryAsinService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/QueryAsinService.java index 2e89945..8517d93 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/QueryAsinService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/QueryAsinService.java @@ -40,7 +40,7 @@ public class QueryAsinService { private static final List SUPPORTED_COUNTRIES = List.of("DE", "UK", "FR", "IT", "ES"); private static final String KEY_SEPARATOR = Character.toString((char) 1); - private static final String UTF8_BOM = Character.toString(0xFEFF); + private static final String UTF8_BOM = String.valueOf((char) 0xFEFF); private final QueryAsinMapper queryAsinMapper; private final ShopManageGroupService shopManageGroupService; @@ -79,7 +79,7 @@ public class QueryAsinService { List rows = queryAsinMapper .selectList(query.last("LIMIT " + ((safePage - 1) * safePageSize) + ", " + safePageSize)); - Map groupNameById = buildGroupNameMap(rows.stream() + Map groupNameById = shopManageGroupService.buildGroupNameMap(rows.stream() .map(QueryAsinEntity::getGroupId) .filter(id -> id != null && id > 0) .distinct() @@ -316,22 +316,6 @@ public class QueryAsinService { return vo; } - private Map buildGroupNameMap(List groupIds) { - if (groupIds == null || groupIds.isEmpty()) { - return Map.of(); - } - LinkedHashMap 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 normalizeCountries(List countries) { LinkedHashSet normalized = new LinkedHashSet<>(); if (countries != null) { @@ -384,11 +368,11 @@ public class QueryAsinService { return upper; } return switch (normalizeHeaderKey(normalized)) { - case "\u5fb7\u56fd", "\u5fb7", "de", "germany", "german", "deutschland" -> "DE"; - case "\u82f1\u56fd", "\u82f1", "uk", "gb", "greatbritain", "britain", "unitedkingdom" -> "UK"; - case "\u6cd5\u56fd", "\u6cd5", "fr", "france", "french" -> "FR"; - case "\u610f\u5927\u5229", "\u610f", "it", "italy", "italian" -> "IT"; - case "\u897f\u73ed\u7259", "\u897f", "es", "spain", "spanish", "espana" -> "ES"; + case "德国", "德", "de", "germany", "german", "deutschland" -> "DE"; + case "英国", "英", "uk", "gb", "greatbritain", "britain", "unitedkingdom" -> "UK"; + case "法国", "法", "fr", "france", "french" -> "FR"; + case "意大利", "意", "it", "italy", "italian" -> "IT"; + case "西班牙", "西", "es", "spain", "spanish", "espana" -> "ES"; default -> ""; }; } @@ -860,11 +844,11 @@ public class QueryAsinService { if (index != null) { return index; } - index = firstHeader("groupname", "\u5206\u7ec4id", "\u5206\u7ec4"); + index = firstHeader("groupname", "分组id", "分组"); if (index != null) { return index; } - return findHeaderByPredicate(key -> key.contains("\u5206\u7ec4") || key.contains("group")); + return findHeaderByPredicate(key -> key.contains("分组") || key.contains("group")); } private Integer resolveShopHeaderIndex() { @@ -873,13 +857,13 @@ public class QueryAsinService { return index; } index = firstHeader( - "\u5e97\u94fa\u540d", "\u5e97\u94fa\u540d\u79f0", "\u5e97\u94fa\u8d26\u53f7", "\u8d26\u53f7", + "店铺名", "店铺名称", "店铺账号", "账号", "account", "selleraccount", "storeaccount", "storename"); if (index != null) { return index; } return findHeaderByPredicate(key -> - (key.contains("\u5e97\u94fa") && (key.contains("\u540d") || key.contains("\u8d26\u53f7"))) + (key.contains("店铺") && (key.contains("名") || key.contains("账号"))) || (key.contains("shop") && (key.contains("name") || key.contains("account"))) || key.contains("selleraccount") || key.contains("storeaccount")); @@ -891,32 +875,32 @@ public class QueryAsinService { return index; } return switch (country) { - case "DE" -> firstHeader("\u5fb7\u56fd", "\u5fb7", "\u5fb7\u56fdasin"); - case "UK" -> firstHeader("\u82f1\u56fd", "\u82f1", "\u82f1\u56fdasin"); - case "FR" -> firstHeader("\u6cd5\u56fd", "\u6cd5", "\u6cd5\u56fdasin"); - case "IT" -> firstHeader("\u610f\u5927\u5229", "\u610f", "\u610f\u5927\u5229asin"); - case "ES" -> firstHeader("\u897f\u73ed\u7259", "\u897f", "\u897f\u73ed\u7259asin"); + case "DE" -> firstHeader("德国", "德", "德国asin"); + case "UK" -> firstHeader("英国", "英", "英国asin"); + case "FR" -> firstHeader("法国", "法", "法国asin"); + case "IT" -> firstHeader("意大利", "意", "意大利asin"); + case "ES" -> firstHeader("西班牙", "西", "西班牙asin"); default -> null; }; } private Integer findSingleCountryIndex() { Integer index = firstHeader( - "\u4e2d\u6587\u56fd\u5bb6\u540d", "\u56fd\u5bb6", "\u56fd\u5bb6\u540d", "\u56fd\u5bb6\u7ad9\u70b9", - "\u7ad9\u70b9", "country", "countryname", "marketplace", "site"); + "中文国家名", "国家", "国家名", "国家站点", + "站点", "country", "countryname", "marketplace", "site"); if (index != null) { return index; } return findHeaderByPredicate(key -> - key.contains("\u56fd\u5bb6") - || key.contains("\u7ad9\u70b9") + key.contains("国家") + || key.contains("站点") || key.contains("country") || key.contains("marketplace") || key.equals("site")); } private Integer findSingleAsinIndex() { - return firstHeader("asin", "\u67e5\u8be2asin", "\u4ea7\u54c1asin", "\u94fe\u63a5asin"); + return firstHeader("asin", "查询asin", "产品asin", "链接asin"); } private boolean hasWideCountryColumns() { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/ShopManageGroupService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/ShopManageGroupService.java index 204beea..73a8471 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/ShopManageGroupService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/ShopManageGroupService.java @@ -48,6 +48,7 @@ public class ShopManageGroupService { public List list(Long createdById) { return toVoList(groupMapper.selectList(new LambdaQueryWrapper() .eq(createdById != null && createdById > 0, ShopManageGroupEntity::getCreatedById, createdById) + .or(createdById != null && createdById > 0, wrapper -> wrapper.eq(ShopManageGroupEntity::getUserId, createdById)) .orderByAsc(ShopManageGroupEntity::getId))); } @@ -74,40 +75,44 @@ public class ShopManageGroupService { .collect(Collectors.toCollection(LinkedHashSet::new)); } Long normalizedOperatorId = normalizePositiveId(operatorId, "操作人不能为空"); - LinkedHashSet groupIds = groupMapper.selectList(new LambdaQueryWrapper() - .select(ShopManageGroupEntity::getId) - .eq(ShopManageGroupEntity::getCreatedById, normalizedOperatorId)) + return groupMapper.selectAccessibleGroupIds(normalizedOperatorId) .stream() - .map(ShopManageGroupEntity::getId) .filter(id -> id != null && id > 0) .collect(Collectors.toCollection(LinkedHashSet::new)); - List memberGroupIds = groupMemberMapper.selectList(new LambdaQueryWrapper() - .select(ShopManageGroupMemberEntity::getGroupId) - .eq(ShopManageGroupMemberEntity::getUserId, normalizedOperatorId)) - .stream() - .map(ShopManageGroupMemberEntity::getGroupId) - .filter(id -> id != null && id > 0) - .toList(); - groupIds.addAll(memberGroupIds); - if (!memberGroupIds.isEmpty()) { - LinkedHashSet leaderIds = groupMapper.selectList(new LambdaQueryWrapper() - .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() - .select(ShopManageGroupEntity::getId) - .in(ShopManageGroupEntity::getCreatedById, leaderIds)) - .stream() - .map(ShopManageGroupEntity::getId) - .filter(id -> id != null && id > 0) - .toList()); - } + } + + public Set listAccessibleCreatorUserIds(Long operatorId, boolean superAdmin) { + if (superAdmin) { + return Set.of(); } - return groupIds; + Long normalizedOperatorId = normalizePositiveId(operatorId, "操作人不能为空"); + return groupMapper.selectAccessibleCreatorUserIds(normalizedOperatorId) + .stream() + .filter(id -> id != null && id > 0) + .collect(Collectors.toCollection(LinkedHashSet::new)); + } + + public Map buildGroupNameMap(List groupIds) { + if (groupIds == null || groupIds.isEmpty()) { + return Map.of(); + } + List normalizedGroupIds = groupIds.stream() + .filter(id -> id != null && id > 0) + .distinct() + .toList(); + if (normalizedGroupIds.isEmpty()) { + return Map.of(); + } + return groupMapper.selectList(new LambdaQueryWrapper() + .select(ShopManageGroupEntity::getId, ShopManageGroupEntity::getGroupName) + .in(ShopManageGroupEntity::getId, normalizedGroupIds)) + .stream() + .filter(group -> group.getId() != null) + .collect(Collectors.toMap( + ShopManageGroupEntity::getId, + group -> normalizeBlank(group.getGroupName()), + (left, right) -> left, + LinkedHashMap::new)); } @Transactional @@ -207,8 +212,9 @@ public class ShopManageGroupService { return; } Long normalizedOperatorId = normalizePositiveId(operatorId, "操作人不能为空"); - boolean createdBySelf = entity.getCreatedById() != null && entity.getCreatedById().equals(normalizedOperatorId); - if (!createdBySelf) { + boolean leaderBySelf = entity.getCreatedById() != null && entity.getCreatedById().equals(normalizedOperatorId); + boolean legacyLeaderBySelf = entity.getUserId() != null && entity.getUserId().equals(normalizedOperatorId); + if (!leaderBySelf && !legacyLeaderBySelf) { throw new BusinessException("只有组长才能维护分组"); } } @@ -332,8 +338,9 @@ public class ShopManageGroupService { LinkedHashSet userIds = new LinkedHashSet<>(); for (ShopManageGroupEntity group : groups) { - if (group.getCreatedById() != null && group.getCreatedById() > 0) { - userIds.add(group.getCreatedById()); + Long leaderUserId = resolveGroupLeaderUserId(group); + if (leaderUserId != null && leaderUserId > 0) { + userIds.add(leaderUserId); } } for (List members : membersByGroupId.values()) { @@ -355,8 +362,9 @@ public class ShopManageGroupService { ShopManageGroupItemVo vo = new ShopManageGroupItemVo(); vo.setId(entity.getId()); vo.setGroupName(entity.getGroupName()); - vo.setLeaderUserId(entity.getCreatedById()); - AdminUserEntity leaderUser = entity.getCreatedById() == null ? null : userById.get(entity.getCreatedById()); + Long leaderUserId = resolveGroupLeaderUserId(entity); + vo.setLeaderUserId(leaderUserId); + AdminUserEntity leaderUser = leaderUserId == null ? null : userById.get(leaderUserId); vo.setLeaderUsername(leaderUser == null ? "" : normalizeBlank(leaderUser.getUsername())); List memberEntities = membersByGroupId.getOrDefault(entity.getId(), Collections.emptyList()); @@ -380,4 +388,14 @@ public class ShopManageGroupService { } return result; } + + private Long resolveGroupLeaderUserId(ShopManageGroupEntity entity) { + if (entity == null) { + return null; + } + if (entity.getCreatedById() != null && entity.getCreatedById() > 0) { + return entity.getCreatedById(); + } + return entity.getUserId(); + } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/ShopManageService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/ShopManageService.java index c994cde..5ab2b6f 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/ShopManageService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/ShopManageService.java @@ -15,7 +15,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; @@ -33,6 +32,7 @@ public class ShopManageService { long safePageSize = Math.min(Math.max(pageSize, 1), 100); String safeShopName = shopName == null ? "" : shopName.trim(); Set accessibleGroupIds = superAdmin ? Set.of() : shopManageGroupService.listAccessibleGroupIds(operatorId, false); + Set accessibleCreatorUserIds = superAdmin ? Set.of() : shopManageGroupService.listAccessibleCreatorUserIds(operatorId, false); LambdaQueryWrapper query = new LambdaQueryWrapper() .eq(groupId != null && groupId > 0, ShopManageEntity::getGroupId, groupId) @@ -40,20 +40,30 @@ public class ShopManageService { .orderByDesc(ShopManageEntity::getId); if (!superAdmin) { - Long normalizedOperatorId = normalizePositiveId(operatorId, "操作人不能为空"); - query.and(wrapper -> { - wrapper.eq(ShopManageEntity::getCreatedById, normalizedOperatorId); - if (!accessibleGroupIds.isEmpty()) { - wrapper.or().in(ShopManageEntity::getGroupId, accessibleGroupIds); - } - }); + normalizePositiveId(operatorId, "操作人不能为空"); + if (accessibleGroupIds.isEmpty() && accessibleCreatorUserIds.isEmpty()) { + query.eq(ShopManageEntity::getId, -1L); + } else { + query.and(wrapper -> { + boolean hasCreatorFilter = !accessibleCreatorUserIds.isEmpty(); + if (hasCreatorFilter) { + wrapper.in(ShopManageEntity::getCreatedById, accessibleCreatorUserIds); + } + if (!accessibleGroupIds.isEmpty()) { + if (hasCreatorFilter) { + wrapper.or(); + } + wrapper.in(ShopManageEntity::getGroupId, accessibleGroupIds); + } + }); + } } Long total = shopManageMapper.selectCount(query); List rows = shopManageMapper .selectList(query.last("LIMIT " + ((safePage - 1) * safePageSize) + ", " + safePageSize)); - Map groupNameById = buildGroupNameMap(rows.stream() + Map groupNameById = shopManageGroupService.buildGroupNameMap(rows.stream() .map(ShopManageEntity::getGroupId) .filter(id -> id != null && id > 0) .distinct() @@ -190,22 +200,6 @@ public class ShopManageService { } } - private Map buildGroupNameMap(List groupIds) { - if (groupIds == null || groupIds.isEmpty()) { - return Map.of(); - } - LinkedHashMap map = new LinkedHashMap<>(); - for (Long groupId : groupIds) { - try { - ShopManageGroupEntity group = shopManageGroupService.getById(groupId); - map.put(groupId, group.getGroupName()); - } catch (Exception ignored) { - map.put(groupId, ""); - } - } - return map; - } - private ShopManageItemVo toItemVo(ShopManageEntity entity, String groupName) { ShopManageItemVo vo = new ShopManageItemVo(); vo.setId(entity.getId()); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/SkipPriceAsinService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/SkipPriceAsinService.java index db38556..11c3bde 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/SkipPriceAsinService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/SkipPriceAsinService.java @@ -1,17 +1,33 @@ package com.nanri.aiimage.modules.shopkey.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import cn.hutool.core.util.IdUtil; import com.nanri.aiimage.common.exception.BusinessException; import com.nanri.aiimage.modules.shopkey.mapper.SkipPriceAsinMapper; +import com.nanri.aiimage.modules.shopkey.mapper.ShopManageMapper; import com.nanri.aiimage.modules.shopkey.model.dto.SkipPriceAsinCreateRequest; +import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageEntity; import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageGroupEntity; import com.nanri.aiimage.modules.shopkey.model.entity.SkipPriceAsinEntity; +import com.nanri.aiimage.modules.shopkey.model.vo.QueryAsinImportProgressVo; +import com.nanri.aiimage.modules.shopkey.model.vo.QueryAsinImportStartVo; import com.nanri.aiimage.modules.shopkey.model.vo.SkipPriceAsinItemVo; import com.nanri.aiimage.modules.shopkey.model.vo.SkipPriceAsinPageVo; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.DataFormatter; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.usermodel.WorkbookFactory; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; +import java.io.File; +import java.io.FileInputStream; +import java.nio.file.Files; import java.math.BigDecimal; import java.math.RoundingMode; import java.time.LocalDateTime; @@ -21,15 +37,20 @@ import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; @Service @RequiredArgsConstructor +@Slf4j public class SkipPriceAsinService { private static final List SUPPORTED_COUNTRIES = List.of("DE", "UK", "FR", "IT", "ES"); private final SkipPriceAsinMapper skipPriceAsinMapper; + private final ShopManageMapper shopManageMapper; private final ShopManageGroupService shopManageGroupService; + private final Map importProgressMap = new ConcurrentHashMap<>(); + private final Map deleteImportProgressMap = new ConcurrentHashMap<>(); public SkipPriceAsinPageVo page(long page, long pageSize, Long groupId, String shopName, String asin, Long operatorId, boolean superAdmin) { @@ -62,7 +83,7 @@ public class SkipPriceAsinService { List rows = skipPriceAsinMapper .selectList(query.last("LIMIT " + ((safePage - 1) * safePageSize) + ", " + safePageSize)); - Map groupNameById = buildGroupNameMap(rows.stream() + Map groupNameById = shopManageGroupService.buildGroupNameMap(rows.stream() .map(SkipPriceAsinEntity::getGroupId) .filter(id -> id != null && id > 0) .distinct() @@ -141,6 +162,486 @@ public class SkipPriceAsinService { return toItemVo(saved, groupName); } + public QueryAsinImportStartVo startImport(MultipartFile file, Long groupId, Long operatorId, boolean superAdmin) { + return startImportTask(file, groupId, operatorId, superAdmin, false); + } + + public QueryAsinImportProgressVo getImportProgress(String importId) { + QueryAsinImportProgressVo progress = importProgressMap.get(importId); + if (progress == null) { + throw new BusinessException("导入任务不存在"); + } + return progress; + } + + public QueryAsinImportStartVo startDeleteImport(MultipartFile file, Long groupId, Long operatorId, boolean superAdmin) { + return startImportTask(file, groupId, operatorId, superAdmin, true); + } + + public QueryAsinImportProgressVo getDeleteImportProgress(String importId) { + QueryAsinImportProgressVo progress = deleteImportProgressMap.get(importId); + if (progress == null) { + throw new BusinessException("删除任务不存在"); + } + return progress; + } + + private QueryAsinImportStartVo startImportTask(MultipartFile file, Long groupId, Long operatorId, + boolean superAdmin, boolean deleteMode) { + if (file == null || file.isEmpty()) { + throw new BusinessException("请上传 xlsx 或 xls 文件"); + } + validateExcelFilename(file.getOriginalFilename()); + if (groupId == null || groupId <= 0) { + throw new BusinessException("请先选择分组"); + } + shopManageGroupService.getAccessibleById(groupId, operatorId, superAdmin); + String shopName = normalizeShopNameFromFilename(file.getOriginalFilename()); + ensureShopExistsInGroup(groupId, shopName); + + String importId = IdUtil.fastSimpleUUID(); + QueryAsinImportProgressVo progress = newImportProgress(); + Map 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 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 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 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 query = new LambdaQueryWrapper() + .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 asinColumns = new LinkedHashMap<>(); + Map 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() + .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 asinColumns, + Map priceColumns, + int firstDataRowIndex) { + } + + private static final class ImportCounters { + private int asinCount; + private int insertedCount; + private int deletedCount; + private int skippedCount; + } + private SkipPriceAsinEntity getById(Long id) { SkipPriceAsinEntity entity = skipPriceAsinMapper.selectById(id); if (entity == null) { @@ -174,22 +675,6 @@ public class SkipPriceAsinService { return vo; } - private Map buildGroupNameMap(List groupIds) { - if (groupIds == null || groupIds.isEmpty()) { - return Map.of(); - } - LinkedHashMap 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 normalizeCountries(List countries) { LinkedHashSet normalized = new LinkedHashSet<>(); if (countries != null) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchTaskService.java index 4f18f2d..17035e6 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchTaskService.java @@ -132,6 +132,21 @@ public class ShopMatchTaskService { return tasks; } + private List selectTaskStatusesByIdsInBatches(List taskIds) { + List 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() + .select(FileTaskEntity::getId, FileTaskEntity::getStatus) + .in(FileTaskEntity::getId, taskIds.subList(start, end)))); + } + return tasks; + } + public ProductRiskDashboardVo dashboard(Long userId) { if (userId == null || userId <= 0) { throw new BusinessException("user_id 不合法"); @@ -174,7 +189,7 @@ public class ShopMatchTaskService { .distinct() .toList(); if (!taskIds.isEmpty()) { - for (FileTaskEntity task : selectTasksByIdsInBatches(taskIds)) { + for (FileTaskEntity task : selectTaskStatusesByIdsInBatches(taskIds)) { if (task != null) { statusByTaskId.put(task.getId(), task.getStatus()); } @@ -465,7 +480,6 @@ public class ShopMatchTaskService { } - @Transactional public void submitResult(Long taskId, ShopMatchSubmitResultRequest request) { if (taskId == null || taskId <= 0) { throw new BusinessException("taskId 不合法"); @@ -540,7 +554,6 @@ public class ShopMatchTaskService { updateTaskStatusFromLatestRows(task, latest); } - @Transactional public boolean tryFinalizeTask(Long taskId, boolean fromCompensation) { if (taskId == null || taskId <= 0) { return false; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/split/service/SplitRunService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/split/service/SplitRunService.java index 474e36e..91630f6 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/split/service/SplitRunService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/split/service/SplitRunService.java @@ -456,8 +456,8 @@ public class SplitRunService { if (value == null) { return ""; } - return value.replace("\ufeff", "") - .replace("\u3000", " ") + return value.replace(String.valueOf((char) 0xFEFF), "") + .replace((char) 0x3000, ' ') .replace("\r\n", " ") .replace("\r", " ") .replace("\n", " ") diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskFileJobService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskFileJobService.java index 8166c2a..8365bf5 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskFileJobService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskFileJobService.java @@ -23,7 +23,6 @@ public class TaskFileJobService { private final TaskFileJobMapper taskFileJobMapper; private final ApplicationEventPublisher applicationEventPublisher; - @Transactional public TaskFileJobEntity enqueueAssembleResult(Long taskId, String moduleType, Long resultId, String scopeKey) { if (taskId == null || taskId <= 0 || moduleType == null || moduleType.isBlank() || resultId == null || resultId <= 0) { return null; @@ -146,7 +145,6 @@ public class TaskFileJobService { return reset; } - @Transactional public boolean markRunning(Long jobId) { if (jobId == null || jobId <= 0) { return false; @@ -158,7 +156,6 @@ public class TaskFileJobService { .set(TaskFileJobEntity::getUpdatedAt, LocalDateTime.now())) > 0; } - @Transactional public void touchRunning(Long jobId) { if (jobId == null || jobId <= 0) { return; @@ -169,7 +166,6 @@ public class TaskFileJobService { .set(TaskFileJobEntity::getUpdatedAt, LocalDateTime.now())); } - @Transactional public void markSuccess(TaskFileJobEntity job, String resultFileUrl) { taskFileJobMapper.update(null, new LambdaUpdateWrapper() .eq(TaskFileJobEntity::getId, job.getId()) @@ -180,7 +176,6 @@ public class TaskFileJobService { .set(TaskFileJobEntity::getFinishedAt, LocalDateTime.now())); } - @Transactional public void markFailed(TaskFileJobEntity job, String message) { int retryCount = job.getRetryCount() == null ? 0 : job.getRetryCount() + 1; taskFileJobMapper.update(null, new LambdaUpdateWrapper() diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultItemService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultItemService.java index d955967..66b9de9 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultItemService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultItemService.java @@ -25,7 +25,6 @@ public class TaskResultItemService { private final ObjectMapper objectMapper; private final TransientPayloadStorageService transientPayloadStorageService; - @Transactional public void replaceResultSnapshot(Long taskId, String moduleType, Long resultId, String scopeKey, Object snapshot) { if (taskId == null || taskId <= 0 || isBlank(moduleType) || resultId == null || resultId <= 0 || snapshot == null) { return; @@ -35,7 +34,6 @@ public class TaskResultItemService { upsert(taskId, moduleType, resultId, normalizedScopeKey, itemKey, null, null, null, snapshot); } - @Transactional public void replaceTaskSnapshots(Long taskId, String moduleType, List snapshots, SnapshotKeyResolver resolver) { if (taskId == null || taskId <= 0 || isBlank(moduleType) || snapshots == null) { return; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultPayloadService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultPayloadService.java index e6fdf36..d6e094a 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultPayloadService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultPayloadService.java @@ -25,7 +25,6 @@ public class TaskResultPayloadService { private final ObjectMapper objectMapper; private final TransientPayloadStorageService transientPayloadStorageService; - @Transactional public void saveLatest(Long taskId, String moduleType, String scopeKey, Object payload) { if (taskId == null || taskId <= 0 || isBlank(moduleType) || isBlank(scopeKey) || payload == null) { return; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TransientPayloadStorageService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TransientPayloadStorageService.java index 022fcab..4282fdf 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TransientPayloadStorageService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TransientPayloadStorageService.java @@ -60,7 +60,7 @@ public class TransientPayloadStorageService { return ossStorageService.readObjectAsString(pointer.substring(OSS_POINTER_PREFIX.length())); } } catch (Exception ex) { - throw new IllegalStateException(errorMessage, ex); + throw new IllegalStateException(errorMessage + ": " + pointer, ex); } return value; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoAuthService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoAuthService.java index bc206da..f8c2a15 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoAuthService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoAuthService.java @@ -212,7 +212,7 @@ public boolean isIpWhitelistError(BusinessException ex) { if (value == null) { return ""; } - return value.replace("\u3000", " ") + return value.replace((char) 0x3000, ' ') .trim(); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoShopIndexService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoShopIndexService.java index a446d9a..44959e5 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoShopIndexService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoShopIndexService.java @@ -357,7 +357,7 @@ public class ZiniaoShopIndexService { if (value == null) { return ""; } - return value.replace("\u3000", " ").trim(); + return value.replace((char) 0x3000, ' ').trim(); } private ZiniaoShopMatchResultVo pendingResult(String message) { diff --git a/backend-java/src/main/resources/application.yml b/backend-java/src/main/resources/application.yml index 8184634..06fe730 100644 --- a/backend-java/src/main/resources/application.yml +++ b/backend-java/src/main/resources/application.yml @@ -19,6 +19,10 @@ spring: maximum-pool-size: ${AIIMAGE_DB_POOL_MAX_SIZE:30} minimum-idle: ${AIIMAGE_DB_POOL_MIN_IDLE:5} connection-timeout: ${AIIMAGE_DB_POOL_CONNECTION_TIMEOUT_MS:30000} + validation-timeout: ${AIIMAGE_DB_POOL_VALIDATION_TIMEOUT_MS:5000} + idle-timeout: ${AIIMAGE_DB_POOL_IDLE_TIMEOUT_MS:600000} + max-lifetime: ${AIIMAGE_DB_POOL_MAX_LIFETIME_MS:1500000} + keepalive-time: ${AIIMAGE_DB_POOL_KEEPALIVE_TIME_MS:120000} leak-detection-threshold: ${AIIMAGE_DB_POOL_LEAK_DETECT_MS:0} data: redis: @@ -54,7 +58,7 @@ mybatis-plus: mapper-locations: classpath*:mapper/**/*.xml configuration: map-underscore-to-camel-case: true - log-impl: org.apache.ibatis.logging.stdout.StdOutImpl + log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl springdoc: api-docs: @@ -80,6 +84,10 @@ aiimage: access-key-id: ${AIIMAGE_TRANSIENT_STORAGE_ACCESS_KEY_ID:} access-key-secret: ${AIIMAGE_TRANSIENT_STORAGE_ACCESS_KEY_SECRET:} region: ${AIIMAGE_TRANSIENT_STORAGE_REGION:us-east-1} + connect-timeout-seconds: ${AIIMAGE_TRANSIENT_STORAGE_CONNECT_TIMEOUT_SECONDS:10} + read-timeout-seconds: ${AIIMAGE_TRANSIENT_STORAGE_READ_TIMEOUT_SECONDS:60} + write-timeout-seconds: ${AIIMAGE_TRANSIENT_STORAGE_WRITE_TIMEOUT_SECONDS:60} + upload-max-retries: ${AIIMAGE_TRANSIENT_STORAGE_UPLOAD_MAX_RETRIES:3} storage: local-temp-dir: ${AIIMAGE_STORAGE_LOCAL_TEMP_DIR:./data/tmp} cleanup-enabled: ${AIIMAGE_STORAGE_CLEANUP_ENABLED:true} diff --git a/backend-java/src/main/resources/db/V36__shopkey_list_query_indexes.sql b/backend-java/src/main/resources/db/V36__shopkey_list_query_indexes.sql new file mode 100644 index 0000000..747c3ea --- /dev/null +++ b/backend-java/src/main/resources/db/V36__shopkey_list_query_indexes.sql @@ -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; diff --git a/backend-java/src/main/resources/db/V37__permission_menu_query_indexes.sql b/backend-java/src/main/resources/db/V37__permission_menu_query_indexes.sql new file mode 100644 index 0000000..daa2db1 --- /dev/null +++ b/backend-java/src/main/resources/db/V37__permission_menu_query_indexes.sql @@ -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; diff --git a/backend-java/src/main/resources/db/V38__task_result_hot_path_indexes.sql b/backend-java/src/main/resources/db/V38__task_result_hot_path_indexes.sql new file mode 100644 index 0000000..01ac687 --- /dev/null +++ b/backend-java/src/main/resources/db/V38__task_result_hot_path_indexes.sql @@ -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; diff --git a/backend-java/src/main/resources/db/V39__allow_multiple_skip_price_asins_per_shop.sql b/backend-java/src/main/resources/db/V39__allow_multiple_skip_price_asins_per_shop.sql new file mode 100644 index 0000000..81b502e --- /dev/null +++ b/backend-java/src/main/resources/db/V39__allow_multiple_skip_price_asins_per_shop.sql @@ -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; diff --git a/backend/blueprints/admin_api.py b/backend/blueprints/admin_api.py index 1652c5c..960f22d 100644 --- a/backend/blueprints/admin_api.py +++ b/backend/blueprints/admin_api.py @@ -4,9 +4,11 @@ import json import os import re +import threading import requests -from flask import Blueprint, request, jsonify, session, current_app +from requests.adapters import HTTPAdapter +from flask import Blueprint, request, jsonify, session, current_app, g import pymysql from werkzeug.security import generate_password_hash @@ -43,6 +45,9 @@ except ImportError: backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://127.0.0.1:18080').rstrip('/') admin_api = Blueprint('admin_api', __name__, url_prefix='/api/admin') +_backend_java_session_local = threading.local() +_column_sort_schema_checked = False +_column_sort_schema_lock = threading.Lock() ADMIN_MENU_ACCESS_CONFIG = { 'dedupe-total-data': { @@ -103,10 +108,22 @@ def _safe_version_key(version): return s or 'unknown' +def _get_backend_java_session(): + http_session = getattr(_backend_java_session_local, 'session', None) + if http_session is None: + http_session = requests.Session() + adapter = HTTPAdapter(pool_connections=8, pool_maxsize=32, max_retries=0) + http_session.mount('http://', adapter) + http_session.mount('https://', adapter) + _backend_java_session_local.session = http_session + return http_session + + def _proxy_backend_java(method, path, *, params=None, json_data=None, files=None, data=None, timeout=10): url = f"{backend_java_base_url}{path}" try: - resp = requests.request(method=method, url=url, params=params, json=json_data, files=files, data=data, timeout=timeout) + requester = requests if files else _get_backend_java_session() + resp = requester.request(method=method, url=url, params=params, json=json_data, files=files, data=data, timeout=timeout) except requests.RequestException: return None, jsonify({'success': False, 'error': 'backend-java 服务不可用'}), 502 try: @@ -146,6 +163,28 @@ def _parse_optional_int(value): def _ensure_column_sort_schema(): + global _column_sort_schema_checked + if _column_sort_schema_checked: + return + with _column_sort_schema_lock: + if _column_sort_schema_checked: + return + conn = get_db() + try: + with conn.cursor() as cur: + try: + cur.execute("ALTER TABLE columns ADD COLUMN sort_order INT NOT NULL DEFAULT 0 COMMENT '鑿滃崟鎺掑簭' AFTER route_path") + except Exception: + pass + try: + cur.execute("UPDATE columns SET sort_order = id WHERE sort_order IS NULL OR sort_order = 0") + except Exception: + pass + conn.commit() + _column_sort_schema_checked = True + finally: + conn.close() + return conn = get_db() try: with conn.cursor() as cur: @@ -299,6 +338,10 @@ def _get_current_admin_permission_sets(): user_id = _get_current_admin_id() if not user_id: return set(), set() + cache_key = f'_admin_permission_sets_{user_id}' + cached = getattr(g, cache_key, None) + if cached is not None: + return cached conn = get_db() try: with conn.cursor() as cur: @@ -314,7 +357,9 @@ def _get_current_admin_permission_sets(): conn.close() key_set = {(row.get('column_key') or '').strip() for row in rows if (row.get('column_key') or '').strip()} route_set = {(row.get('route_path') or '').strip() for row in rows if (row.get('route_path') or '').strip()} - return key_set, route_set + cached = (key_set, route_set) + setattr(g, cache_key, cached) + return cached def _ensure_admin_menu_access(*menu_names): @@ -830,14 +875,6 @@ def list_columns(): if denied: return denied menu_type = (request.args.get('menu_type') or '').strip() - params = {'menuType': menu_type} if menu_type else None - result, error_response, status = _proxy_backend_java( - 'GET', - '/api/admin/permission-menus', - params=params, - ) - if error_response is None: - _sync_local_columns_from_backend([_format_permission_item(item) for item in (result.get('data') or [])]) local_rows = _load_local_columns(menu_type or None) items = [ { @@ -1484,32 +1521,7 @@ def _load_expanded_shop_manage_groups(role, current_row): if error_response is not None: return None, error_response, status - direct_groups = result.get('data') or [] - if role == 'super_admin': - return direct_groups, None, 200 - - direct_ids = {item.get('id') for item in direct_groups if item.get('id')} - leader_ids = {item.get('leaderUserId') for item in direct_groups if item.get('leaderUserId')} - if not leader_ids: - return direct_groups, None, 200 - - all_result, error_response, status = _proxy_backend_java( - 'GET', - '/api/admin/shop-manages/groups', - params={'superAdmin': 'true'}, - ) - if error_response is not None: - return None, error_response, status - - merged = [] - seen_ids = set() - for item in all_result.get('data') or []: - item_id = item.get('id') - if item_id in direct_ids or item.get('leaderUserId') in leader_ids: - if item_id not in seen_ids: - merged.append(item) - seen_ids.add(item_id) - return merged, None, 200 + return result.get('data') or [], None, 200 @admin_api.route('/shop-manages') @@ -1523,59 +1535,6 @@ def list_shop_manages(): group_id_raw = (request.args.get('group_id') or '').strip() shop_name = (request.args.get('shop_name') or '').strip() - if role != 'super_admin': - groups, error_response, status = _load_expanded_shop_manage_groups(role, current_row) - if error_response is not None: - return error_response, status - accessible_group_ids = [item.get('id') for item in (groups or []) if item.get('id')] - selected_group_id = None - if group_id_raw: - try: - selected_group_id = int(group_id_raw) - except (TypeError, ValueError): - selected_group_id = None - if selected_group_id and selected_group_id not in accessible_group_ids: - return jsonify({'success': True, 'items': [], 'total': 0, 'page': page, 'page_size': page_size}) - query_group_ids = [selected_group_id] if selected_group_id else accessible_group_ids - all_items = [] - for group_id in query_group_ids: - group_page = 1 - while True: - group_params = { - 'page': group_page, - 'pageSize': 100, - 'groupId': group_id, - 'superAdmin': 'true', - } - if current_row and current_row.get('id'): - group_params['operatorId'] = current_row.get('id') - if shop_name: - group_params['shopName'] = shop_name - result, error_response, status = _proxy_backend_java( - 'GET', - '/api/admin/shop-manages', - params=group_params, - ) - if error_response is not None: - return error_response, status - payload = result.get('data') or {} - rows = payload.get('items') or [] - all_items.extend(rows) - total = payload.get('total') or 0 - if group_page * 100 >= total or not rows: - break - group_page += 1 - all_items.sort(key=lambda item: item.get('id') or 0, reverse=True) - total = len(all_items) - offset = (page - 1) * page_size - return jsonify({ - 'success': True, - 'items': [_format_shop_manage_item(item) for item in all_items[offset:offset + page_size]], - 'total': total, - 'page': page, - 'page_size': page_size, - }) - params = {'page': page, 'pageSize': page_size} if current_row and current_row.get('id'): params['operatorId'] = current_row.get('id') @@ -2031,6 +1990,116 @@ def update_skip_price_asin_country(item_id, country): }) +@admin_api.route('/skip-price-asins/import/') +@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/') +@login_required +def skip_price_asin_delete_import_progress(import_id): + role, current_row, denied = _ensure_backend_menu_access('skip-price-asin') + if denied: + return denied + result, error_response, status = _proxy_backend_java( + 'GET', + f'/api/admin/skip-price-asins/delete-import/{import_id}', + ) + if error_response is not None: + return error_response, status + return jsonify({ + 'success': True, + 'progress': _format_query_asin_import_progress(result.get('data') or {}), + }) + + +@admin_api.route('/skip-price-asins/delete-import', methods=['POST']) +@login_required +def delete_import_skip_price_asins(): + role, current_row, denied = _ensure_backend_menu_access('skip-price-asin') + if denied: + return denied + file_storage = request.files.get('file') + if not file_storage or file_storage.filename == '': + return jsonify({'success': False, 'error': '请选择 Excel 文件'}) + group_id_raw = (request.form.get('group_id') or '').strip() + if not group_id_raw: + return jsonify({'success': False, 'error': '请先选择分组'}) + params = { + 'groupId': group_id_raw, + 'operatorId': current_row.get('id') if current_row else None, + 'superAdmin': 'true' if role == 'super_admin' else 'false', + } + files = { + 'file': (file_storage.filename, file_storage.stream, file_storage.mimetype or 'application/octet-stream') + } + result, error_response, status = _proxy_backend_java( + 'POST', + '/api/admin/skip-price-asins/delete-import', + params=params, + files=files, + timeout=60, + ) + if error_response is not None: + return error_response, status + summary = result.get('data') or {} + return jsonify({ + 'success': True, + 'msg': result.get('message') or '开始删除', + 'import_id': summary.get('importId') or '', + }) + + def _format_query_asin_item(item): return { 'id': item.get('id'), diff --git a/backend/config.py b/backend/config.py index 4e042f5..db46cef 100644 --- a/backend/config.py +++ b/backend/config.py @@ -23,9 +23,10 @@ bucket_path = "nanri-image/" file_url_pre = f"https://{bucket}.oss-cn-hangzhou.aliyuncs.com/" import os -# backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://127.0.0.1:18080').rstrip('/') +backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://127.0.0.1:18080').rstrip('/') # backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://8.136.19.173:18080').rstrip('/') -backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://47.111.163.154:18080').rstrip('/') +# backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://47.111.163.154:18080').rstrip('/') +# backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://121.196.149.225:18080').rstrip('/') os.environ['OSS_ACCESS_KEY_ID'] = accessKeyId os.environ['OSS_ACCESS_KEY_SECRET'] = accessKeySecret os.environ['SECRET_KEY'] = "ddffc7c1d02121d9554d7b080b2511b6" diff --git a/backend/utils/auth.py b/backend/utils/auth.py index 772cd13..5b5c0a7 100644 --- a/backend/utils/auth.py +++ b/backend/utils/auth.py @@ -3,7 +3,7 @@ """ from functools import wraps -from flask import request, redirect, url_for, session, jsonify +from flask import request, redirect, url_for, session, jsonify, g from utils.db import get_db @@ -13,15 +13,22 @@ def is_session_user_valid(): uid = session.get('user_id') if not uid: return False + cached_user = getattr(g, '_current_user_row', None) + if cached_user and cached_user.get('id') == uid: + return True try: conn = get_db() with conn.cursor() as cur: - cur.execute("SELECT id FROM users WHERE id = %s", (uid,)) + cur.execute( + "SELECT id, username, is_admin, role, created_by_id FROM users WHERE id = %s", + (uid,) + ) row = cur.fetchone() conn.close() if not row: session.clear() return False + g._current_user_row = row return True except Exception: session.clear() @@ -34,16 +41,19 @@ def get_current_admin_role(): if not uid: return None, None try: - conn = get_db() - with conn.cursor() as cur: - cur.execute( - "SELECT id, username, is_admin, role, created_by_id FROM users WHERE id = %s", - (uid,) - ) - row = cur.fetchone() - conn.close() + row = getattr(g, '_current_user_row', None) + if not row or row.get('id') != uid: + conn = get_db() + with conn.cursor() as cur: + cur.execute( + "SELECT id, username, is_admin, role, created_by_id FROM users WHERE id = %s", + (uid,) + ) + row = cur.fetchone() + conn.close() if not row: return None, None + g._current_user_row = row role = (row.get('role') or '').strip().lower() if not role: role = 'super_admin' if row.get('is_admin') and row.get('created_by_id') is None else ( diff --git a/backend/utils/db.py b/backend/utils/db.py index edf13ef..5ec2849 100644 --- a/backend/utils/db.py +++ b/backend/utils/db.py @@ -6,12 +6,20 @@ import pymysql from werkzeug.security import generate_password_hash try: - from config import mysql_host, mysql_user, mysql_password, mysql_database + from config import mysql_host as config_mysql_host + from config import mysql_user as config_mysql_user + from config import mysql_password as config_mysql_password + from config import mysql_database as config_mysql_database except ImportError: - mysql_host = os.environ.get('MYSQL_HOST', 'localhost') - mysql_user = os.environ.get('MYSQL_USER', 'root') - mysql_password = os.environ.get('MYSQL_PASSWORD', '') - mysql_database = os.environ.get('MYSQL_DATABASE', 'maixiang_ai') + config_mysql_host = 'localhost' + config_mysql_user = 'root' + config_mysql_password = '' + config_mysql_database = 'maixiang_ai' + +mysql_host = os.environ.get('MYSQL_HOST', config_mysql_host) +mysql_user = os.environ.get('MYSQL_USER', config_mysql_user) +mysql_password = os.environ.get('MYSQL_PASSWORD', config_mysql_password) +mysql_database = os.environ.get('MYSQL_DATABASE', config_mysql_database) def get_db(): diff --git a/backend/web_source/_tmp_prefix.js b/backend/web_source/_tmp_prefix.js deleted file mode 100644 index c578064..0000000 --- a/backend/web_source/_tmp_prefix.js +++ /dev/null @@ -1,2000 +0,0 @@ - - (function () { - // Tab 鍒囨崲 - var adminTabsEl = document.getElementById('adminTabs'); - if (adminTabsEl) adminTabsEl.innerHTML = ''; - var activeAdminTabName = ''; - var ADMIN_PANEL_MAP = { - 'users': 'panel-users', - 'columns': 'panel-columns', - 'dedupe-total-data': 'panel-dedupe-total-data', - 'shop-keys': 'panel-shop-keys', - 'shop-manage': 'panel-shop-manage', - 'skip-price-asin': 'panel-skip-price-asin', - 'history': 'panel-history', - 'version': 'panel-version' - }; - function runTabLoader(tabName) { - if (tabName === 'users') { loadUsers(1); loadColumnsForPermission(); } - else if (tabName === 'columns') loadColumns(); - else if (tabName === 'dedupe-total-data') loadDedupeTotalData(1); - else if (tabName === 'shop-keys') loadShopKeys(1); - else if (tabName === 'shop-manage') loadShopManage(1); - else if (tabName === 'skip-price-asin') loadSkipPriceAsin(1); - else if (tabName === 'history') loadHistory(1); - else if (tabName === 'version') loadVersions(); - } - function hideAllAdminPanels() { - document.querySelectorAll('.tab-panel').forEach(function (panel) { - panel.classList.remove('active'); - panel.style.display = 'none'; - }); - } - function getActiveAdminTabName() { - return activeAdminTabName; - } - function activateAdminTab(tabName) { - var panelId = ADMIN_PANEL_MAP[tabName]; - var panel = panelId ? document.getElementById(panelId) : null; - if (!panel) return; - activeAdminTabName = tabName; - document.querySelectorAll('#adminTabs .tab').forEach(function (tab) { - tab.classList.toggle('active', tab.dataset.tab === tabName); - }); - hideAllAdminPanels(); - panel.style.display = ''; - panel.classList.add('active'); - runTabLoader(tabName); - } - function renderAdminTabs(items) { - var knownItems = (items || []).filter(function (item) { - return !!ADMIN_PANEL_MAP[item.route_path]; - }); - if (!knownItems.length) { - activeAdminTabName = ''; - if (adminTabsEl) adminTabsEl.innerHTML = '
鏆傛棤鍙敤鑿滃崟
'; - hideAllAdminPanels(); - return; - } - if (adminTabsEl) { - adminTabsEl.innerHTML = knownItems.map(function (item) { - return '
' + (item.name || item.route_path) + '
'; - }).join(''); - } - document.querySelectorAll('#adminTabs .tab').forEach(function (tab) { - tab.onclick = function () { - activateAdminTab(tab.dataset.tab); - }; - }); - var fallbackTab = activeAdminTabName && knownItems.some(function (item) { return item.route_path === activeAdminTabName; }) - ? activeAdminTabName - : knownItems[0].route_path; - activateAdminTab(fallbackTab); - } - function loadAdminMenus(preferredTab) { - if (preferredTab) activeAdminTabName = preferredTab; - fetch('/api/admin/current-user/menus') - .then(function (r) { return r.json(); }) - .then(function (res) { - if (!res.success) { - if (adminTabsEl) adminTabsEl.innerHTML = '
鑿滃崟鍔犺浇澶辫触
'; - hideAllAdminPanels(); - return; - } - renderAdminTabs(res.items || []); - }) - .catch(function () { - if (adminTabsEl) adminTabsEl.innerHTML = '
鑿滃崟鍔犺浇澶辫触
'; - hideAllAdminPanels(); - }); - } - hideAllAdminPanels(); - - // ========== 鐢ㄦ埛绠$悊 ========== - var userPage = 1, userPageSize = 15; - var currentUserId = null; - var currentUserRole = 'admin'; - var currentUserUsername = ''; - var adminsList = []; - function roleLabel(role) { - if (role === 'super_admin') return '瓒呯骇绠$悊鍛?; - if (role === 'admin') return '绠$悊鍛?; - return '鏅€氳处鍙?; - } - function buildUserListQuery(page) { - var q = 'page=' + (page || 1) + '&page_size=' + userPageSize; - var kw = (document.getElementById('searchUsername').value || '').trim(); - if (kw) q += '&username=' + encodeURIComponent(kw); - var cby = document.getElementById('filterCreatedBy').value; - if (cby) q += '&created_by_id=' + encodeURIComponent(cby); - return q; - } - function loadUsers(page) { - userPage = page || 1; - fetch('/api/admin/users?' + buildUserListQuery(userPage)) - .then(function (r) { return r.json(); }) - .then(function (res) { - var tbody = document.getElementById('userListBody'); - if (!res.success) { - tbody.innerHTML = '鍔犺浇澶辫触: ' + (res.error || '') + ''; - return; - } - currentUserId = res.current_user_id || null; - currentUserRole = res.current_user_role || 'admin'; - currentUserUsername = (res.current_user_username || '').trim(); - adminsList = res.admins || []; - var items = res.items || []; - if (items.length === 0) { - tbody.innerHTML = '鏆傛棤鐢ㄦ埛'; - } else { - tbody.innerHTML = items.map(function (u) { - return '' + u.id + '' + (u.username || '') + '' + - roleLabel(u.role || 'normal') + '' + (u.creator_username || '-') + '' + (u.created_at || '') + '' + - ' ' + - '' + - ''; - }).join(''); - } - renderPagination('userPagination', res.total, res.page, res.page_size, loadUsers); - bindUserActions(); - updateCreateFormByRole(); - updateUserFilterByRole(); - }) - .catch(function () { - document.getElementById('userListBody').innerHTML = '璇锋眰澶辫触'; - }); - } - function updateUserFilterByRole() { - var grp = document.getElementById('filterCreatedByGroup'); - var sel = document.getElementById('filterCreatedBy'); - if (currentUserRole === 'super_admin') { - grp.style.display = 'block'; - var cur = sel.value; - sel.innerHTML = ''; - adminsList.forEach(function (a) { - var opt = document.createElement('option'); - opt.value = a.id; - opt.textContent = a.username; - sel.appendChild(opt); - }); - sel.value = cur || ''; - } else { - grp.style.display = 'none'; - } - } - - function updateDedupeTotalDataAccess() { - var tab = document.querySelector('.tab[data-tab="dedupe-total-data"]'); - var panel = document.getElementById('panel-dedupe-total-data'); - var canUse = currentUserRole === 'super_admin' || (currentUserRole === 'admin' && currentUserUsername === ''); - if (tab) tab.style.display = canUse ? '' : 'none'; - if (panel) panel.style.display = canUse ? '' : 'none'; - if (!canUse && tab && tab.classList.contains('active')) { - tab.classList.remove('active'); - if (panel) panel.classList.remove('active'); - var usersTab = document.querySelector('.tab[data-tab="users"]'); - var usersPanel = document.getElementById('panel-users'); - if (usersTab) usersTab.classList.add('active'); - if (usersPanel) usersPanel.classList.add('active'); - } - } - function updateShopManageAccess() { - var tab = document.querySelector('.tab[data-tab="shop-manage"]'); - var panel = document.getElementById('panel-shop-manage'); - if (!tab || !panel) return; - var canUse = currentUserRole === 'super_admin' || - !!currentUserAdminPermissionKeys['admin_shop_manage'] || - !!currentUserAdminPermissionRoutes['shop-manage']; - tab.style.display = canUse ? '' : 'none'; - panel.style.display = canUse ? '' : 'none'; - if (!canUse && tab.classList.contains('active')) { - tab.classList.remove('active'); - panel.classList.remove('active'); - var usersTab = document.querySelector('.tab[data-tab="users"]'); - var usersPanel = document.getElementById('panel-users'); - if (usersTab) usersTab.classList.add('active'); - if (usersPanel) usersPanel.classList.add('active'); - } - } - function loadCurrentUserAdminPermissions() { - currentUserAdminPermissionKeys = {}; - currentUserAdminPermissionRoutes = {}; - if (!currentUserId || currentUserRole === 'super_admin') { - updateShopManageAccess(); - return; - } - fetch('/api/admin/user/' + currentUserId + '/column-permissions?menu_type=admin') - .then(function (r) { return r.json(); }) - .then(function (res) { - if (!res.success) { - updateShopManageAccess(); - return; - } - (res.items || []).forEach(function (item) { - var columnKey = (item.column_key || '').trim(); - var routePath = (item.route_path || '').trim(); - if (columnKey) currentUserAdminPermissionKeys[columnKey] = true; - if (routePath) currentUserAdminPermissionRoutes[routePath] = true; - }); - updateShopManageAccess(); - }) - .catch(function () { - updateShopManageAccess(); - }); - } - function applyTabAccess(tabName, canUse) { - var tab = document.querySelector('.tab[data-tab="' + tabName + '"]'); - var panel = document.getElementById('panel-' + tabName); - if (tab) tab.style.display = canUse ? '' : 'none'; - if (panel) panel.style.display = canUse ? '' : 'none'; - if (!canUse && tab && tab.classList.contains('active')) { - tab.classList.remove('active'); - if (panel) panel.classList.remove('active'); - var usersTab = document.querySelector('.tab[data-tab="users"]'); - var usersPanel = document.getElementById('panel-users'); - if (usersTab) usersTab.classList.add('active'); - if (usersPanel) usersPanel.classList.add('active'); - } - } - function hasAdminTabAccess(tabName) { - var config = ADMIN_TAB_ACCESS_CONFIG[tabName]; - if (!config) return true; - if (currentUserRole === 'super_admin') return true; - if (config.superAdminOnly) return false; - return !!currentUserAdminPermissionKeys[config.columnKey] || - !!currentUserAdminPermissionRoutes[config.routePath]; - } - function refreshAdminTabAccess() { - Object.keys(ADMIN_TAB_ACCESS_CONFIG).forEach(function (tabName) { - applyTabAccess(tabName, hasAdminTabAccess(tabName)); - }); - } - function loadCurrentUserAdminPermissions() { - currentUserAdminPermissionKeys = {}; - currentUserAdminPermissionRoutes = {}; - if (!currentUserId || currentUserRole === 'super_admin') { - refreshAdminTabAccess(); - return; - } - fetch('/api/admin/user/' + currentUserId + '/column-permissions?menu_type=admin') - .then(function (r) { return r.json(); }) - .then(function (res) { - if (!res.success) { - refreshAdminTabAccess(); - return; - } - (res.items || []).forEach(function (item) { - var columnKey = (item.column_key || '').trim(); - var routePath = (item.route_path || '').trim(); - if (columnKey) currentUserAdminPermissionKeys[columnKey] = true; - if (routePath) currentUserAdminPermissionRoutes[routePath] = true; - }); - refreshAdminTabAccess(); - }) - .catch(function () { - refreshAdminTabAccess(); - }); - } - var allColumnsList = []; - function loadColumnsForPermission() { - fetch('/api/admin/columns?menu_type=admin') - .then(function (r) { return r.json(); }) - .then(function (res) { - if (!res.success) return; - allColumnsList = res.items || []; - renderColumnPermissionWrap('createColumnPermissionWrap'); - renderColumnPermissionWrap('editColumnPermissionWrap'); - var selCreate = document.getElementById('createColumnPermissionWrap'); - var selEdit = document.getElementById('editColumnPermissionWrap'); - if (selCreate && !selCreate._colCardsBound) { - selCreate._colCardsBound = true; - selCreate.onchange = function () { renderColumnCards('createColumnPermissionWrap'); }; - } - if (selEdit && !selEdit._colCardsBound) { - selEdit._colCardsBound = true; - selEdit.onchange = function () { renderColumnCards('editColumnPermissionWrap'); }; - } - }) - .catch(function () { }); - } - var columnCardsContainerMap = { createColumnPermissionWrap: 'createColumnCards', editColumnPermissionWrap: 'editColumnCards' }; - function renderColumnCards(selectId) { - var sel = document.getElementById(selectId); - var cardsId = columnCardsContainerMap[selectId]; - var cardsEl = cardsId ? document.getElementById(cardsId) : null; - if (!sel || sel.tagName !== 'SELECT' || !cardsEl) return; - cardsEl.innerHTML = ''; - for (var i = 0; i < sel.options.length; i++) { - var opt = sel.options[i]; - if (opt.disabled || !opt.selected) continue; - var card = document.createElement('span'); - card.className = 'column-permission-card'; - card.textContent = opt.textContent; - var btn = document.createElement('button'); - btn.type = 'button'; - btn.className = 'col-card-remove'; - btn.setAttribute('aria-label', '绉婚櫎'); - btn.textContent = '脳'; - (function (option, seldId) { - btn.onclick = function () { - option.selected = false; - renderColumnCards(seldId); - }; - })(opt, selectId); - card.appendChild(btn); - cardsEl.appendChild(card); - } - } - function renderColumnPermissionWrap(wrapId) { - var sel = document.getElementById(wrapId); - if (!sel || sel.tagName !== 'SELECT') return; - sel.innerHTML = ''; - allColumnsList.forEach(function (c) { - var opt = document.createElement('option'); - opt.value = c.id; - opt.textContent = c.name + ' (' + c.column_key + ')'; - sel.appendChild(opt); - }); - if (allColumnsList.length === 0) { - var opt = document.createElement('option'); - opt.disabled = true; - opt.textContent = '鏆傛棤鑿滃崟锛岃鍏堝湪鈥滄爮鐩潈闄愰厤缃€濅腑鏂板鑿滃崟'; - sel.appendChild(opt); - } - renderColumnCards(wrapId); - } - function getSelectedColumnIds(wrapId) { - var sel = document.getElementById(wrapId); - if (!sel || sel.tagName !== 'SELECT') return []; - var ids = []; - for (var i = 0; i < sel.options.length; i++) { - if (sel.options[i].selected) { - var v = parseInt(sel.options[i].value, 10); - if (!isNaN(v)) ids.push(v); - } - } - return ids; - } - function setColumnPermissionCheckboxes(wrapId, columnIds) { - var sel = document.getElementById(wrapId); - if (!sel || sel.tagName !== 'SELECT') return; - var set = {}; - (columnIds || []).forEach(function (id) { set[id] = true; }); - for (var i = 0; i < sel.options.length; i++) { - var opt = sel.options[i]; - if (opt.disabled) continue; - opt.selected = set[parseInt(opt.value, 10)] || false; - } - renderColumnCards(wrapId); - } - function updateCreateFormByRole() { - var roleSel = document.getElementById('createRole'); - var optAdmin = document.getElementById('optAdmin'); - var formCreatedBy = document.getElementById('formGroupCreatedBy'); - var selCreatedBy = document.getElementById('createCreatedBy'); - if (currentUserRole === 'super_admin') { - if (optAdmin) optAdmin.style.display = ''; - formCreatedBy.style.display = (roleSel.value === 'normal') ? 'block' : 'none'; - selCreatedBy.innerHTML = ''; - (res.items || []).forEach(function (u) { - var opt = document.createElement('option'); - opt.value = u.id; - opt.textContent = u.username + ' (' + roleLabel(u.role || 'normal') + ')'; - sel.appendChild(opt); - }); - sel.value = cur || ''; - }); - } - var shopManageGroupUsers = []; - function refreshShopManageGroupUserSelect(selectedUserId) { - var sel = document.getElementById('shopManageGroupUserSelect'); - if (!sel) return; - var opts = ['']; - shopManageGroupUsers.forEach(function (u) { - opts.push(''); - }); - sel.innerHTML = opts.join(''); - if (selectedUserId != null && selectedUserId !== '') { - sel.value = String(selectedUserId); - } - syncShopManageGroupNameWithUser(); - } - function syncShopManageGroupNameWithUser() { - var sel = document.getElementById('shopManageGroupUserSelect'); - var input = document.getElementById('shopManageGroupInput'); - if (!sel || !input) return; - var selectedUser = null; - shopManageGroupUsers.some(function (u) { - if (String(u.id) === String(sel.value || '')) { - selectedUser = u; - return true; - } - return false; - }); - input.value = selectedUser ? (selectedUser.username || '') : ''; - } - function loadUserOptions() { - return fetch('/api/admin/users?page=1&page_size=999') - .then(function (r) { return r.json(); }) - .then(function (res) { - var items = res.items || []; - shopManageGroupUsers = items.map(function (u) { - return { id: u.id, username: u.username || '', role: u.role || 'normal' }; - }); - refreshShopManageGroupUserSelect(document.getElementById('shopManageGroupUserSelect') ? document.getElementById('shopManageGroupUserSelect').value : ''); - var sel = document.getElementById('filterUser'); - var cur = sel.value; - sel.innerHTML = ''; - items.forEach(function (u) { - var opt = document.createElement('option'); - opt.value = u.id; - opt.textContent = u.username + ' (' + roleLabel(u.role || 'normal') + ')'; - sel.appendChild(opt); - }); - sel.value = cur || ''; - }); - } - document.getElementById('btnFilterHistory').onclick = function () { loadHistory(1); }; - - // ========== 鏁版嵁鍘婚噸鎬绘暟鎹?========== - var dedupeTotalDataPage = 1, dedupeTotalDataPageSize = 15; - function buildDedupeTotalDataQuery(page) { - var q = 'page=' + (page || 1) + '&page_size=' + dedupeTotalDataPageSize; - var keyword = (document.getElementById('searchDedupeTotalData').value || '').trim(); - if (keyword) q += '&keyword=' + encodeURIComponent(keyword); - return q; - } - function loadDedupeTotalData(page) { - dedupeTotalDataPage = page || 1; - fetch('/api/admin/dedupe-total-data?' + buildDedupeTotalDataQuery(dedupeTotalDataPage)) - .then(function (r) { return r.json(); }) - .then(function (res) { - var tbody = document.getElementById('dedupeTotalDataListBody'); - if (!res.success) { - tbody.innerHTML = '鍔犺浇澶辫触: ' + (res.error || '') + ''; - return; - } - var items = res.items || []; - if (items.length === 0) { - tbody.innerHTML = '鏆傛棤鎬绘暟鎹?/td>'; - } else { - tbody.innerHTML = items.map(function (item) { - return '' + item.id + '' + (item.data_value || '') + '' + (item.created_at || '') + '' + - ' ' + - '' + - ''; - }).join(''); - } - renderPagination('dedupeTotalDataPagination', res.total, res.page, res.page_size, loadDedupeTotalData); - bindDedupeTotalDataActions(); - }) - .catch(function () { - document.getElementById('dedupeTotalDataListBody').innerHTML = '璇锋眰澶辫触'; - }); - } - function bindDedupeTotalDataActions() { - document.querySelectorAll('[data-dedupe-total-edit]').forEach(function (btn) { - btn.onclick = function () { - document.getElementById('editDedupeTotalDataId').value = btn.dataset.dedupeTotalEdit || ''; - document.getElementById('editDedupeTotalDataValue').value = (btn.dataset.value || '').replace(/"/g, '"'); - document.getElementById('msgEditDedupeTotalData').textContent = ''; - document.getElementById('msgEditDedupeTotalData').className = 'msg'; - document.getElementById('editDedupeTotalDataModal').classList.add('show'); - }; - }); - document.querySelectorAll('[data-dedupe-total-delete]').forEach(function (btn) { - btn.onclick = function () { - var value = (btn.dataset.value || '').replace(/"/g, '"'); - if (!confirm('纭畾鍒犻櫎鎬绘暟鎹€? + value + '鈥濆悧锛?)) return; - fetch('/api/admin/dedupe-total-data/' + btn.dataset.dedupeTotalDelete, { method: 'DELETE' }) - .then(function (r) { return r.json(); }) - .then(function (res) { - if (res.success) { loadDedupeTotalData(dedupeTotalDataPage); } - else { alert(res.error || '鍒犻櫎澶辫触'); } - }); - }; - }); - } - document.getElementById('btnSearchDedupeTotalData').onclick = function () { loadDedupeTotalData(1); }; - var dedupeImportPollTimer = null; - var dedupeDeleteImportPollTimer = null; - function stopDedupeImportProgress() { - if (dedupeImportPollTimer) { - clearInterval(dedupeImportPollTimer); - dedupeImportPollTimer = null; - } - } - function stopDedupeDeleteImportProgress() { - if (dedupeDeleteImportPollTimer) { - clearInterval(dedupeDeleteImportPollTimer); - dedupeDeleteImportPollTimer = null; - } - } - function setDedupeImportProgress(percent, text) { - var wrap = document.getElementById('dedupeTotalDataProgressWrap'); - var fill = document.getElementById('dedupeTotalDataProgressFill'); - var textEl = document.getElementById('dedupeTotalDataProgressText'); - wrap.style.display = 'block'; - fill.style.width = Math.max(0, Math.min(100, percent || 0)) + '%'; - textEl.textContent = text || ''; - } - function setDedupeDeleteImportProgress(percent, text) { - var wrap = document.getElementById('dedupeTotalDataDeleteProgressWrap'); - var fill = document.getElementById('dedupeTotalDataDeleteProgressFill'); - var textEl = document.getElementById('dedupeTotalDataDeleteProgressText'); - wrap.style.display = 'block'; - fill.style.width = Math.max(0, Math.min(100, percent || 0)) + '%'; - textEl.textContent = text || ''; - } - function pollDedupeImport(importId) { - stopDedupeImportProgress(); - function tick() { - fetch('/api/admin/dedupe-total-data/import/' + encodeURIComponent(importId)) - .then(function (r) { return r.json(); }) - .then(function (res) { - if (!res.success) { - stopDedupeImportProgress(); - document.getElementById('msgDedupeTotalData').textContent = res.error || '鏌ヨ瀵煎叆杩涘害澶辫触'; - document.getElementById('msgDedupeTotalData').className = 'msg err'; - return; - } - var progress = res.progress || {}; - var totalRows = progress.total_rows || 0; - var processedRows = progress.processed_rows || 0; - var percent = totalRows > 0 ? Math.round(processedRows * 100 / totalRows) : 0; - setDedupeImportProgress(percent, '澶勭悊涓細宸插鐞?' + processedRows + ' / ' + totalRows + '锛屾柊澧?' + (progress.inserted_count || 0) + '锛岃烦杩?' + (progress.skipped_count || 0)); - if (progress.status === 'success') { - stopDedupeImportProgress(); - document.getElementById('msgDedupeTotalData').textContent = '瀵煎叆鎴愬姛锛氭€昏鏁?' + (progress.total_rows || 0) + '锛孉SIN 鏁伴噺 ' + (progress.asin_count || 0) + '锛屾柊澧?' + (progress.inserted_count || 0) + '锛岃烦杩?' + (progress.skipped_count || 0); - document.getElementById('msgDedupeTotalData').className = 'msg ok'; - loadDedupeTotalData(1); - } else if (progress.status === 'failed') { - stopDedupeImportProgress(); - document.getElementById('msgDedupeTotalData').textContent = progress.error_message || '瀵煎叆澶辫触'; - document.getElementById('msgDedupeTotalData').className = 'msg err'; - } - }) - .catch(function () { - stopDedupeImportProgress(); - document.getElementById('msgDedupeTotalData').textContent = '鏌ヨ瀵煎叆杩涘害澶辫触'; - document.getElementById('msgDedupeTotalData').className = 'msg err'; - }); - } - tick(); - dedupeImportPollTimer = setInterval(tick, 1000); - } - function pollDedupeDeleteImport(importId) { - stopDedupeDeleteImportProgress(); - function tick() { - fetch('/api/admin/dedupe-total-data/delete-import/' + encodeURIComponent(importId)) - .then(function (r) { return r.json(); }) - .then(function (res) { - if (!res.success) { - stopDedupeDeleteImportProgress(); - document.getElementById('msgDeleteDedupeTotalData').textContent = res.error || '鏌ヨ鍒犻櫎杩涘害澶辫触'; - document.getElementById('msgDeleteDedupeTotalData').className = 'msg err'; - return; - } - var progress = res.progress || {}; - var totalRows = progress.total_rows || 0; - var processedRows = progress.processed_rows || 0; - var percent = totalRows > 0 ? Math.round(processedRows * 100 / totalRows) : 0; - setDedupeDeleteImportProgress(percent, '澶勭悊涓細宸插鐞?' + processedRows + ' / ' + totalRows + '锛屽垹闄?' + (progress.deleted_count || 0) + '锛岃烦杩?' + (progress.skipped_count || 0)); - if (progress.status === 'success') { - stopDedupeDeleteImportProgress(); - document.getElementById('msgDeleteDedupeTotalData').textContent = '鍒犻櫎鎴愬姛锛氭€昏鏁?' + (progress.total_rows || 0) + '锛孉SIN 鏁伴噺 ' + (progress.asin_count || 0) + '锛屽垹闄?' + (progress.deleted_count || 0) + '锛岃烦杩?' + (progress.skipped_count || 0); - document.getElementById('msgDeleteDedupeTotalData').className = 'msg ok'; - loadDedupeTotalData(1); - } else if (progress.status === 'failed') { - stopDedupeDeleteImportProgress(); - document.getElementById('msgDeleteDedupeTotalData').textContent = progress.error_message || '鍒犻櫎澶辫触'; - document.getElementById('msgDeleteDedupeTotalData').className = 'msg err'; - } - }) - .catch(function () { - stopDedupeDeleteImportProgress(); - document.getElementById('msgDeleteDedupeTotalData').textContent = '鏌ヨ鍒犻櫎杩涘害澶辫触'; - document.getElementById('msgDeleteDedupeTotalData').className = 'msg err'; - }); - } - tick(); - dedupeDeleteImportPollTimer = setInterval(tick, 1000); - } - - document.getElementById('btnAddDedupeTotalData').onclick = function () { - var fileInput = document.getElementById('dedupeTotalDataFile'); - var msgEl = document.getElementById('msgDedupeTotalData'); - msgEl.textContent = ''; - msgEl.className = 'msg'; - stopDedupeImportProgress(); - document.getElementById('dedupeTotalDataProgressWrap').style.display = 'none'; - if (!fileInput.files || fileInput.files.length === 0) { - msgEl.textContent = '璇烽€夋嫨 Excel 鏂囦欢'; - msgEl.classList.add('err'); - return; - } - var file = fileInput.files[0]; - var lowerName = (file.name || '').toLowerCase(); - if (!(lowerName.endsWith('.xlsx') || lowerName.endsWith('.xls'))) { - msgEl.textContent = '浠呮敮鎸?.xlsx 鎴?.xls 鏂囦欢'; - msgEl.classList.add('err'); - return; - } - var formData = new FormData(); - formData.append('file', file); - var xhr = new XMLHttpRequest(); - xhr.open('POST', '/api/admin/dedupe-total-data/import', true); - xhr.upload.onprogress = function (event) { - if (event.lengthComputable) { - var percent = Math.round(event.loaded * 100 / event.total); - setDedupeImportProgress(percent, '涓婁紶涓細' + percent + '%'); - } - }; - xhr.onreadystatechange = function () { - if (xhr.readyState !== 4) return; - if (xhr.status < 200 || xhr.status >= 300) { - msgEl.textContent = '璇锋眰澶辫触'; - msgEl.className = 'msg err'; - return; - } - var res; - try { res = JSON.parse(xhr.responseText || '{}'); } catch (e) { res = { success: false, error: '杩斿洖鏍煎紡閿欒' }; } - if (!res.success) { - msgEl.textContent = res.error || '瀵煎叆澶辫触'; - msgEl.className = 'msg err'; - return; - } - setDedupeImportProgress(100, '涓婁紶瀹屾垚锛屽悗绔鐞嗕腑...'); - pollDedupeImport(res.import_id); - fileInput.value = ''; - }; - xhr.onerror = function () { - msgEl.textContent = '璇锋眰澶辫触'; - msgEl.className = 'msg err'; - }; - xhr.send(formData); - }; - document.getElementById('btnDeleteImportDedupeTotalData').onclick = function () { - var fileInput = document.getElementById('dedupeTotalDataDeleteFile'); - var msgEl = document.getElementById('msgDeleteDedupeTotalData'); - msgEl.textContent = ''; - msgEl.className = 'msg'; - stopDedupeDeleteImportProgress(); - document.getElementById('dedupeTotalDataDeleteProgressWrap').style.display = 'none'; - if (!fileInput.files || fileInput.files.length === 0) { - msgEl.textContent = '璇烽€夋嫨 Excel 鏂囦欢'; - msgEl.classList.add('err'); - return; - } - var file = fileInput.files[0]; - var lowerName = (file.name || '').toLowerCase(); - if (!(lowerName.endsWith('.xlsx') || lowerName.endsWith('.xls'))) { - msgEl.textContent = '浠呮敮鎸?.xlsx 鎴?.xls 鏂囦欢'; - msgEl.classList.add('err'); - return; - } - if (!confirm('纭畾鎸?Excel 涓殑 ASIN 鎵归噺鍒犻櫎鍖归厤鐨勬€绘暟鎹悧锛?)) { - return; - } - var formData = new FormData(); - formData.append('file', file); - var xhr = new XMLHttpRequest(); - xhr.open('POST', '/api/admin/dedupe-total-data/delete-import', true); - xhr.upload.onprogress = function (event) { - if (event.lengthComputable) { - var percent = Math.round(event.loaded * 100 / event.total); - setDedupeDeleteImportProgress(percent, '涓婁紶涓細' + percent + '%'); - } - }; - xhr.onreadystatechange = function () { - if (xhr.readyState !== 4) return; - if (xhr.status < 200 || xhr.status >= 300) { - msgEl.textContent = '璇锋眰澶辫触'; - msgEl.className = 'msg err'; - return; - } - var res; - try { res = JSON.parse(xhr.responseText || '{}'); } catch (e) { res = { success: false, error: '杩斿洖鏍煎紡閿欒' }; } - if (!res.success) { - msgEl.textContent = res.error || '鍒犻櫎澶辫触'; - msgEl.className = 'msg err'; - return; - } - setDedupeDeleteImportProgress(100, '涓婁紶瀹屾垚锛屽悗绔鐞嗕腑...'); - pollDedupeDeleteImport(res.import_id); - fileInput.value = ''; - }; - xhr.onerror = function () { - msgEl.textContent = '璇锋眰澶辫触'; - msgEl.className = 'msg err'; - }; - xhr.send(formData); - }; - document.getElementById('btnSaveDedupeTotalData').onclick = function () { - var itemId = document.getElementById('editDedupeTotalDataId').value; - var value = (document.getElementById('editDedupeTotalDataValue').value || '').trim(); - var msgEl = document.getElementById('msgEditDedupeTotalData'); - msgEl.textContent = ''; - msgEl.className = 'msg'; - if (!value) { - msgEl.textContent = '璇峰~鍐欐€绘暟鎹€?; - msgEl.classList.add('err'); - return; - } - fetch('/api/admin/dedupe-total-data/' + itemId, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ data_value: value }) - }) - .then(function (r) { return r.json(); }) - .then(function (res) { - if (res.success) { - document.getElementById('editDedupeTotalDataModal').classList.remove('show'); - loadDedupeTotalData(dedupeTotalDataPage); - } else { - msgEl.textContent = res.error || '淇濆瓨澶辫触'; - msgEl.classList.add('err'); - } - }); - }; - document.getElementById('btnCloseEditDedupeTotalData').onclick = function () { - document.getElementById('editDedupeTotalDataModal').classList.remove('show'); - }; - - // ========== 搴楅摵瀵嗛挜绠$悊 ========== - var shopKeyPage = 1, shopKeyPageSize = 15; - function buildShopKeyQuery(page) { - return 'page=' + (page || 1) + '&page_size=' + shopKeyPageSize; - } - function loadShopKeys(page) { - shopKeyPage = page || 1; - fetch('/api/admin/shop-keys?' + buildShopKeyQuery(shopKeyPage)) - .then(function (r) { return r.json(); }) - .then(function (res) { - var tbody = document.getElementById('shopKeyListBody'); - if (!res.success) { - tbody.innerHTML = '鍔犺浇澶辫触: ' + (res.error || '') + ''; - return; - } - var items = res.items || []; - if (items.length === 0) { - tbody.innerHTML = '鏆傛棤搴楅摵瀵嗛挜'; - } else { - tbody.innerHTML = items.map(function (item, index) { - var rowNo = (shopKeyPage - 1) * shopKeyPageSize + index + 1; - return '' + rowNo + '' + (item.ziniao_account_name || '') + '' + (item.ziniao_token || '') + '' + (item.created_at || '') + '' + (item.updated_at || '') + '' + - ' ' + - '' + - ''; - }).join(''); - } - renderPagination('shopKeyPagination', res.total, res.page, res.page_size, loadShopKeys); - bindShopKeyActions(); - }) - .catch(function () { - document.getElementById('shopKeyListBody').innerHTML = '璇锋眰澶辫触'; - }); - } - function bindShopKeyActions() { - document.querySelectorAll('[data-shop-key-edit]').forEach(function (btn) { - btn.onclick = function () { - var item = {}; - try { item = JSON.parse((btn.dataset.shopKey || '').replace(/"/g, '"')); } catch (e) { item = {}; } - document.getElementById('editShopKeyId').value = item.id || ''; - document.getElementById('editShopKeyZiniaoAccountName').value = item.ziniao_account_name || ''; - document.getElementById('editShopKeyZiniaoToken').value = item.ziniao_token || ''; - document.getElementById('msgEditShopKey').textContent = ''; - document.getElementById('msgEditShopKey').className = 'msg'; - document.getElementById('editShopKeyModal').classList.add('show'); - }; - }); - document.querySelectorAll('[data-shop-key-delete]').forEach(function (btn) { - btn.onclick = function () { - var name = (btn.dataset.ziniaoAccountName || '').replace(/"/g, '"'); - if (!confirm('纭畾鍒犻櫎搴楅摵瀵嗛挜鈥? + name + '鈥濆悧锛?)) return; - fetch('/api/admin/shop-key/' + btn.dataset.shopKeyDelete, { method: 'DELETE' }) - .then(function (r) { return r.json(); }) - .then(function (res) { - if (res.success) { loadShopKeys(shopKeyPage); } - else { alert(res.error || '鍒犻櫎澶辫触'); } - }); - }; - }); - } - document.getElementById('btnCreateShopKey').onclick = function () { - var ziniaoAccountName = (document.getElementById('shopKeyZiniaoAccountName').value || '').trim(); - var ziniaoToken = (document.getElementById('shopKeyZiniaoToken').value || '').trim(); - var msgEl = document.getElementById('msgShopKey'); - msgEl.textContent = ''; - msgEl.className = 'msg'; - if (!ziniaoAccountName || !ziniaoToken) { - msgEl.textContent = '璇峰畬鏁村~鍐欑传楦熻处鍙峰悕绉般€佺传楦熶护鐗?; - msgEl.classList.add('err'); - return; - } - fetch('/api/admin/shop-key', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - ziniao_account_name: ziniaoAccountName, - ziniao_token: ziniaoToken - }) - }) - .then(function (r) { return r.json(); }) - .then(function (res) { - if (res.success) { - document.getElementById('shopKeyZiniaoAccountName').value = ''; - document.getElementById('shopKeyZiniaoToken').value = ''; - msgEl.textContent = res.msg || '鍒涘缓鎴愬姛'; - msgEl.className = 'msg ok'; - loadShopKeys(1); - } else { - msgEl.textContent = res.error || '鍒涘缓澶辫触'; - msgEl.className = 'msg err'; - } - }) - .catch(function () { - msgEl.textContent = '璇锋眰澶辫触'; - msgEl.className = 'msg err'; - }); - }; - document.getElementById('btnSaveShopKey').onclick = function () { - var itemId = document.getElementById('editShopKeyId').value; - var ziniaoAccountName = (document.getElementById('editShopKeyZiniaoAccountName').value || '').trim(); - var ziniaoToken = (document.getElementById('editShopKeyZiniaoToken').value || '').trim(); - var msgEl = document.getElementById('msgEditShopKey'); - msgEl.textContent = ''; - msgEl.className = 'msg'; - if (!ziniaoAccountName || !ziniaoToken) { - msgEl.textContent = '璇峰畬鏁村~鍐欑传楦熻处鍙峰悕绉般€佺传楦熶护鐗?; - msgEl.classList.add('err'); - return; - } - fetch('/api/admin/shop-key/' + itemId, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - ziniao_account_name: ziniaoAccountName, - ziniao_token: ziniaoToken - }) - }) - .then(function (r) { return r.json(); }) - .then(function (res) { - if (res.success) { - document.getElementById('editShopKeyModal').classList.remove('show'); - loadShopKeys(shopKeyPage); - } else { - msgEl.textContent = res.error || '淇濆瓨澶辫触'; - msgEl.classList.add('err'); - } - }) - .catch(function () { - msgEl.textContent = '璇锋眰澶辫触'; - msgEl.classList.add('err'); - }); - }; - document.getElementById('btnCloseEditShopKey').onclick = function () { - document.getElementById('editShopKeyModal').classList.remove('show'); - }; - - // ========== 搴楅摵绠$悊 ========== - var shopManagePage = 1, shopManagePageSize = 15; - var shopManageGroups = []; - - function buildShopManageQuery(page) { - var query = 'page=' + (page || 1) + '&page_size=' + shopManagePageSize; - var groupId = (document.getElementById('shopManageFilterGroupId').value || '').trim(); - var shopName = (document.getElementById('shopManageFilterShopName').value || '').trim(); - if (groupId) query += '&group_id=' + encodeURIComponent(groupId); - if (shopName) query += '&shop_name=' + encodeURIComponent(shopName); - return query; - } - - function refreshShopGroupSelects(selectedCreateId, selectedEditId) { - var createSel = document.getElementById('shopManageGroupSelect'); - var editSel = document.getElementById('editShopManageGroupSelect'); - var filterSel = document.getElementById('shopManageFilterGroupId'); - var skipCreateSel = document.getElementById('skipPriceAsinGroupSelect'); - var skipFilterSel = document.getElementById('skipPriceAsinFilterGroupId'); - var chooseSkipShopSel = document.getElementById('chooseSkipPriceAsinShopGroupId'); - var selectedFilterId = filterSel ? filterSel.value : ''; - var selectedSkipCreateId = skipCreateSel ? skipCreateSel.value : ''; - var selectedSkipFilterId = skipFilterSel ? skipFilterSel.value : ''; - var selectedChooseSkipShopId = chooseSkipShopSel ? chooseSkipShopSel.value : ''; - var createOpts = ['']; - var filterOpts = ['']; - shopManageGroups.forEach(function (g) { - var option = ''; - createOpts.push(option); - filterOpts.push(option); - }); - createSel.innerHTML = createOpts.join(''); - editSel.innerHTML = createOpts.join(''); - if (skipCreateSel) skipCreateSel.innerHTML = createOpts.join(''); - if (filterSel) filterSel.innerHTML = filterOpts.join(''); - if (skipFilterSel) skipFilterSel.innerHTML = filterOpts.join(''); - if (chooseSkipShopSel) chooseSkipShopSel.innerHTML = filterOpts.join(''); - if (selectedCreateId != null) createSel.value = String(selectedCreateId); - if (selectedEditId != null) editSel.value = String(selectedEditId); - if (filterSel && selectedFilterId) filterSel.value = selectedFilterId; - if (skipCreateSel && selectedSkipCreateId) skipCreateSel.value = selectedSkipCreateId; - if (skipFilterSel && selectedSkipFilterId) skipFilterSel.value = selectedSkipFilterId; - if (chooseSkipShopSel && selectedChooseSkipShopId) chooseSkipShopSel.value = selectedChooseSkipShopId; - } - - function loadShopManageGroups(selectedCreateId, selectedEditId) { - return fetch('/api/admin/shop-manage-groups') - .then(function (r) { return r.json(); }) - .then(function (res) { - if (!res.success) throw new Error(res.error || '鍔犺浇鍒嗙粍澶辫触'); - shopManageGroups = res.items || []; - refreshShopGroupSelects(selectedCreateId, selectedEditId); - return shopManageGroups; - }) - .catch(function () { - shopManageGroups = []; - refreshShopGroupSelects(); - return []; - }); - } - - function openShopManageGroupModal() { - document.getElementById('shopManageGroupEditId').value = ''; - document.getElementById('shopManageGroupInput').value = ''; - document.getElementById('msgShopManageGroup').textContent = ''; - document.getElementById('msgShopManageGroup').className = 'msg'; - loadShopManageGroups().then(function () { - renderShopManageGroupRows(); - document.getElementById('shopManageGroupModal').classList.add('show'); - }); - } - - function renderShopManageGroupRows() { - var tbody = document.getElementById('shopManageGroupListBody'); - if (!shopManageGroups.length) { - tbody.innerHTML = '鏆傛棤鍒嗙粍'; - return; - } - tbody.innerHTML = shopManageGroups.map(function (item, index) { - return '' + (index + 1) + '' + (item.group_name || '') + '' + (item.created_at || '') + '' + (item.updated_at || '') + '' + - ' ' + - '' + - ''; - }).join(''); - - document.querySelectorAll('[data-shop-group-edit]').forEach(function (btn) { - btn.onclick = function () { - document.getElementById('shopManageGroupEditId').value = btn.dataset.shopGroupEdit; - document.getElementById('shopManageGroupInput').value = (btn.dataset.shopGroupName || '').replace(/"/g, '"'); - }; - }); - - document.querySelectorAll('[data-shop-group-delete]').forEach(function (btn) { - btn.onclick = function () { - var gid = btn.dataset.shopGroupDelete; - var gname = (btn.dataset.shopGroupName || '').replace(/"/g, '"'); - if (!confirm('纭畾鍒犻櫎鍒嗙粍鈥? + gname + '鈥濆悧锛?)) return; - fetch('/api/admin/shop-manage-group/' + gid, { method: 'DELETE' }) - .then(function (r) { return r.json(); }) - .then(function (res) { - if (!res.success) { - alert(res.error || '鍒犻櫎澶辫触'); - return; - } - loadShopManageGroups().then(function () { - renderShopManageGroupRows(); - loadShopManage(shopManagePage); - loadSkipPriceAsin(skipPriceAsinPage); - }); - }); - }; - }); - } - - function loadShopManage(page) { - shopManagePage = page || 1; - fetch('/api/admin/shop-manages?' + buildShopManageQuery(shopManagePage)) - .then(function (r) { return r.json(); }) - .then(function (res) { - var tbody = document.getElementById('shopManageListBody'); - if (!res.success) { - tbody.innerHTML = '鍔犺浇澶辫触: ' + (res.error || '') + ''; - return; - } - var items = res.items || []; - if (items.length === 0) { - tbody.innerHTML = '鏆傛棤搴楅摵'; - } else { - tbody.innerHTML = items.map(function (item, index) { - var rowNo = (shopManagePage - 1) * shopManagePageSize + index + 1; - return '' + rowNo + '' + (item.group_name || '') + '' + (item.shop_name || '') + '' + (item.mall_name || '') + '' + (item.account || '') + '' + (item.password || '') + '' + (item.created_at || '') + '' + (item.updated_at || '') + '' + - ' ' + - '' + - ''; - }).join(''); - } - renderPagination('shopManagePagination', res.total, res.page, res.page_size, loadShopManage); - bindShopManageActions(); - }) - .catch(function () { - document.getElementById('shopManageListBody').innerHTML = '璇锋眰澶辫触'; - }); - } - - function bindShopManageActions() { - document.querySelectorAll('[data-shop-manage-edit]').forEach(function (btn) { - btn.onclick = function () { - var item = {}; - try { item = JSON.parse((btn.dataset.shopManage || '').replace(/"/g, '"')); } catch (e) { item = {}; } - document.getElementById('editShopManageId').value = item.id || ''; - document.getElementById('editShopManageShopName').value = item.shop_name || ''; - document.getElementById('editShopManageMallName').value = item.mall_name || ''; - document.getElementById('editShopManageAccount').value = item.account || ''; - document.getElementById('editShopManagePassword').value = item.password || ''; - document.getElementById('msgEditShopManage').textContent = ''; - document.getElementById('msgEditShopManage').className = 'msg'; - loadShopManageGroups(null, item.group_id).then(function () { - document.getElementById('editShopManageModal').classList.add('show'); - }); - }; - }); - document.querySelectorAll('[data-shop-manage-delete]').forEach(function (btn) { - btn.onclick = function () { - var name = (btn.dataset.shopManageName || '').replace(/"/g, '"'); - if (!confirm('纭畾鍒犻櫎搴楅摵鈥? + name + '鈥濆悧锛?)) return; - fetch('/api/admin/shop-manage/' + btn.dataset.shopManageDelete, { method: 'DELETE' }) - .then(function (r) { return r.json(); }) - .then(function (res) { - if (res.success) { loadShopManage(shopManagePage); } - else { alert(res.error || '鍒犻櫎澶辫触'); } - }); - }; - }); - } - - document.getElementById('btnManageShopGroups').onclick = openShopManageGroupModal; - document.getElementById('btnManageShopGroupsFromEdit').onclick = openShopManageGroupModal; - document.getElementById('btnSearchShopManage').onclick = function () { - loadShopManage(1); - }; - document.getElementById('shopManageFilterShopName').addEventListener('keydown', function (e) { - if (e.key === 'Enter') { - e.preventDefault(); - loadShopManage(1); - } - }); - document.getElementById('btnCloseShopManageGroupModal').onclick = function () { - document.getElementById('shopManageGroupModal').classList.remove('show'); - }; - document.getElementById('btnCancelShopManageGroupEdit').onclick = function () { - document.getElementById('shopManageGroupEditId').value = ''; - document.getElementById('shopManageGroupInput').value = ''; - }; - document.getElementById('btnSaveShopManageGroup').onclick = function () { - var editId = (document.getElementById('shopManageGroupEditId').value || '').trim(); - var groupName = (document.getElementById('shopManageGroupInput').value || '').trim(); - var msgEl = document.getElementById('msgShopManageGroup'); - msgEl.textContent = ''; - msgEl.className = 'msg'; - if (!groupName) { - msgEl.textContent = '璇疯緭鍏ュ垎缁勫悕绉?; - msgEl.className = 'msg err'; - return; - } - var method = editId ? 'PUT' : 'POST'; - var url = editId ? ('/api/admin/shop-manage-group/' + editId) : '/api/admin/shop-manage-group'; - fetch(url, { - method: method, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ group_name: groupName }) - }) - .then(function (r) { return r.json(); }) - .then(function (res) { - if (!res.success) { - msgEl.textContent = res.error || '淇濆瓨澶辫触'; - msgEl.className = 'msg err'; - return; - } - document.getElementById('shopManageGroupEditId').value = ''; - document.getElementById('shopManageGroupInput').value = ''; - msgEl.textContent = res.msg || '淇濆瓨鎴愬姛'; - msgEl.className = 'msg ok'; - loadShopManageGroups().then(function () { - renderShopManageGroupRows(); - loadShopManage(shopManagePage); - loadSkipPriceAsin(skipPriceAsinPage); - }); - }) - .catch(function () { - msgEl.textContent = '璇锋眰澶辫触'; - msgEl.className = 'msg err'; - }); - }; - - function refreshShopGroupSelects(selectedCreateId, selectedEditId) { - var createSel = document.getElementById('shopManageGroupSelect'); - var editSel = document.getElementById('editShopManageGroupSelect'); - var filterSel = document.getElementById('shopManageFilterGroupId'); - var skipCreateSel = document.getElementById('skipPriceAsinGroupSelect'); - var skipFilterSel = document.getElementById('skipPriceAsinFilterGroupId'); - var chooseSkipShopSel = document.getElementById('chooseSkipPriceAsinShopGroupId'); - var selectedFilterId = filterSel ? filterSel.value : ''; - var selectedSkipCreateId = skipCreateSel ? skipCreateSel.value : ''; - var selectedSkipFilterId = skipFilterSel ? skipFilterSel.value : ''; - var selectedChooseSkipShopId = chooseSkipShopSel ? chooseSkipShopSel.value : ''; - var createOpts = ['']; - var filterOpts = ['']; - shopManageGroups.forEach(function (g) { - var option = ''; - createOpts.push(option); - filterOpts.push(option); - }); - createSel.innerHTML = createOpts.join(''); - editSel.innerHTML = createOpts.join(''); - if (skipCreateSel) skipCreateSel.innerHTML = createOpts.join(''); - if (filterSel) filterSel.innerHTML = filterOpts.join(''); - if (skipFilterSel) skipFilterSel.innerHTML = filterOpts.join(''); - if (chooseSkipShopSel) chooseSkipShopSel.innerHTML = filterOpts.join(''); - if (selectedCreateId != null) createSel.value = String(selectedCreateId); - if (selectedEditId != null) editSel.value = String(selectedEditId); - if (filterSel && selectedFilterId) filterSel.value = selectedFilterId; - if (skipCreateSel && selectedSkipCreateId) skipCreateSel.value = selectedSkipCreateId; - if (skipFilterSel && selectedSkipFilterId) skipFilterSel.value = selectedSkipFilterId; - if (chooseSkipShopSel && selectedChooseSkipShopId) chooseSkipShopSel.value = selectedChooseSkipShopId; - } - - function loadShopManageGroups(selectedCreateId, selectedEditId) { - return fetch('/api/admin/shop-manage-groups') - .then(function (r) { return r.json(); }) - .then(function (res) { - if (!res.success) throw new Error(res.error || '鍔犺浇鍒嗙粍澶辫触'); - shopManageGroups = res.items || []; - refreshShopGroupSelects(selectedCreateId, selectedEditId); - return shopManageGroups; - }) - .catch(function () { - shopManageGroups = []; - refreshShopGroupSelects(); - return []; - }); - } - - function resetShopManageGroupForm() { - document.getElementById('shopManageGroupEditId').value = ''; - document.getElementById('shopManageGroupInput').value = ''; - document.getElementById('msgShopManageGroup').textContent = ''; - document.getElementById('msgShopManageGroup').className = 'msg'; - refreshShopManageGroupUserSelect(''); - } - - function openShopManageGroupModal() { - resetShopManageGroupForm(); - loadUserOptions() - .then(function () { return loadShopManageGroups(); }) - .then(function () { - renderShopManageGroupRows(); - document.getElementById('shopManageGroupModal').classList.add('show'); - }); - } - - function renderShopManageGroupRows() { - var tbody = document.getElementById('shopManageGroupListBody'); - if (!shopManageGroups.length) { - tbody.innerHTML = '鏆傛棤鍒嗙粍'; - return; - } - tbody.innerHTML = shopManageGroups.map(function (item, index) { - return '' + (index + 1) + '' + (item.group_name || '') + '' + (item.username || '') + '' + (item.created_at || '') + '' + (item.updated_at || '') + '' + - ' ' + - '' + - ''; - }).join(''); - - document.querySelectorAll('[data-shop-group-edit]').forEach(function (btn) { - btn.onclick = function () { - document.getElementById('shopManageGroupEditId').value = btn.dataset.shopGroupEdit; - refreshShopManageGroupUserSelect(btn.dataset.shopGroupUserId || ''); - }; - }); - - document.querySelectorAll('[data-shop-group-delete]').forEach(function (btn) { - btn.onclick = function () { - var gid = btn.dataset.shopGroupDelete; - var gname = (btn.dataset.shopGroupName || '').replace(/"/g, '"'); - if (!confirm('纭畾鍒犻櫎鍒嗙粍鈥? + gname + '鈥濆悧锛?)) return; - fetch('/api/admin/shop-manage-group/' + gid, { method: 'DELETE' }) - .then(function (r) { return r.json(); }) - .then(function (res) { - if (!res.success) { - alert(res.error || '鍒犻櫎澶辫触'); - return; - } - loadShopManageGroups().then(function () { - renderShopManageGroupRows(); - loadShopManage(shopManagePage); - loadSkipPriceAsin(skipPriceAsinPage); - }); - }); - }; - }); - } - - document.getElementById('shopManageGroupUserSelect').onchange = syncShopManageGroupNameWithUser; - document.getElementById('btnManageShopGroups').onclick = openShopManageGroupModal; - document.getElementById('btnManageShopGroupsFromEdit').onclick = openShopManageGroupModal; - document.getElementById('btnManageSkipPriceAsinGroups').onclick = openShopManageGroupModal; - document.getElementById('btnCloseShopManageGroupModal').onclick = function () { - document.getElementById('shopManageGroupModal').classList.remove('show'); - }; - document.getElementById('btnCancelShopManageGroupEdit').onclick = resetShopManageGroupForm; - document.getElementById('btnSaveShopManageGroup').onclick = function () { - var editId = (document.getElementById('shopManageGroupEditId').value || '').trim(); - var groupName = (document.getElementById('shopManageGroupInput').value || '').trim(); - var userId = (document.getElementById('shopManageGroupUserSelect').value || '').trim(); - var msgEl = document.getElementById('msgShopManageGroup'); - msgEl.textContent = ''; - msgEl.className = 'msg'; - if (!userId || !groupName) { - msgEl.textContent = '璇烽€夋嫨鍏宠仈鐢ㄦ埛'; - msgEl.className = 'msg err'; - return; - } - var method = editId ? 'PUT' : 'POST'; - var url = editId ? ('/api/admin/shop-manage-group/' + editId) : '/api/admin/shop-manage-group'; - fetch(url, { - method: method, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ group_name: groupName, user_id: Number(userId) }) - }) - .then(function (r) { return r.json(); }) - .then(function (res) { - if (!res.success) { - msgEl.textContent = res.error || '淇濆瓨澶辫触'; - msgEl.className = 'msg err'; - return; - } - resetShopManageGroupForm(); - msgEl.textContent = res.msg || '淇濆瓨鎴愬姛'; - msgEl.className = 'msg ok'; - loadShopManageGroups().then(function () { - renderShopManageGroupRows(); - loadShopManage(shopManagePage); - loadSkipPriceAsin(skipPriceAsinPage); - }); - }) - .catch(function () { - msgEl.textContent = '璇锋眰澶辫触'; - msgEl.className = 'msg err'; - }); - }; - - document.getElementById('btnCreateShopManage').onclick = function () { - var groupId = (document.getElementById('shopManageGroupSelect').value || '').trim(); - var shopName = (document.getElementById('shopManageShopName').value || '').trim(); - var mallName = (document.getElementById('shopManageMallName').value || '').trim(); - var account = (document.getElementById('shopManageAccount').value || '').trim(); - var password = (document.getElementById('shopManagePassword').value || '').trim(); - var msgEl = document.getElementById('msgShopManage'); - msgEl.textContent = ''; - msgEl.className = 'msg'; - if (!groupId || !shopName || !mallName || !account || !password) { - msgEl.textContent = '璇峰畬鏁村~鍐欏垎缁勩€佸簵閾哄悕銆佸簵閾哄晢鍩庡悕銆佽处鍙枫€佸瘑鐮?; - msgEl.classList.add('err'); - return; - } - fetch('/api/admin/shop-manage', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ group_id: Number(groupId), shop_name: shopName, mall_name: mallName, account: account, password: password }) - }) - .then(function (r) { return r.json(); }) - .then(function (res) { - if (res.success) { - document.getElementById('shopManageGroupSelect').value = ''; - document.getElementById('shopManageShopName').value = ''; - document.getElementById('shopManageMallName').value = ''; - document.getElementById('shopManageAccount').value = ''; - document.getElementById('shopManagePassword').value = ''; - msgEl.textContent = res.msg || '鍒涘缓鎴愬姛'; - msgEl.className = 'msg ok'; - loadShopManage(1); - } else { - msgEl.textContent = res.error || '鍒涘缓澶辫触'; - msgEl.className = 'msg err'; - } - }) - .catch(function () { - msgEl.textContent = '璇锋眰澶辫触'; - msgEl.className = 'msg err'; - }); - }; - - document.getElementById('btnSaveShopManage').onclick = function () { - var itemId = document.getElementById('editShopManageId').value; - var groupId = (document.getElementById('editShopManageGroupSelect').value || '').trim(); - var shopName = (document.getElementById('editShopManageShopName').value || '').trim(); - var mallName = (document.getElementById('editShopManageMallName').value || '').trim(); - var account = (document.getElementById('editShopManageAccount').value || '').trim(); - var password = (document.getElementById('editShopManagePassword').value || '').trim(); - var msgEl = document.getElementById('msgEditShopManage'); - msgEl.textContent = ''; - msgEl.className = 'msg'; - if (!groupId || !shopName || !mallName || !account || !password) { - msgEl.textContent = '璇峰畬鏁村~鍐欏垎缁勩€佸簵閾哄悕銆佸簵閾哄晢鍩庡悕銆佽处鍙枫€佸瘑鐮?; - msgEl.classList.add('err'); - return; - } - fetch('/api/admin/shop-manage/' + itemId, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ group_id: Number(groupId), shop_name: shopName, mall_name: mallName, account: account, password: password }) - }) - .then(function (r) { return r.json(); }) - .then(function (res) { - if (res.success) { - document.getElementById('editShopManageModal').classList.remove('show'); - loadShopManage(shopManagePage); - } else { - msgEl.textContent = res.error || '淇濆瓨澶辫触'; - msgEl.classList.add('err'); - } - }) - .catch(function () { - msgEl.textContent = '璇锋眰澶辫触'; - msgEl.classList.add('err'); - }); - }; - document.getElementById('btnCloseEditShopManage').onclick = function () { - document.getElementById('editShopManageModal').classList.remove('show'); - }; - - // ========== 鐗堟湰绠$悊 ========== - // ========== 璺宠繃璺熶环 ASIN ========== - var skipPriceAsinPage = 1, skipPriceAsinPageSize = 15; - var chooseSkipPriceAsinShopPage = 1, chooseSkipPriceAsinShopPageSize = 10; - var skipPriceCountryColumns = [ - { code: 'DE', field: 'asin_de', label: '寰峰浗' }, - { code: 'UK', field: 'asin_uk', label: '鑻卞浗' }, - { code: 'FR', field: 'asin_fr', label: '娉曞浗' }, - { code: 'IT', field: 'asin_it', label: '鎰忓ぇ鍒? }, - { code: 'ES', field: 'asin_es', label: '瑗跨彮鐗? } - ]; - function buildChooseSkipPriceAsinShopQuery(page) { - var query = 'page=' + (page || 1) + '&page_size=' + chooseSkipPriceAsinShopPageSize; - var groupId = (document.getElementById('chooseSkipPriceAsinShopGroupId').value || '').trim(); - var shopName = (document.getElementById('chooseSkipPriceAsinShopKeyword').value || '').trim(); - if (groupId) query += '&group_id=' + encodeURIComponent(groupId); - if (shopName) query += '&shop_name=' + encodeURIComponent(shopName); - return query; - } - function loadChooseSkipPriceAsinShops(page) { - chooseSkipPriceAsinShopPage = page || 1; - fetch('/api/admin/shop-manages?' + buildChooseSkipPriceAsinShopQuery(chooseSkipPriceAsinShopPage)) - .then(function (r) { return r.json(); }) - .then(function (res) { - var tbody = document.getElementById('chooseSkipPriceAsinShopListBody'); - if (!res.success) { - tbody.innerHTML = '鍔犺浇澶辫触: ' + (res.error || '') + ''; - return; - } - var items = res.items || []; - if (!items.length) { - tbody.innerHTML = '鏆傛棤搴楅摵'; - } else { - tbody.innerHTML = items.map(function (item, index) { - var rowNo = (chooseSkipPriceAsinShopPage - 1) * chooseSkipPriceAsinShopPageSize + index + 1; - return '' + rowNo + '' + (item.group_name || '') + '' + (item.shop_name || '') + '' + (item.mall_name || '') + '' + (item.account || '') + '' + - '' + - ''; - }).join(''); - } - renderPagination('chooseSkipPriceAsinShopPagination', res.total, res.page, res.page_size, loadChooseSkipPriceAsinShops); - bindChooseSkipPriceAsinShopActions(); - }) - .catch(function () { - document.getElementById('chooseSkipPriceAsinShopListBody').innerHTML = '璇锋眰澶辫触'; - }); - } - function bindChooseSkipPriceAsinShopActions() { - document.querySelectorAll('[data-choose-skip-price-asin-shop]').forEach(function (btn) { - btn.onclick = function () { - document.getElementById('skipPriceAsinShopName').value = (btn.dataset.shopName || '').replace(/"/g, '"'); - if (!document.getElementById('skipPriceAsinGroupSelect').value && btn.dataset.groupId) { - document.getElementById('skipPriceAsinGroupSelect').value = btn.dataset.groupId; - } - document.getElementById('chooseSkipPriceAsinShopModal').classList.remove('show'); - }; - }); - } - function openChooseSkipPriceAsinShopModal() { - var currentGroupId = (document.getElementById('skipPriceAsinGroupSelect').value || '').trim(); - if (currentGroupId) { - document.getElementById('chooseSkipPriceAsinShopGroupId').value = currentGroupId; - } - document.getElementById('chooseSkipPriceAsinShopKeyword').value = (document.getElementById('skipPriceAsinShopName').value || '').trim(); - document.getElementById('chooseSkipPriceAsinShopModal').classList.add('show'); - loadChooseSkipPriceAsinShops(1); - } - function setupSkipPriceAsinShopPicker() { - var input = document.getElementById('skipPriceAsinShopName'); - var button = document.getElementById('btnChooseSkipPriceAsinShop'); - if (!input || !button) return; - var inputParent = input.parentNode; - if (inputParent && inputParent.style && inputParent.style.display === 'flex') return; - var legacyWrap = button.parentNode; - var wrapper = document.createElement('div'); - wrapper.style.display = 'flex'; - wrapper.style.gap = '8px'; - wrapper.style.alignItems = 'center'; - input.parentNode.insertBefore(wrapper, input); - wrapper.appendChild(input); - wrapper.appendChild(button); - button.style.whiteSpace = 'nowrap'; - button.style.marginTop = '0'; - if (legacyWrap && legacyWrap !== wrapper) { - legacyWrap.style.display = 'none'; - } - } - function getSelectedSkipPriceCountries() { - return Array.from(document.getElementById('skipPriceAsinCountries').selectedOptions).map(function (option) { - return option.value; - }); - } - function renderSkipPriceAsinInputs() { - var container = document.getElementById('skipPriceAsinInputs'); - var selectedCountries = getSelectedSkipPriceCountries(); - var existingValues = {}; - container.querySelectorAll('[data-skip-price-country-input]').forEach(function (input) { - existingValues[input.getAttribute('data-skip-price-country-input')] = input.value; - }); - if (!selectedCountries.length) { - container.innerHTML = '
璇烽€夋嫨鍥藉鍚庤緭鍏?ASIN
'; - return; - } - container.innerHTML = selectedCountries.map(function (countryCode) { - var country = skipPriceCountryColumns.find(function (item) { return item.code === countryCode; }); - var label = country ? country.label : countryCode; - var value = existingValues[countryCode] || ''; - return '
' + - '' + label + '' + - '' + - '
'; - }).join(''); - } - function collectSkipPriceAsinMappings(countries) { - var mappings = {}; - for (var i = 0; i < countries.length; i++) { - var countryCode = countries[i]; - var input = document.querySelector('[data-skip-price-country-input="' + countryCode + '"]'); - var asin = input ? (input.value || '').trim().toUpperCase() : ''; - if (!asin) { - var country = skipPriceCountryColumns.find(function (item) { return item.code === countryCode; }); - var label = country ? country.label : countryCode; - throw new Error(label + ' ASIN 涓嶈兘涓虹┖'); - } - mappings[countryCode] = asin; - } - return mappings; - } - function buildSkipPriceAsinQuery(page) { - var query = 'page=' + (page || 1) + '&page_size=' + skipPriceAsinPageSize; - var groupId = (document.getElementById('skipPriceAsinFilterGroupId').value || '').trim(); - var shopName = (document.getElementById('skipPriceAsinFilterShopName').value || '').trim(); - var asin = (document.getElementById('skipPriceAsinFilterAsin').value || '').trim(); - if (groupId) query += '&group_id=' + encodeURIComponent(groupId); - if (shopName) query += '&shop_name=' + encodeURIComponent(shopName); - if (asin) query += '&asin=' + encodeURIComponent(asin); - return query; - } - function renderSkipPriceAsinCell(item, country) { - var value = item[country.field] || ''; - if (!value) return '-'; - return '
' + - '' + value + '' + - '' + - '' + - '
'; - } - function bindSkipPriceAsinActions() { - document.querySelectorAll('[data-skip-price-asin-edit]').forEach(function (btn) { - btn.onclick = function () { - var shopName = (btn.dataset.shopName || '').replace(/"/g, '"'); - var countryCode = btn.dataset.country || ''; - var currentAsin = (btn.dataset.asin || '').replace(/"/g, '"'); - var nextAsin = prompt('璇疯緭鍏ュ簵閾衡€? + shopName + '鈥濆湪 ' + countryCode + ' 鐨?ASIN', currentAsin); - if (nextAsin === null) return; - nextAsin = (nextAsin || '').trim(); - if (!nextAsin) { - alert('ASIN 涓嶈兘涓虹┖'); - return; - } - fetch('/api/admin/skip-price-asin/' + btn.dataset.skipPriceAsinEdit + '/country/' + countryCode, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ asin: nextAsin }) - }) - .then(function (r) { return r.json(); }) - .then(function (res) { - if (res.success) loadSkipPriceAsin(skipPriceAsinPage); - else alert(res.error || '淇濆瓨澶辫触'); - }); - }; - }); - document.querySelectorAll('[data-skip-price-asin-delete]').forEach(function (btn) { - btn.onclick = function () { - var shopName = (btn.dataset.shopName || '').replace(/"/g, '"'); - var countryCode = btn.dataset.country || ''; - if (!confirm('纭畾鍒犻櫎搴楅摵鈥? + shopName + '鈥濆湪 ' + countryCode + ' 鐨?ASIN 鍚楋紵')) return; - fetch('/api/admin/skip-price-asin/' + btn.dataset.skipPriceAsinDelete + '/country/' + countryCode, { - method: 'DELETE' - }) - .then(function (r) { return r.json(); }) - .then(function (res) { - if (res.success) loadSkipPriceAsin(skipPriceAsinPage); - else alert(res.error || '鍒犻櫎澶辫触'); - }); - }; - }); - } - function loadSkipPriceAsin(page) { - skipPriceAsinPage = page || 1; - fetch('/api/admin/skip-price-asins?' + buildSkipPriceAsinQuery(skipPriceAsinPage)) - .then(function (r) { return r.json(); }) - .then(function (res) { - var tbody = document.getElementById('skipPriceAsinListBody'); - if (!res.success) { - tbody.innerHTML = '鍔犺浇澶辫触: ' + (res.error || '') + ''; - return; - } - var items = res.items || []; - if (items.length === 0) { - tbody.innerHTML = '鏆傛棤鏁版嵁'; - } else { - tbody.innerHTML = items.map(function (item, index) { - var rowNo = (skipPriceAsinPage - 1) * skipPriceAsinPageSize + index + 1; - return '' + rowNo + '' + (item.group_name || '') + '' + (item.shop_name || '') + '' + - skipPriceCountryColumns.map(function (country) { - return '' + renderSkipPriceAsinCell(item, country) + ''; - }).join('') + - ''; - }).join(''); - } - renderPagination('skipPriceAsinPagination', res.total, res.page, res.page_size, loadSkipPriceAsin); - bindSkipPriceAsinActions(); - }) - .catch(function () { - document.getElementById('skipPriceAsinListBody').innerHTML = '璇锋眰澶辫触'; - }); - } - document.getElementById('btnManageSkipPriceAsinGroups').onclick = openShopManageGroupModal; - document.getElementById('btnChooseSkipPriceAsinShop').onclick = openChooseSkipPriceAsinShopModal; - document.getElementById('btnSearchChooseSkipPriceAsinShop').onclick = function () { - loadChooseSkipPriceAsinShops(1); - }; - document.getElementById('chooseSkipPriceAsinShopGroupId').onchange = function () { - loadChooseSkipPriceAsinShops(1); - }; - document.getElementById('chooseSkipPriceAsinShopKeyword').addEventListener('keydown', function (e) { - if (e.key === 'Enter') { - e.preventDefault(); - loadChooseSkipPriceAsinShops(1); - } - }); - document.getElementById('btnCloseChooseSkipPriceAsinShopModal').onclick = function () { - document.getElementById('chooseSkipPriceAsinShopModal').classList.remove('show'); - }; - document.getElementById('skipPriceAsinCountries').addEventListener('change', renderSkipPriceAsinInputs); - document.getElementById('btnSearchSkipPriceAsin').onclick = function () { - loadSkipPriceAsin(1); - }; - document.getElementById('skipPriceAsinFilterShopName').addEventListener('keydown', function (e) { - if (e.key === 'Enter') { - e.preventDefault(); - loadSkipPriceAsin(1); - } - }); - document.getElementById('skipPriceAsinFilterAsin').addEventListener('keydown', function (e) { - if (e.key === 'Enter') { - e.preventDefault(); - loadSkipPriceAsin(1); - } - }); - document.getElementById('btnCreateSkipPriceAsin').onclick = function () { - var groupId = (document.getElementById('skipPriceAsinGroupSelect').value || '').trim(); - var shopName = (document.getElementById('skipPriceAsinShopName').value || '').trim(); - var countries = getSelectedSkipPriceCountries(); - var msgEl = document.getElementById('msgSkipPriceAsin'); - msgEl.textContent = ''; - msgEl.className = 'msg'; - if (!groupId || !shopName || !countries.length) { - msgEl.textContent = '璇峰畬鏁村~鍐欏垎缁勩€佸簵閾哄悕銆佸浗瀹跺拰 ASIN'; - msgEl.className = 'msg err'; - return; - } - var asinMappings = {}; - try { - asinMappings = collectSkipPriceAsinMappings(countries); - } catch (err) { - msgEl.textContent = err.message || '璇疯緭鍏?ASIN'; - msgEl.className = 'msg err'; - return; - } - var fallbackAsin = ''; - Object.keys(asinMappings).some(function (countryCode) { - fallbackAsin = asinMappings[countryCode] || ''; - return !!fallbackAsin; - }); - fetch('/api/admin/skip-price-asin', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - group_id: Number(groupId), - shop_name: shopName, - countries: countries, - asin: fallbackAsin, - asin_mappings: asinMappings - }) - }) - .then(function (r) { return r.json(); }) - .then(function (res) { - if (!res.success) { - msgEl.textContent = res.error || '淇濆瓨澶辫触'; - msgEl.className = 'msg err'; - return; - } - document.getElementById('skipPriceAsinGroupSelect').value = ''; - document.getElementById('skipPriceAsinShopName').value = ''; - Array.from(document.getElementById('skipPriceAsinCountries').options).forEach(function (option) { - option.selected = false; - }); - renderSkipPriceAsinInputs(); - msgEl.textContent = res.msg || '淇濆瓨鎴愬姛'; - msgEl.className = 'msg ok'; - loadSkipPriceAsin(1); - }) - .catch(function () { - msgEl.textContent = '璇锋眰澶辫触'; - msgEl.className = 'msg err'; - }); - }; - setupSkipPriceAsinShopPicker(); - renderSkipPriceAsinInputs(); - function loadVersions() { - fetch('/api/admin/versions') - .then(function (r) { return r.json(); }) - .then(function (res) { - var tbody = document.getElementById('versionListBody'); - if (!res.success) { - tbody.innerHTML = '鍔犺浇澶辫触: ' + (res.error || '') + ''; - return; - } - var items = res.items || []; - if (items.length === 0) { - tbody.innerHTML = '鏆傛棤鐗堟湰璁板綍'; - } else { - tbody.innerHTML = items.map(function (v) { - var url = (v.file_url || '').replace(/"/g, '"'); - return '' + (v.version || '') + '' + url + '' + (v.created_at || '') + '涓嬭浇'; - }).join(''); - } - }) - .catch(function () { - document.getElementById('versionListBody').innerHTML = '璇锋眰澶辫触'; - }); - } - document.getElementById('btnUploadVersion').onclick = function () { - var version = (document.getElementById('versionNumber').value || '').trim(); - var fileInput = document.getElementById('versionZip'); - var msgEl = document.getElementById('msgVersion'); - msgEl.textContent = ''; - msgEl.className = 'msg'; - if (!version) { - msgEl.textContent = '璇峰~鍐欑増鏈彿'; - msgEl.classList.add('err'); - return; - } - if (!fileInput.files || fileInput.files.length === 0) { - msgEl.textContent = '璇烽€夋嫨 zip 鍘嬬缉鍖?; - msgEl.classList.add('err'); - return; - } - var file = fileInput.files[0]; - if (!(file.name || '').toLowerCase().endsWith('.zip')) { - msgEl.textContent = '浠呮敮鎸?.zip 鏍煎紡'; - msgEl.classList.add('err'); - return; - } - var formData = new FormData(); - formData.append('version', version); - formData.append('file', file); - msgEl.textContent = '涓婁紶涓?..'; - msgEl.classList.remove('err', 'ok'); - fetch('/api/admin/version', { - method: 'POST', - body: formData - }) - .then(function (r) { return r.json(); }) - .then(function (res) { - if (res.success) { - msgEl.textContent = '鍙戝竷鎴愬姛銆傜増鏈細' + res.version + '锛岄摼鎺ワ細' + (res.file_url || ''); - msgEl.classList.add('ok'); - document.getElementById('versionNumber').value = ''; - fileInput.value = ''; - loadVersions(); - } else { - msgEl.textContent = res.error || '涓婁紶澶辫触'; - msgEl.classList.add('err'); - } - }) - .catch(function () { - msgEl.textContent = '璇锋眰澶辫触'; - msgEl.classList.add('err'); - }); - }; - - // ========== 鏍忕洰鏉冮檺閰嶇疆 ========== - function loadColumns() { - fetch('/api/admin/columns') - .then(function (r) { return r.json(); }) - .then(function (res) { - var tbody = document.getElementById('columnListBody'); - if (!res.success) { - tbody.innerHTML = '鍔犺浇澶辫触: ' + (res.error || '') + ''; - return; - } - allColumnsList = res.items || []; - if (allColumnsList.length === 0) { - tbody.innerHTML = '鏆傛棤鑿滃崟锛岃鍦ㄤ笂鏂规柊澧?/td>'; - } else { - tbody.innerHTML = allColumnsList.map(function (c) { - return '' + c.id + '' + (c.name || '') + '' + (c.column_key || '') + '' + (c.created_at || '') + '' + - ' ' + - ''; - }).join(''); - } - bindColumnActions(); - }) - .catch(function () { - document.getElementById('columnListBody').innerHTML = '璇锋眰澶辫触'; - }); - } - function bindColumnActions() { - document.querySelectorAll('[data-column-edit]').forEach(function (btn) { - btn.onclick = function () { - document.getElementById('editColumnId').value = btn.dataset.columnEdit || ''; - document.getElementById('editColumnName').value = (btn.dataset.name || '').replace(/"/g, '"'); - document.getElementById('editColumnKey').value = (btn.dataset.key || '').replace(/"/g, '"'); - document.getElementById('msgEditColumn').textContent = ''; - document.getElementById('editColumnModal').classList.add('show'); - }; - }); - document.querySelectorAll('[data-column-delete]').forEach(function (btn) { - btn.onclick = function () { - if (!confirm('纭畾鍒犻櫎鑿滃崟鈥? + (btn.dataset.name || '').replace(/"/g, '"') + '鈥濆悧锛?)) return; - fetch('/api/admin/column/' + btn.dataset.columnDelete, { method: 'DELETE' }) - .then(function (r) { return r.json(); }) - .then(function (res) { - if (res.success) { loadColumns(); loadColumnsForPermission(); loadAdminMenus(getActiveAdminTabName()); } - else { alert(res.error || '鍒犻櫎澶辫触'); } - }); - }; - }); - } - document.getElementById('btnAddColumn').onclick = function () { - var name = (document.getElementById('columnName').value || '').trim(); - var key = (document.getElementById('columnKey').value || '').trim(); - var msgEl = document.getElementById('msgColumn'); - msgEl.textContent = ''; - msgEl.className = 'msg'; - if (!name) { msgEl.textContent = '璇峰~鍐欒彍鍗曞悕绉?; msgEl.classList.add('err'); return; } - if (!key) { - msgEl.textContent = '璇峰~鍐欐爮鐩爣璇?; msgEl.classList.add('err'); return; - } - fetch('/api/admin/column', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: name, column_key: key }) - }) - .then(function (r) { return r.json(); }) - .then(function (res) { - if (res.success) { - msgEl.textContent = res.msg || '鏂板鎴愬姛'; - msgEl.classList.add('ok'); - document.getElementById('columnName').value = ''; - document.getElementById('columnKey').value = ''; - loadColumns(); - loadColumnsForPermission(); - loadAdminMenus(getActiveAdminTabName()); - } else { diff --git a/backend/web_source/admin.html b/backend/web_source/admin.html index ccfb20b..cc8d4f7 100644 --- a/backend/web_source/admin.html +++ b/backend/web_source/admin.html @@ -227,6 +227,65 @@ color: #666; } + .request-loading-bar { + position: fixed; + left: 0; + top: 0; + width: 0; + height: 3px; + background: #667eea; + opacity: 0; + z-index: 3000; + transition: width 0.2s ease, opacity 0.2s ease; + } + + .request-loading-bar.show { + width: 74%; + opacity: 1; + } + + .request-loading-bar.finishing { + width: 100%; + opacity: 0; + } + + .request-loading { + position: fixed; + right: 24px; + top: 72px; + display: none; + align-items: center; + gap: 10px; + padding: 10px 14px; + color: #333; + background: rgba(255, 255, 255, 0.96); + border: 1px solid #e6eaf2; + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + font-size: 13px; + z-index: 3000; + pointer-events: none; + } + + .request-loading.show { + display: flex; + } + + .request-spinner { + width: 16px; + height: 16px; + border: 2px solid #dce2ff; + border-top-color: #667eea; + border-radius: 50%; + animation: request-spin 0.8s linear infinite; + } + + @keyframes request-spin { + to { + transform: rotate(360deg); + } + } + table { width: 100%; border-collapse: collapse; @@ -336,6 +395,178 @@ font-size: 16px; } + #shopManageGroupModal .shop-group-modal { + width: min(1180px, calc(100vw - 48px)); + max-width: none; + max-height: calc(100vh - 48px); + padding: 0; + overflow: hidden; + display: flex; + flex-direction: column; + } + + .shop-group-modal-header { + padding: 20px 24px 14px; + border-bottom: 1px solid #edf0f5; + flex: 0 0 auto; + } + + .shop-group-modal-header h3 { + margin: 0; + } + + .shop-group-editor { + padding: 18px 24px 14px; + display: grid; + grid-template-columns: minmax(180px, 240px) minmax(240px, 320px) minmax(360px, 1fr); + gap: 16px; + align-items: start; + flex: 0 0 auto; + } + + .shop-group-editor .form-group { + margin-bottom: 0; + } + + .shop-group-member-select { + min-height: 128px; + max-height: 180px; + } + + .shop-group-help { + color: #777; + font-size: 12px; + line-height: 1.5; + margin-top: 6px; + } + + .shop-group-action-bar { + padding: 0 24px 12px; + display: flex; + gap: 8px; + justify-content: flex-end; + align-items: center; + flex: 0 0 auto; + } + + #msgShopManageGroup { + margin: 0; + padding: 0 24px 10px; + min-height: 18px; + flex: 0 0 auto; + } + + .shop-group-table-wrap { + margin: 0 24px; + border: 1px solid #edf0f5; + border-radius: 8px; + overflow: auto; + min-height: 220px; + flex: 1 1 auto; + } + + .shop-group-table { + min-width: 1040px; + table-layout: fixed; + } + + .shop-group-table th { + position: sticky; + top: 0; + z-index: 1; + } + + .shop-group-table th, + .shop-group-table td { + vertical-align: top; + } + + .shop-group-table .col-index { + width: 58px; + } + + .shop-group-table .col-name { + width: 130px; + } + + .shop-group-table .col-leader { + width: 110px; + } + + .shop-group-table .col-count { + width: 86px; + } + + .shop-group-table .col-members { + width: auto; + } + + .shop-group-table .col-time { + width: 120px; + } + + .shop-group-table .col-action { + width: 128px; + } + + .shop-group-time-cell, + .shop-group-action-cell { + white-space: nowrap; + } + + .shop-group-name-cell, + .shop-group-leader-cell { + word-break: break-word; + } + + .shop-group-members { + display: flex; + flex-wrap: wrap; + gap: 6px; + max-height: 96px; + overflow-y: auto; + padding-right: 4px; + } + + .shop-group-member-chip { + display: inline-flex; + align-items: center; + max-width: 150px; + padding: 3px 8px; + border-radius: 999px; + background: #eef1ff; + color: #4f63d8; + font-size: 12px; + line-height: 1.4; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .shop-group-modal-footer { + padding: 14px 24px 18px; + display: flex; + gap: 8px; + justify-content: flex-end; + border-top: 1px solid #edf0f5; + flex: 0 0 auto; + } + + @media (max-width: 900px) { + #shopManageGroupModal .shop-group-modal { + width: calc(100vw - 24px); + max-height: calc(100vh - 24px); + } + + .shop-group-editor { + grid-template-columns: 1fr; + } + + .shop-group-table-wrap { + margin: 0 16px; + } + } + .column-permission-cards { display: flex; flex-wrap: wrap; @@ -347,6 +578,97 @@ overflow: hidden; } + .multi-select-native { + position: absolute; + opacity: 0; + pointer-events: none; + width: 1px !important; + height: 1px !important; + min-height: 0 !important; + padding: 0 !important; + border: 0 !important; + } + + .multi-select-dropdown { + position: relative; + width: 100%; + } + + .multi-select-trigger { + width: 100%; + min-height: 42px; + padding: 10px 34px 10px 12px; + border: 1px solid #ddd; + border-radius: 6px; + background: #fff; + color: #333; + font-size: 14px; + text-align: left; + cursor: pointer; + position: relative; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .multi-select-trigger::after { + content: ""; + position: absolute; + right: 12px; + top: 50%; + width: 7px; + height: 7px; + border-right: 1.5px solid #666; + border-bottom: 1.5px solid #666; + transform: translateY(-65%) rotate(45deg); + } + + .multi-select-dropdown.open .multi-select-trigger { + border-color: #667eea; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.12); + } + + .multi-select-panel { + display: none; + position: absolute; + left: 0; + right: 0; + top: calc(100% + 6px); + z-index: 30; + background: #fff; + border: 1px solid #ddd; + border-radius: 8px; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.14); + padding: 6px; + max-height: 220px; + overflow-y: auto; + } + + .multi-select-dropdown.open .multi-select-panel { + display: block; + } + + .multi-select-option { + display: flex; + align-items: center; + gap: 8px; + min-height: 34px; + padding: 7px 8px; + border-radius: 6px; + cursor: pointer; + font-size: 14px; + color: #333; + } + + .multi-select-option:hover { + background: #f4f6ff; + } + + .multi-select-option input { + width: auto; + margin: 0; + } + .column-permission-card { display: inline-flex; align-items: center; @@ -398,6 +720,11 @@ +
+
+ + 请求处理中... +

管理后台

@@ -762,7 +1089,7 @@
- @@ -779,6 +1106,42 @@

+
+
+

导入文件新增

+
+
+ + +
+ +
+

+ +
+
+

导入文件删除

+
+
+ + +
+ +
+

+ +
+

店铺列表

@@ -840,7 +1203,7 @@
- @@ -1063,48 +1426,62 @@