Compare commits
209 Commits
aeb4e1710c
...
backup/bac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9835831415 | ||
|
|
080625567b | ||
|
|
266c0f17c1 | ||
|
|
225d13fb6e | ||
|
|
2ed1250604 | ||
|
|
ca4a2cd07a | ||
|
|
73ac9187a6 | ||
|
|
7503e3fa8b | ||
|
|
532438faba | ||
|
|
a1376b51b0 | ||
|
|
1e087c1aae | ||
|
|
2e2de02476 | ||
|
|
b4b80cb572 | ||
|
|
14cebf22c3 | ||
|
|
01ecde45c5 | ||
|
|
1dc2cac19a | ||
|
|
01f4dbb83a | ||
|
|
fe692fcd1d | ||
|
|
18b0c2d211 | ||
|
|
648e7d2f14 | ||
|
|
289b1c67ce | ||
|
|
52095eb992 | ||
|
|
a2d0bd3c1c | ||
|
|
683a3934fc | ||
|
|
b0514c3e9a | ||
|
|
8be12ca9bc | ||
|
|
afeeb8327c | ||
|
|
4d2da94692 | ||
|
|
b746812e23 | ||
|
|
3fbbf87c10 | ||
|
|
c3810fdb7b | ||
|
|
b16c63225f | ||
|
|
45a45dc0fe | ||
|
|
c315bb350c | ||
|
|
6d398b66bc | ||
|
|
934919c699 | ||
|
|
12d84a22e4 | ||
|
|
dc457aff9e | ||
|
|
578847d596 | ||
|
|
6416c14f20 | ||
|
|
add6e6aeb5 | ||
|
|
7968cef8c3 | ||
|
|
34148b5d8a | ||
|
|
79e408333d | ||
|
|
74a7cd22b0 | ||
|
|
e1cca219b8 | ||
|
|
4419c5bacd | ||
|
|
4eec0dd5a4 | ||
|
|
8e60f50616 | ||
|
|
f85e3be40e | ||
|
|
7f9116f12a | ||
|
|
11f0d2c745 | ||
|
|
955a6439a0 | ||
|
|
172917ac41 | ||
|
|
a5f6898d06 | ||
|
|
83bc90ecf2 | ||
|
|
cbfc29e953 | ||
|
|
b66465aa6a | ||
|
|
fbd1a4dbea | ||
|
|
be8a8492a9 | ||
|
|
b352eea21d | ||
|
|
51bf0aaa8c | ||
|
|
28a752265c | ||
|
|
1d9d138423 | ||
|
|
8c5edc6ad6 | ||
|
|
ee6fb5d33a | ||
|
|
cd2b437be5 | ||
|
|
a504e7a13b | ||
|
|
325687d532 | ||
|
|
9760d1171c | ||
|
|
d42ed57119 | ||
|
|
18208f691c | ||
|
|
c06a4d0e06 | ||
|
|
f21934e55b | ||
|
|
515135c120 | ||
|
|
42bdd59c71 | ||
|
|
8be85fd332 | ||
|
|
e4bf104ae2 | ||
|
|
5105bf7049 | ||
|
|
08997e9e20 | ||
|
|
9146d17625 | ||
|
|
1aca3d6152 | ||
|
|
052bb31aea | ||
|
|
225b525ba1 | ||
|
|
e1543416e3 | ||
|
|
0b57aea5f6 | ||
|
|
a04c2f9a19 | ||
|
|
c352c34501 | ||
|
|
5c9671df44 | ||
|
|
8a8d3c5cd9 | ||
|
|
d272afae1c | ||
|
|
03e697f5d3 | ||
|
|
c502afb588 | ||
|
|
3fada5d198 | ||
|
|
c0fdea6570 | ||
|
|
0391cb223f | ||
|
|
b25111e4b4 | ||
|
|
2f2db4986e | ||
|
|
6f970b3783 | ||
|
|
95d5c82474 | ||
|
|
87507708ce | ||
|
|
c6ae7ca170 | ||
|
|
6894f9cc57 | ||
|
|
524d8763ce | ||
|
|
1e845a1510 | ||
|
|
0341838d19 | ||
|
|
ea35273597 | ||
|
|
72c8167472 | ||
|
|
9b1138c83e | ||
|
|
dc5e23892f | ||
|
|
4f8cbc3e38 | ||
|
|
3ffbb2b004 | ||
|
|
34ed15a9bd | ||
|
|
4b6295dd44 | ||
|
|
01116d5607 | ||
|
|
07904a29aa | ||
|
|
8d683a791d | ||
|
|
ef0e0df0ac | ||
|
|
ac07416352 | ||
|
|
025ca6d4fd | ||
| 951a353881 | |||
|
|
eb04caccf1 | ||
|
|
cd84def61d | ||
|
|
d8098b0378 | ||
|
|
62d30ec190 | ||
|
|
d0d3c6ee67 | ||
|
|
21b6b5b270 | ||
|
|
365050b890 | ||
|
|
73392a9b83 | ||
|
|
c47c03fde4 | ||
|
|
86fd9475e4 | ||
|
|
2d0d1d3461 | ||
|
|
ccaec4a984 | ||
|
|
169ba7edb6 | ||
|
|
0327d1cc51 | ||
|
|
e02ddab599 | ||
|
|
336338cff5 | ||
|
|
acff31e652 | ||
|
|
ad304a6880 | ||
|
|
cb34788e53 | ||
|
|
13b0ffb5d8 | ||
|
|
c9947c4ac8 | ||
|
|
afffb7aad2 | ||
|
|
9720453ac4 | ||
|
|
1ff6d5220e | ||
|
|
46d91fd8ca | ||
|
|
a24db2f73c | ||
|
|
ca9537f862 | ||
|
|
94ae7e68ff | ||
|
|
94e1930538 | ||
|
|
5bbe5a3077 | ||
|
|
8cead01270 | ||
|
|
99fc14b9f9 | ||
|
|
d3671206e1 | ||
|
|
0a8202788b | ||
|
|
855f1affd9 | ||
|
|
5a294e5c85 | ||
|
|
df606a1087 | ||
|
|
ece3552b89 | ||
|
|
4df3945131 | ||
|
|
6eb0f33421 | ||
|
|
25ce9f74b8 | ||
|
|
0f1f471590 | ||
|
|
d9487b6885 | ||
|
|
9845ab8f2a | ||
|
|
6fa130386a | ||
|
|
53df8b9971 | ||
|
|
d6625762cf | ||
|
|
cb3b9e9cfe | ||
|
|
3d39236b13 | ||
|
|
9e57fa5762 | ||
|
|
9c6232bc85 | ||
|
|
d3c7938627 | ||
|
|
06256946fa | ||
|
|
2042d386e2 | ||
|
|
7176754564 | ||
|
|
a20222192e | ||
|
|
a5c2681e88 | ||
|
|
5e2f750359 | ||
|
|
27602890ab | ||
|
|
dfe0d652ce | ||
|
|
24076adf98 | ||
|
|
15a78abc1b | ||
|
|
7fcdf1a761 | ||
|
|
d0ca28ae2d | ||
|
|
32a9494dd6 | ||
|
|
a5da06537e | ||
|
|
21c6f41c69 | ||
|
|
506eb0faef | ||
|
|
485b4dd485 | ||
|
|
4f728107d1 | ||
|
|
01e491e332 | ||
|
|
ed91c2ae7d | ||
|
|
55ac81f0cc | ||
|
|
64e32523cd | ||
|
|
5b636c3dfd | ||
|
|
c5d62c71a3 | ||
|
|
fa384a6ec8 | ||
|
|
24cf47e1e3 | ||
|
|
8e66926b9e | ||
|
|
574b372f42 | ||
|
|
74a5fb9ce1 | ||
|
|
4b83bf76b4 | ||
|
|
82ccd9a8ea | ||
|
|
2dbeec7d6d | ||
|
|
fb32f7bd35 | ||
|
|
63d3e4047c | ||
|
|
7c6d9dd989 | ||
|
|
ce121cf0d0 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -43,6 +43,7 @@ MANIFEST
|
||||
.installed.cfg
|
||||
|
||||
# Build / packaging
|
||||
app.zip
|
||||
build/
|
||||
dist/
|
||||
develop-eggs/
|
||||
@@ -108,4 +109,4 @@ xlsx/
|
||||
OPS_REDIS_MYSQL_OPTIMIZATION_NOTES.md
|
||||
架构.md
|
||||
*ts.%
|
||||
.omc
|
||||
.omc
|
||||
|
||||
7818
2026_05_29.log
Normal file
7818
2026_05_29.log
Normal file
File diff suppressed because one or more lines are too long
9
app/.env
9
app/.env
@@ -11,9 +11,12 @@ 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://121.196.149.225:18080
|
||||
# java_api_base=http://api.aishufu.top:18080/
|
||||
# java_api_base=http://api.aishufu.top:18080/
|
||||
java_api_base=http://127.0.0.1:18080/
|
||||
|
||||
# 与 Java 后端共享的 JWT 签名密钥,必须与 backend-java 的 AIIMAGE_JWT_SECRET 完全一致
|
||||
AIIMAGE_JWT_SECRET=please-change-this-secret-please-rotate-at-least-32-bytes
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ proxy_mode=2
|
||||
client_name=ShuFuAI
|
||||
|
||||
|
||||
java_api_base=http://127.0.0.1:18080
|
||||
# java_api_base=http://47.111.163.154:18080
|
||||
java_api_base=http://api.aishufu.top:18080/
|
||||
# java_api_base=http://api.aishufu.top:18080/
|
||||
|
||||
|
||||
|
||||
@@ -11,8 +11,13 @@ 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://api.aishufu.top:18080/
|
||||
# java_api_base=http://api.aishufu.top:18080/
|
||||
# java_api_base=http://api.aishufu.top:18080/
|
||||
|
||||
# 与 Java 后端共享的 JWT 签名密钥,必须与 backend-java 的 AIIMAGE_JWT_SECRET 完全一致
|
||||
AIIMAGE_JWT_SECRET=please-change-this-secret-please-rotate-at-least-32-bytes
|
||||
# JWT cookie 名称,默认 aiimage_token;改动需与 Java 端 aiimage.auth.cookie-name 保持一致
|
||||
# AIIMAGE_AUTH_COOKIE_NAME=aiimage_token
|
||||
|
||||
|
||||
|
||||
@@ -417,8 +417,8 @@ class AmamzonBase(ZiniaoDriver):
|
||||
"""
|
||||
try:
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
if level == "ERROR":
|
||||
show_notification(message, "error")
|
||||
# if level == "ERROR":
|
||||
# show_notification(message, "error")
|
||||
print(f"[{timestamp}] [{self.mark_name}] [{level}] {message}")
|
||||
except Exception as e:
|
||||
print(f"输出出错,{e}")
|
||||
@@ -689,8 +689,8 @@ class TaskBase:
|
||||
"""
|
||||
try:
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
if level == "ERROR":
|
||||
show_notification(message, "error")
|
||||
# if level == "ERROR":
|
||||
# show_notification(message, "error")
|
||||
print(f"[{timestamp}] [{self.task_name}] [{level}] {message}")
|
||||
except Exception as e:
|
||||
print(f"输出出错,{e}")
|
||||
@@ -739,7 +739,7 @@ class TaskBase:
|
||||
if not shop_data:
|
||||
mes = f"获取店铺凭证失败,响应数据: {shop_data.get('message', '未知错误')}"
|
||||
self.log(mes, "ERROR")
|
||||
show_notification(mes, "ERROR")
|
||||
# show_notification(mes, "ERROR")
|
||||
continue
|
||||
|
||||
password = shop_data["data"]["password"]
|
||||
|
||||
@@ -308,8 +308,9 @@ class AmzoneApprove(AmamzonBase):
|
||||
self.log("开始执行...")
|
||||
num = 0
|
||||
retry_num = 0
|
||||
|
||||
max_retry_num = 5
|
||||
total_page = 0
|
||||
current_page = 0
|
||||
while retry_num < max_retry_num: # 最多重试3次
|
||||
# if num > 3: #测试
|
||||
# return
|
||||
@@ -325,13 +326,13 @@ class AmzoneApprove(AmamzonBase):
|
||||
page_pamel = self.tab.eles('xpath://kat-pagination',timeout=5)
|
||||
if len(page_pamel) > 0:
|
||||
current_page = page_pamel[0].sr('xpath:.//ul[@class="pages"]//li[@aria-current="true"]').text
|
||||
current_page = int(current_page.strip())
|
||||
|
||||
# 总页数
|
||||
total_page = page_pamel[0].sr.eles('xpath:.//ul[@class="pages"]//span[@class="page__inner"][last()]')
|
||||
if len(total_page) > 0:
|
||||
total_page = total_page[-1].text
|
||||
else:
|
||||
total_page = 0
|
||||
total_page = int(total_page[-1].text.strip())
|
||||
|
||||
self.log(f"当前页码: {current_page} / 总页数: {total_page}")
|
||||
except Exception as e:
|
||||
self.log(f"获取页码失败:{e}")
|
||||
@@ -543,6 +544,14 @@ class AmzoneApprove(AmamzonBase):
|
||||
self.tab.refresh()
|
||||
self.tab.wait.doc_loaded(raise_err=False,timeout=120)
|
||||
|
||||
#检查页数是否相等,不相等则继续
|
||||
self.log(f"开始检查页数,当前页数 {current_page} / {total_page}")
|
||||
if total_page != 0 and current_page!= 0 and current_page < total_page:
|
||||
self.log(f"检查到页数还未完成,重启浏览器继续")
|
||||
raise RuntimeError(f"与页面的连接已断开,检查到页数还未完成,重启浏览器继续")
|
||||
|
||||
|
||||
|
||||
class ApproveTask(TaskBase):
|
||||
"""审批任务处理类:负责处理产品风险审批任务"""
|
||||
task_name = "产品风险审批-TASK"
|
||||
@@ -674,12 +683,12 @@ class ApproveTask(TaskBase):
|
||||
return
|
||||
|
||||
current_url = None
|
||||
max_retries = 200 #只要没有完成,一直重试
|
||||
for _ in range(max_retries):
|
||||
try:
|
||||
self.process_country(driver, country_code, task_id, shop_name,risk_listing_filter,target_url=current_url)
|
||||
driver.reset_already_asin()
|
||||
driver.close_store()
|
||||
|
||||
break
|
||||
except Exception as e:
|
||||
self.log(f"处理国家 {country_code} 失败: {str(e)}", "ERROR")
|
||||
@@ -777,7 +786,7 @@ class ApproveTask(TaskBase):
|
||||
}
|
||||
result.append(country_data)
|
||||
|
||||
if len(result) > 20:
|
||||
if len(result) > 10:
|
||||
self.post_result_batch(task_id, shop_name, country_code, result)
|
||||
result = []
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from datetime import datetime
|
||||
from DrissionPage import Chromium, ChromiumOptions
|
||||
from collections import defaultdict
|
||||
|
||||
from config import base_dir
|
||||
from config import base_dir,debug
|
||||
from amazon.tool import get_shop_info,show_notification
|
||||
|
||||
|
||||
@@ -46,7 +46,8 @@ class ChromeAmzoneBase:
|
||||
杀死当前谷歌浏览器进程,并使用 drissionpage 启动谷歌浏览器,使用系统安装的浏览器默认用户文件夹
|
||||
"""
|
||||
print("正在关闭现有的chromium浏览器进程...")
|
||||
os.system('taskkill /f /t /im chrome.exe')
|
||||
if not debug:
|
||||
os.system('taskkill /f /t /im chrome.exe')
|
||||
time.sleep(2)
|
||||
|
||||
print("正在启动chromium浏览器...")
|
||||
@@ -70,8 +71,8 @@ class ChromeAmzoneBase:
|
||||
"""
|
||||
try:
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
if level == "ERROR":
|
||||
show_notification(message, "error")
|
||||
# if level == "ERROR":
|
||||
# show_notification(message, "error")
|
||||
print(f"[{timestamp}] [{self.mark_name}] [{level}] {message}")
|
||||
except Exception as e:
|
||||
print(f"输出出错,{e}")
|
||||
@@ -192,4 +193,14 @@ class ChromeAmzoneBase:
|
||||
self.browser.quit()
|
||||
print("浏览器已关闭")
|
||||
except Exception as e:
|
||||
print(f"关闭浏览器时出错: {str(e)}")
|
||||
print(f"关闭浏览器时出错: {str(e)}")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from amazon.amazon_base import TaskBase
|
||||
from amazon.chrome_base import ChromeAmzoneBase
|
||||
|
||||
|
||||
from config import runing_task, runing_shop,base_dir,DELETE_BRAND_API_BASE
|
||||
from config import runing_task,runing_shop,base_dir,DELETE_BRAND_API_BASE
|
||||
|
||||
|
||||
class ChromeAmzone(ChromeAmzoneBase):
|
||||
@@ -79,7 +79,8 @@ class ChromeAmzone(ChromeAmzoneBase):
|
||||
except Exception as e:
|
||||
error_msg = f"运行出错: {traceback.format_exc()}"
|
||||
self.log(error_msg)
|
||||
show_notification(f"采集失败: {str(e)}", "error")
|
||||
# show_notification(f"采集失败: {str(e)}", "error")
|
||||
raise RuntimeError(error_msg)
|
||||
return {}
|
||||
|
||||
def _scrape_data(self):
|
||||
@@ -91,7 +92,8 @@ class ChromeAmzone(ChromeAmzoneBase):
|
||||
"""
|
||||
data = {
|
||||
'image_url': "",
|
||||
'title': ""
|
||||
'title': "",
|
||||
"sku" : ""
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -100,9 +102,41 @@ class ChromeAmzone(ChromeAmzoneBase):
|
||||
title_ele = self.tab.ele('xpath://h1[@id="title"]',timeout=30)
|
||||
title = title_ele.text
|
||||
data["title"] = title
|
||||
sku_ele_ls = self.tab.eles('xpath://ul[@class="a-unordered-list a-vertical a-spacing-mini"]', timeout=20)
|
||||
if len(sku_ele_ls) > 0:
|
||||
data["sku"] = sku_ele_ls[0].text
|
||||
imge_ele = self.tab.ele('xpath://div[@id="imgTagWrapperId"]//img',timeout=20)
|
||||
image_url = imge_ele.attr("src")
|
||||
|
||||
image_url = ""
|
||||
min_image_url = ""
|
||||
data_a_dynamic_image = imge_ele.attr("data-a-dynamic-image")
|
||||
if data_a_dynamic_image:
|
||||
dynamic_image_json = json.loads(data_a_dynamic_image)
|
||||
self.log(f"图片信息:{dynamic_image_json}")
|
||||
max_area = 0
|
||||
min_area = 0
|
||||
|
||||
for url, (width, height) in dynamic_image_json.items():
|
||||
area = width * height
|
||||
if area > max_area:
|
||||
max_area = area
|
||||
image_url = url
|
||||
if min_area == 0:
|
||||
min_area = area
|
||||
min_image_url = url
|
||||
if area < min_area:
|
||||
min_area = area
|
||||
min_image_url = url
|
||||
|
||||
if not image_url:
|
||||
image_url = imge_ele.attr("src")
|
||||
|
||||
if not min_image_url:
|
||||
min_image_url = imge_ele.attr("src")
|
||||
|
||||
data["image_url"] = image_url
|
||||
data["min_image_url"] = min_image_url
|
||||
|
||||
return data
|
||||
|
||||
except Exception as e:
|
||||
@@ -246,6 +280,7 @@ class SpiderTask(TaskBase):
|
||||
"country": i.get("country"),
|
||||
"url": return_data.get("image_url"),
|
||||
"title": return_data.get("title"),
|
||||
"sku": return_data.get("sku")
|
||||
}
|
||||
for i in items
|
||||
]
|
||||
@@ -263,7 +298,7 @@ class SpiderTask(TaskBase):
|
||||
result.append(res)
|
||||
print("================")
|
||||
is_done = gp_index == len(groups)-1
|
||||
if len(result) > 20 or is_done:
|
||||
if len(result) > 10 or is_done:
|
||||
self.post_result(task_id=task_id,chunkIndex=gp_index+1,chunkTotal=len(groups),
|
||||
asin=asin,item_data=result,is_done=is_done)
|
||||
result = []
|
||||
|
||||
@@ -35,9 +35,12 @@ class AmzoneMatchAction(AmamzonBase):
|
||||
self.log("开始执行...")
|
||||
num = 0
|
||||
retry_num = 0
|
||||
get_page_faild = 0
|
||||
|
||||
while retry_num < 3: # 最多重试3次
|
||||
max_retry_num = 5
|
||||
total_page = 0
|
||||
current_page = 0
|
||||
|
||||
while retry_num < max_retry_num:
|
||||
# if num > 3: #测试
|
||||
# return
|
||||
# 等待加载完成
|
||||
@@ -45,6 +48,7 @@ class AmzoneMatchAction(AmamzonBase):
|
||||
load_ele = self.tab.eles("xpath://div[contains(@class,'Loader-module__loader')]",timeout=5)
|
||||
if len(load_ele) > 0:
|
||||
load_ele[0].wait.deleted(timeout=3, raise_err=False)
|
||||
|
||||
time.sleep(0.5)
|
||||
|
||||
# 获取当前页码
|
||||
@@ -52,23 +56,17 @@ class AmzoneMatchAction(AmamzonBase):
|
||||
page_pamel = self.tab.eles('xpath://kat-pagination',timeout=5)
|
||||
if len(page_pamel) > 0:
|
||||
current_page = page_pamel[0].sr('xpath:.//ul[@class="pages"]//li[@aria-current="true"]').text
|
||||
current_page = int(current_page.strip())
|
||||
# 测试
|
||||
# if current_page > 1:
|
||||
# break
|
||||
# 总页数
|
||||
total_page = page_pamel[0].sr.eles('xpath:.//ul[@class="pages"]//span[@class="page__inner"][last()]')
|
||||
if len(total_page) > 0:
|
||||
total_page = total_page[-1].text
|
||||
else:
|
||||
total_page = 0
|
||||
total_page = int(total_page[-1].text.strip())
|
||||
self.log(f"【{self.mark_name}】当前页码: {current_page} / 总页数: {total_page}")
|
||||
get_page_faild = 0
|
||||
except Exception as e:
|
||||
self.log(f"【{self.mark_name}】获取页码失败", e)
|
||||
get_page_faild+= 1
|
||||
if get_page_faild > 2:
|
||||
show_notification(f"【{self.mark_name}】获取页码失败超3次停止任务!")
|
||||
break
|
||||
|
||||
# 保存当前URL,用于失败重试时访问
|
||||
try:
|
||||
@@ -81,6 +79,15 @@ class AmzoneMatchAction(AmamzonBase):
|
||||
|
||||
sku_ls = self.tab.eles("xpath://div[@data-sku]",timeout=10)
|
||||
self.log(f"【{self.mark_name}】获取到 {len(sku_ls)}")
|
||||
if len(sku_ls) == 0:
|
||||
retry_num += 1
|
||||
self.log(f"没有获取到SKU列表,重试{retry_num}/{max_retry_num}")
|
||||
self.tab.refresh()
|
||||
self.tab.wait.doc_loaded(raise_err=False, timeout=120)
|
||||
if retry_num == max_retry_num:
|
||||
raise RuntimeError(f"与页面的连接已断开,没有获取到SKU列表,重试{retry_num}/{max_retry_num}")
|
||||
continue
|
||||
|
||||
# for sku_ele in sku_ls[0:2]:
|
||||
for sku_ele in sku_ls:
|
||||
# solve_problem = sku_ele.eles('xpath:.//kat-link[@label="解决商品信息问题"]')
|
||||
@@ -140,6 +147,12 @@ class AmzoneMatchAction(AmamzonBase):
|
||||
self.tab.refresh()
|
||||
self.tab.wait.doc_loaded(raise_err=False,timeout=120)
|
||||
|
||||
#检查页数是否相等,不相等则继续
|
||||
self.log(f"开始检查页数,当前页数 {current_page} / {total_page}")
|
||||
if total_page != 0 and current_page!= 0 and current_page < total_page:
|
||||
self.log(f"检查到页数还未完成,重启浏览器继续")
|
||||
raise RuntimeError(f"与页面的连接已断开,检查到页数还未完成,重启浏览器继续")
|
||||
|
||||
|
||||
class MatchTak(TaskBase):
|
||||
task_name = "匹配价格-TASK"
|
||||
@@ -261,7 +274,7 @@ class MatchTak(TaskBase):
|
||||
self.log(f"检测到任务 {task_id} 的暂停请求,停止处理国家", "WARNING")
|
||||
break
|
||||
|
||||
skip_asin = skip_asins_by_country.get(country_code)
|
||||
skip_asin = skip_asins_by_country.get(country_code,[])
|
||||
if skipAsinDetailsByCountry :
|
||||
skipAsinDetails = {i.get("asin"):i.get("minimumPrice") for i in skipAsinDetailsByCountry.get(country_code)}
|
||||
else:
|
||||
@@ -276,6 +289,7 @@ class MatchTak(TaskBase):
|
||||
return
|
||||
|
||||
current_url = None
|
||||
max_retries = 200
|
||||
for _ in range(max_retries):
|
||||
try:
|
||||
self.process_country(driver, country_code, task_id, shop_name,risk_listing_filter,limit,
|
||||
@@ -366,8 +380,8 @@ class MatchTak(TaskBase):
|
||||
if task_id in runing_task:
|
||||
runing_task[task_id]["current_asin"] = asin
|
||||
runing_task[task_id]["processed_asins"] += 1
|
||||
|
||||
runing_task[task_id]["failed_count"] += 1
|
||||
|
||||
minimumPrice = ""
|
||||
if "有最低价跳过" in status:
|
||||
minimumPrice = skipAsinDetails.get(asin)
|
||||
@@ -378,7 +392,7 @@ class MatchTak(TaskBase):
|
||||
"done": False
|
||||
})
|
||||
|
||||
if len(result) > 20:
|
||||
if len(result) > 10:
|
||||
self.post_result_batch(task_id, shop_name, country_code,result)
|
||||
result = []
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -10,9 +10,10 @@ from datetime import datetime
|
||||
from collections import defaultdict
|
||||
import requests
|
||||
from urllib.parse import quote
|
||||
|
||||
import os
|
||||
from curl_cffi import requests as requests_frp
|
||||
|
||||
from config import base_dir
|
||||
|
||||
|
||||
from amazon.tool import show_notification,get_shop_info,remove_special_characters,split_currency_values
|
||||
@@ -76,7 +77,9 @@ class ChromeAmzone(ChromeAmzoneBase):
|
||||
将图片链接(URL 或本地文件路径)转换为 Base64 编码的字符串。
|
||||
"""
|
||||
if image_source.startswith(('http://', 'https://')):
|
||||
response = requests.get(image_source, timeout=10)
|
||||
response = requests.get(image_source, timeout=10,headers={
|
||||
"user-agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36 Edg/142.0.0.0 18444"
|
||||
})
|
||||
response.raise_for_status() # 非 2xx 状态码将抛出异常
|
||||
image_data = response.content
|
||||
else:
|
||||
@@ -153,7 +156,7 @@ class ChromeAmzone(ChromeAmzoneBase):
|
||||
# 发送第一次请求
|
||||
try:
|
||||
response = requests_frp.post(url, headers=headers, params=params, json=data, impersonate="chrome101",
|
||||
cookies=cookie, proxies=proxies)
|
||||
cookies=cookie, proxies=proxies,verify=False)
|
||||
response.encoding = "utf-8"
|
||||
|
||||
# 检查响应状态码和数据有效性
|
||||
@@ -176,6 +179,7 @@ class ChromeAmzone(ChromeAmzoneBase):
|
||||
need_retry = True
|
||||
|
||||
# 如果需要重试且配置了代理URL
|
||||
# need_retry = True
|
||||
if need_retry and CONFIG_PROXY_URL and not proxies:
|
||||
try:
|
||||
proxy_resp = requests.get(CONFIG_PROXY_URL, timeout=10)
|
||||
@@ -204,7 +208,7 @@ class ChromeAmzone(ChromeAmzoneBase):
|
||||
if proxy_ip:
|
||||
proxies = {
|
||||
"http": f"http://{proxy_ip}",
|
||||
"https": f"http://{proxy_ip}",
|
||||
"https": f"https://{proxy_ip}",
|
||||
}
|
||||
|
||||
if proxies:
|
||||
@@ -251,8 +255,37 @@ class ChromeAmzone(ChromeAmzoneBase):
|
||||
title = title_ele.text
|
||||
data["title"] = title
|
||||
imge_ele = self.tab.ele('xpath://div[@id="imgTagWrapperId"]//img',timeout=20)
|
||||
image_url = imge_ele.attr("src")
|
||||
|
||||
image_url = ""
|
||||
min_image_url = ""
|
||||
data_a_dynamic_image = imge_ele.attr("data-a-dynamic-image")
|
||||
if data_a_dynamic_image:
|
||||
dynamic_image_json = json.loads(data_a_dynamic_image)
|
||||
self.log(f"图片信息:{dynamic_image_json}")
|
||||
max_area = 0
|
||||
min_area = 0
|
||||
|
||||
for url, (width, height) in dynamic_image_json.items():
|
||||
area = width * height
|
||||
if area > max_area:
|
||||
max_area = area
|
||||
image_url = url
|
||||
if min_area == 0:
|
||||
min_area = area
|
||||
min_image_url = url
|
||||
if area < min_area:
|
||||
min_area = area
|
||||
min_image_url = url
|
||||
|
||||
if not image_url:
|
||||
image_url = imge_ele.attr("src")
|
||||
|
||||
if not min_image_url:
|
||||
min_image_url = imge_ele.attr("src")
|
||||
|
||||
|
||||
data["image_url"] = image_url
|
||||
data["min_image_url"] = min_image_url
|
||||
|
||||
# sku
|
||||
sku_ele_ls = self.tab.eles('xpath://ul[@class="a-unordered-list a-vertical a-spacing-mini"]',timeout=20)
|
||||
@@ -277,12 +310,13 @@ class ChromeAmzone(ChromeAmzoneBase):
|
||||
Returns:
|
||||
dict: 包含采集到的数据
|
||||
"""
|
||||
return_data = {}
|
||||
try:
|
||||
# 验证国家是否支持
|
||||
if country not in self.country_info:
|
||||
error_msg = f"不支持的国家: {country},支持的国家有: {list(self.country_info.keys())}"
|
||||
print(error_msg)
|
||||
show_notification(error_msg, "error")
|
||||
# show_notification(error_msg, "error")
|
||||
return None
|
||||
|
||||
# 获取国家配置
|
||||
@@ -314,29 +348,35 @@ class ChromeAmzone(ChromeAmzoneBase):
|
||||
self.log("正在抓取商品数据...")
|
||||
data = self._scrape_data()
|
||||
|
||||
return_data.update(data)
|
||||
|
||||
# 判断是否采集标题出错
|
||||
new_size = "220,220"
|
||||
pattern = r"\._(?:[A-Z]+)?(\d+)_\."
|
||||
replacement = f"._{new_size}_.jpg"
|
||||
# replacement = f"._{new_size}_.jpg"
|
||||
# 使用正则替换
|
||||
image_new_url = re.sub(pattern, lambda m: replacement, data["image_url"])
|
||||
# image_new_url = re.sub(pattern, lambda m: replacement, data["image_url"])
|
||||
image_new_url = data["min_image_url"]
|
||||
|
||||
print(image_new_url)
|
||||
|
||||
iamge_base64 = self.image_to_base64(image_new_url)
|
||||
|
||||
similar_data = []
|
||||
# 4、请求获取插件的数据
|
||||
for page_num in range(total_page):
|
||||
resp_data = self.get_aliprice_data(
|
||||
page=page_num,
|
||||
title = data["title"],
|
||||
domain=domain,
|
||||
category=data["category"],
|
||||
imageBase64 = iamge_base64
|
||||
)
|
||||
self.log(f"Aliprice 扩展数据获取:{str(resp_data)[0:200]}")
|
||||
if len(resp_data.get("data",[])) > 0:
|
||||
for i in resp_data.get("data"):
|
||||
similar_data.append(i)
|
||||
if image_new_url:
|
||||
for page_num in range(total_page):
|
||||
resp_data = self.get_aliprice_data(
|
||||
page=page_num+1,
|
||||
title = data["title"],
|
||||
domain=domain,
|
||||
category=data["category"],
|
||||
imageBase64 = iamge_base64
|
||||
)
|
||||
self.log(f"Aliprice 扩展数据获取:{str(resp_data)[0:200]}")
|
||||
if len(resp_data.get("data",[])) > 0:
|
||||
for i in resp_data.get("data"):
|
||||
similar_data.append(i)
|
||||
|
||||
data["similar_data"] = similar_data
|
||||
|
||||
@@ -346,14 +386,17 @@ class ChromeAmzone(ChromeAmzoneBase):
|
||||
data['url'] = product_url
|
||||
data['timestamp'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
print(f"数据抓取完成: {json.dumps(data)}")
|
||||
return data
|
||||
print(f"数据抓取完成: {json.dumps(return_data)}")
|
||||
return_data.update(data)
|
||||
|
||||
return return_data
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"运行出错: {traceback.format_exc()}"
|
||||
print(error_msg)
|
||||
show_notification(f"采集失败: {str(e)}", "error")
|
||||
return {}
|
||||
# show_notification(f"采集失败: {str(e)}", "error")
|
||||
raise RuntimeError(f"{traceback.format_exc()}")
|
||||
return return_data
|
||||
|
||||
|
||||
class SimilarAsinTask(TaskBase):
|
||||
@@ -478,10 +521,9 @@ class SimilarAsinTask(TaskBase):
|
||||
result = []
|
||||
for gp_index,gp in enumerate(groups):
|
||||
items = gp.get("items", [])
|
||||
return_data = {}
|
||||
|
||||
group_item = []
|
||||
for index,value in enumerate(items):
|
||||
|
||||
print(value)
|
||||
return_data = {
|
||||
'image_url': "",
|
||||
'title': "",
|
||||
@@ -494,37 +536,36 @@ class SimilarAsinTask(TaskBase):
|
||||
country = value.get("country")
|
||||
for _ in range(max_retry):
|
||||
try:
|
||||
return_data = chrome.run(country, asin,total_page=2) or {}
|
||||
return_data = chrome.run(country, asin,total_page=1) or {}
|
||||
self.log(f"抓取结果->{return_data}")
|
||||
break
|
||||
except Exception as e:
|
||||
# if "与页面的连接已断开" in str(e):
|
||||
chrome = ChromeAmzone()
|
||||
self.log(f"{asin}抓取数据报错,{e}")
|
||||
if not isinstance(return_data, dict):
|
||||
return_data = {}
|
||||
if return_data.get("image_url"):
|
||||
break
|
||||
# if not isinstance(return_data, dict):
|
||||
# return_data = {}
|
||||
# if return_data.get("image_url"):
|
||||
# break
|
||||
|
||||
group_item.append({
|
||||
"sourceFileKey": value.get("sourceFileKey",""),
|
||||
"sourceFilename": value.get("sourceFilename",""),
|
||||
"rowToken": value.get("rowToken",""),
|
||||
"groupKey": value.get("groupKey",""),
|
||||
"id": value.get("values",{}).get("id",""),
|
||||
"asin": value.get("asin",""),
|
||||
"sku" : return_data.get("sku",""),
|
||||
"country": value.get("country",""),
|
||||
"url": return_data.get("image_url",""),
|
||||
"title": return_data.get("title",""),
|
||||
"done": False,
|
||||
"urls" : [i.get("ori_picture") for i in return_data.get("similar_data",[])[:16]]
|
||||
})
|
||||
# task_id: int, chunkIndex:int,chunkTotal: int, country_code: str, asin: str, status: dict,error:str="",
|
||||
# item_data:dict={},
|
||||
|
||||
|
||||
group_item = [
|
||||
{
|
||||
"sourceFileKey": i.get("sourceFileKey"),
|
||||
"sourceFilename": i.get("sourceFilename"),
|
||||
"rowToken": i.get("rowToken"),
|
||||
"groupKey": i.get("groupKey"),
|
||||
"id": i.get("id"),
|
||||
"asin": i.get("asin"),
|
||||
"sku" : return_data.get("sku"),
|
||||
"country": i.get("country"),
|
||||
"url": return_data.get("image_url"),
|
||||
"title": return_data.get("title"),
|
||||
"done": False,
|
||||
"urls" : [i.get("ori_picture") for i in return_data.get("similar_data")]
|
||||
}
|
||||
for i in items
|
||||
]
|
||||
# task_id: int, chunkIndex:int,chunkTotal: int, country_code: str, asin: str, status: dict,error:str="",
|
||||
# item_data:dict={},
|
||||
res = {
|
||||
"sourceFileKey": gp.get("sourceFileKey"),
|
||||
"sourceFilename": gp.get("sourceFilename"),
|
||||
@@ -629,7 +670,7 @@ class SimilarAsinTask(TaskBase):
|
||||
time.sleep(2)
|
||||
|
||||
self.log(f"已达到最大重试次数,结果回传最终失败", "ERROR")
|
||||
raise RuntimeError("已达到最大重试次数,结果回传最终失败")
|
||||
# raise RuntimeError("已达到最大重试次数,结果回传最终失败")
|
||||
|
||||
if __name__ == '__main__':
|
||||
spide = ChromeAmzone()
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
按功能拆分为蓝图:认证(auth)、主页面(main)、管理员(admin)、图片(image)、品牌(brand)
|
||||
"""
|
||||
import os
|
||||
import secrets
|
||||
import logging
|
||||
from datetime import timedelta, datetime
|
||||
from datetime import datetime
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
from flask import Flask
|
||||
@@ -73,12 +72,6 @@ def create_app():
|
||||
supports_credentials=True,
|
||||
resources={r"/api/*": {"origins": cors_origins}, r"/login": {"origins": cors_origins}},
|
||||
)
|
||||
# 生产环境必须通过环境变量注入固定 SECRET_KEY;否则服务重启会使旧会话失效。
|
||||
app.secret_key = os.environ.get('SECRET_KEY', 'dev-secret-key-change-me')
|
||||
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7)
|
||||
app.config['SESSION_COOKIE_HTTPONLY'] = True
|
||||
app.config['SESSION_COOKIE_SAMESITE'] = os.environ.get('SESSION_COOKIE_SAMESITE', 'Lax')
|
||||
app.config['SESSION_COOKIE_SECURE'] = os.environ.get('SESSION_COOKIE_SECURE', '0') == '1'
|
||||
|
||||
# 注册蓝图(不设 url_prefix,保持原有 URL 路径不变,前端无需改动)
|
||||
app.register_blueprint(auth_bp)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
公共模块:数据库连接、初始化、会话校验、装饰器、模板渲染
|
||||
公共模块:数据库连接、初始化、JWT 鉴权装饰器、模板渲染
|
||||
供各蓝图复用
|
||||
"""
|
||||
import os
|
||||
@@ -8,18 +8,19 @@ from datetime import timedelta
|
||||
from functools import wraps
|
||||
|
||||
import pymysql
|
||||
from flask import request, redirect, url_for, session, jsonify, render_template, render_template_string
|
||||
from flask import request, redirect, url_for, jsonify, render_template, render_template_string, g
|
||||
|
||||
from config import mysql_host, mysql_user, mysql_password, mysql_database
|
||||
from jwt_util import parse_token, get_token_from_request
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
STATIC_DIR = os.path.join(BASE_DIR, 'static')
|
||||
ASSETS_DIR = os.path.join(BASE_DIR, 'assets')
|
||||
WEB_SOURCE_DIR = os.path.join(BASE_DIR, 'web_source')
|
||||
TEMPLATE_FALLBACK_DIRS = (
|
||||
WEB_SOURCE_DIR,
|
||||
os.path.join(WEB_SOURCE_DIR, 'templates_backup'),
|
||||
)
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
STATIC_DIR = os.path.join(BASE_DIR, 'static')
|
||||
ASSETS_DIR = os.path.join(BASE_DIR, 'assets')
|
||||
WEB_SOURCE_DIR = os.path.join(BASE_DIR, 'web_source')
|
||||
TEMPLATE_FALLBACK_DIRS = (
|
||||
WEB_SOURCE_DIR,
|
||||
os.path.join(WEB_SOURCE_DIR, 'templates_backup'),
|
||||
)
|
||||
|
||||
|
||||
def get_db():
|
||||
@@ -35,13 +36,13 @@ def get_db():
|
||||
|
||||
def _render_html(template_name: str, **context):
|
||||
"""读取 HTML 模板:若为加密文件则先解密,再渲染。未加密或解密失败时按明文渲染。"""
|
||||
path = next(
|
||||
(candidate for candidate in (os.path.join(base_path, template_name) for base_path in TEMPLATE_FALLBACK_DIRS)
|
||||
if os.path.isfile(candidate)),
|
||||
None,
|
||||
)
|
||||
if path is None:
|
||||
return render_template(template_name, **context)
|
||||
path = next(
|
||||
(candidate for candidate in (os.path.join(base_path, template_name) for base_path in TEMPLATE_FALLBACK_DIRS)
|
||||
if os.path.isfile(candidate)),
|
||||
None,
|
||||
)
|
||||
if path is None:
|
||||
return render_template(template_name, **context)
|
||||
with open(path, "rb") as f:
|
||||
raw = f.read()
|
||||
try:
|
||||
@@ -54,13 +55,13 @@ def _render_html(template_name: str, **context):
|
||||
|
||||
def _render_html_new(template_name: str, **context):
|
||||
"""读取 HTML 模板:若为加密文件则先解密,再渲染。未加密或解密失败时按明文渲染。"""
|
||||
path = next(
|
||||
(candidate for candidate in (os.path.join(base_path, template_name) for base_path in TEMPLATE_FALLBACK_DIRS)
|
||||
if os.path.isfile(candidate)),
|
||||
None,
|
||||
)
|
||||
if path is None:
|
||||
return render_template(template_name, **context)
|
||||
path = next(
|
||||
(candidate for candidate in (os.path.join(base_path, template_name) for base_path in TEMPLATE_FALLBACK_DIRS)
|
||||
if os.path.isfile(candidate)),
|
||||
None,
|
||||
)
|
||||
if path is None:
|
||||
return render_template(template_name, **context)
|
||||
with open(path, "rb") as f:
|
||||
raw = f.read()
|
||||
try:
|
||||
@@ -72,9 +73,36 @@ def _render_html_new(template_name: str, **context):
|
||||
|
||||
|
||||
|
||||
def _resolve_jwt_user():
|
||||
"""从 Authorization/cookie 解析 JWT,命中后写入 flask.g 缓存。无效返回 None。"""
|
||||
cached = getattr(g, "_aiimage_user", None)
|
||||
if cached is not None:
|
||||
return cached or None
|
||||
token = get_token_from_request()
|
||||
payload = parse_token(token) if token else None
|
||||
if not payload:
|
||||
g._aiimage_user = False
|
||||
return None
|
||||
g._aiimage_user = payload
|
||||
g.aiimage_user_id = payload.get("user_id")
|
||||
g.aiimage_username = payload.get("username") or ""
|
||||
g.aiimage_device_id = payload.get("device_id") or ""
|
||||
return payload
|
||||
|
||||
|
||||
def current_user_id():
|
||||
user = _resolve_jwt_user()
|
||||
return user.get("user_id") if user else None
|
||||
|
||||
|
||||
def current_username():
|
||||
user = _resolve_jwt_user()
|
||||
return user.get("username") if user else ""
|
||||
|
||||
|
||||
def _is_session_user_valid():
|
||||
"""校验 session 中的 user_id 是否在数据库中仍存在;不存在则清除 session 并返回 False"""
|
||||
uid = session.get('user_id')
|
||||
"""兼容旧调用:判断当前 JWT 用户是否仍存在于数据库。"""
|
||||
uid = current_user_id()
|
||||
if not uid:
|
||||
return False
|
||||
try:
|
||||
@@ -83,23 +111,22 @@ def _is_session_user_valid():
|
||||
cur.execute("SELECT id FROM users WHERE id = %s", (uid,))
|
||||
row = cur.fetchone()
|
||||
conn.close()
|
||||
if not row:
|
||||
session.clear()
|
||||
return False
|
||||
return True
|
||||
return bool(row)
|
||||
except Exception:
|
||||
session.clear()
|
||||
return False
|
||||
|
||||
|
||||
def _get_current_admin_role():
|
||||
"""获取当前登录用户的管理角色:super_admin / admin / None(非管理员)"""
|
||||
uid = current_user_id()
|
||||
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",
|
||||
(session['user_id'],)
|
||||
(uid,)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.close()
|
||||
@@ -110,13 +137,19 @@ def _get_current_admin_role():
|
||||
return None, None
|
||||
|
||||
|
||||
def _unauthorized_response():
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest' \
|
||||
or request.path.startswith('/api/') \
|
||||
or 'application/json' in (request.headers.get('Accept') or ''):
|
||||
return jsonify({'success': False, 'error': '未登录'}), 401
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
|
||||
def login_required(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
if not session.get('user_id') or not _is_session_user_valid():
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return jsonify({'success': False, 'error': '未登录'}), 401
|
||||
return redirect(url_for('auth.login'))
|
||||
if not current_user_id():
|
||||
return _unauthorized_response()
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
@@ -124,22 +157,24 @@ def login_required(f):
|
||||
def admin_required(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
if not session.get('user_id'):
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return jsonify({'success': False, 'error': '未登录'}), 401
|
||||
return redirect(url_for('auth.login'))
|
||||
uid = current_user_id()
|
||||
if not uid:
|
||||
return _unauthorized_response()
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT is_admin, role FROM users WHERE id = %s", (session['user_id'],))
|
||||
cur.execute("SELECT is_admin, role FROM users WHERE id = %s", (uid,))
|
||||
row = cur.fetchone()
|
||||
conn.close()
|
||||
if not row or not row.get('is_admin'):
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest' \
|
||||
or request.path.startswith('/api/') \
|
||||
or 'application/json' in (request.headers.get('Accept') or ''):
|
||||
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
|
||||
return redirect(url_for('main.home'))
|
||||
except Exception as e:
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest' \
|
||||
or request.path.startswith('/api/'):
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
return redirect(url_for('main.home'))
|
||||
return f(*args, **kwargs)
|
||||
|
||||
1
app/assets/appearance-patent-BoTBnU7J.css
Normal file
1
app/assets/appearance-patent-BoTBnU7J.css
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/appearance-patent.js
Normal file
1
app/assets/appearance-patent.js
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/brand-COze15GJ.js
Normal file
1
app/assets/brand-COze15GJ.js
Normal file
@@ -0,0 +1 @@
|
||||
import{bN as r}from"./java-modules-B8c-YG5x.js";const n="";function s(e){return r(`${n}/api/brand/expand-folder-recursive`,{folder:e})}export{s as e};
|
||||
1
app/assets/categorized-timers-JPA-olTr.js
Normal file
1
app/assets/categorized-timers-JPA-olTr.js
Normal file
@@ -0,0 +1 @@
|
||||
const r=new Map;function c(n){let t=r.get(n);return t||(t=new Map,r.set(n,t)),t}function u(n,t){const e=r.get(n);e&&(e.delete(t),e.size||r.delete(n))}function l(n,t,e){const i=window.setTimeout(()=>{u(n,i),t()},e);return c(n).set(i,{id:i,kind:"timeout",category:n}),i}function a(n,t,e){const i=window.setInterval(t,e);return c(n).set(i,{id:i,kind:"interval",category:n}),i}function f(n,t){if(t==null)return;const i=r.get(n)?.get(t);i?.kind==="interval"?window.clearInterval(t):window.clearTimeout(t),i?.cancel?.(),u(n,t)}function s(n){const t=r.get(n);if(t){for(const e of t.values())e.kind==="interval"?window.clearInterval(e.id):window.clearTimeout(e.id),e.cancel?.();r.delete(n)}}function d(n,t){return new Promise(e=>{const i=window.setTimeout(()=>{u(n,i),e()},t);c(n).set(i,{id:i,kind:"timeout",category:n,cancel:e})})}function m(n){const t=`${n}:`;for(const e of Array.from(r.keys()))(e===n||e.startsWith(t))&&s(e)}function w(n){const t=e=>`${n}:${e}`;return{setTimeout(e,i,o){return l(t(e),i,o)},setInterval(e,i,o){return a(t(e),i,o)},clearTimer(e,i){f(t(e),i)},clearCategory(e){s(t(e))},clearScope(){m(n)},sleep(e,i){return d(t(e),i)}}}export{w as c};
|
||||
1
app/assets/collect-data-DPGAQszU.css
Normal file
1
app/assets/collect-data-DPGAQszU.css
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/collect-data.js
Normal file
1
app/assets/collect-data.js
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/convert-6oxZNMye.css
Normal file
1
app/assets/convert-6oxZNMye.css
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/convert.js
Normal file
1
app/assets/convert.js
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/dedupe-DpfPYQDv.css
Normal file
1
app/assets/dedupe-DpfPYQDv.css
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/dedupe.js
Normal file
1
app/assets/dedupe.js
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/delete-brand-DZ3x5hOX.css
Normal file
1
app/assets/delete-brand-DZ3x5hOX.css
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/delete-brand.js
Normal file
1
app/assets/delete-brand.js
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/el-input-Cxc-k42P.css
Normal file
1
app/assets/el-input-Cxc-k42P.css
Normal file
File diff suppressed because one or more lines are too long
55
app/assets/java-modules-B8c-YG5x.js
Normal file
55
app/assets/java-modules-B8c-YG5x.js
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/java-modules-l5anrOZ2.css
Normal file
1
app/assets/java-modules-l5anrOZ2.css
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/listingFilters-BpGOU_pJ.js
Normal file
1
app/assets/listingFilters-BpGOU_pJ.js
Normal file
@@ -0,0 +1 @@
|
||||
const e=[{value:"SearchSuppressed",label:"在搜索结果中禁止显示"},{value:"ApprovalRequired",label:"需要批准"},{value:"Active",label:"在售"},{value:"DetailPageRemoved",label:"详情页面已删除"}];export{e as L};
|
||||
1
app/assets/listingFilters-CK58rX4v.css
Normal file
1
app/assets/listingFilters-CK58rX4v.css
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/patrol-delete-Cqhg2qXm.css
Normal file
1
app/assets/patrol-delete-Cqhg2qXm.css
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/patrol-delete.js
Normal file
1
app/assets/patrol-delete.js
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/price-track-BsA0AIfw.css
Normal file
1
app/assets/price-track-BsA0AIfw.css
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/price-track.js
Normal file
1
app/assets/price-track.js
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/product-risk-BQIMXTON.css
Normal file
1
app/assets/product-risk-BQIMXTON.css
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/product-risk.js
Normal file
1
app/assets/product-risk.js
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/query-asin-CECO5lUB.css
Normal file
1
app/assets/query-asin-CECO5lUB.css
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/query-asin.js
Normal file
1
app/assets/query-asin.js
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/shop-match-DCW4m_j8.css
Normal file
1
app/assets/shop-match-DCW4m_j8.css
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/shop-match.js
Normal file
1
app/assets/shop-match.js
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/similar-asin-Bl4LsbT8.css
Normal file
1
app/assets/similar-asin-Bl4LsbT8.css
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/similar-asin.js
Normal file
1
app/assets/similar-asin.js
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/split-CR1PtDQE.css
Normal file
1
app/assets/split-CR1PtDQE.css
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/split.js
Normal file
1
app/assets/split.js
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/zh-cn-CscLo6na.js
Normal file
1
app/assets/zh-cn-CscLo6na.js
Normal file
File diff suppressed because one or more lines are too long
@@ -1,379 +1,17 @@
|
||||
"""
|
||||
管理员蓝图:用户管理(列表/创建/更新/删除)、生成历史、管理页
|
||||
"""
|
||||
import json
|
||||
import pymysql
|
||||
from flask import Blueprint, request, jsonify
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from app_common import get_db, _render_html, _get_current_admin_role, admin_required, login_required
|
||||
from flask import session
|
||||
|
||||
admin_bp = Blueprint('admin', __name__)
|
||||
|
||||
|
||||
def _parse_json(val, default=None):
|
||||
if val is None:
|
||||
return default if default is not None else []
|
||||
if isinstance(val, (list, dict)):
|
||||
return val
|
||||
try:
|
||||
return json.loads(val)
|
||||
except Exception:
|
||||
return default if default is not None else []
|
||||
|
||||
|
||||
@admin_bp.route('/admin')
|
||||
@login_required
|
||||
@admin_required
|
||||
def admin_page():
|
||||
return _render_html('admin.html')
|
||||
|
||||
|
||||
@admin_bp.route('/api/admin/users')
|
||||
@admin_required
|
||||
def admin_list_users():
|
||||
"""分页获取用户列表;支持用户名模糊搜索、指定管理员所属普通用户筛选"""
|
||||
role, current_row = _get_current_admin_role()
|
||||
if not role:
|
||||
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
|
||||
page = max(1, int(request.args.get('page', 1)))
|
||||
page_size = min(50, max(5, int(request.args.get('page_size', 15))))
|
||||
offset = (page - 1) * page_size
|
||||
search_username = (request.args.get('username') or request.args.get('search') or '').strip()
|
||||
created_by_id_arg = request.args.get('created_by_id') or request.args.get('admin_id')
|
||||
created_by_id = int(created_by_id_arg) if created_by_id_arg and str(created_by_id_arg).isdigit() else None
|
||||
if role != 'super_admin':
|
||||
created_by_id = None
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
if role == 'super_admin':
|
||||
where_parts = ["1=1"]
|
||||
params = []
|
||||
if search_username:
|
||||
where_parts.append("u.username LIKE %s")
|
||||
params.append("%" + search_username + "%")
|
||||
if created_by_id is not None:
|
||||
where_parts.append("u.created_by_id = %s")
|
||||
params.append(created_by_id)
|
||||
where_sql = " AND ".join(where_parts)
|
||||
cur.execute(
|
||||
"""SELECT u.id, u.username, u.is_admin, u.role, u.created_at, u.created_by_id,
|
||||
creator.username AS creator_username
|
||||
FROM users u
|
||||
LEFT JOIN users creator ON creator.id = u.created_by_id
|
||||
WHERE """ + where_sql + """ ORDER BY u.id LIMIT %s OFFSET %s""",
|
||||
tuple(params) + (page_size, offset),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
cur.execute("SELECT COUNT(*) as total FROM users u WHERE " + where_sql, tuple(params))
|
||||
total = cur.fetchone()['total']
|
||||
cur.execute("SELECT id, username FROM users WHERE role = 'admin' ORDER BY id")
|
||||
admins = [{'id': r['id'], 'username': r['username']} for r in cur.fetchall()]
|
||||
else:
|
||||
admin_id = current_row['id']
|
||||
where_parts = ["(u.id = %s OR (u.role = 'normal' AND u.created_by_id = %s))"]
|
||||
params = [admin_id, admin_id]
|
||||
if search_username:
|
||||
where_parts.append("u.username LIKE %s")
|
||||
params.append("%" + search_username + "%")
|
||||
where_sql = " AND ".join(where_parts)
|
||||
cur.execute(
|
||||
"""SELECT u.id, u.username, u.is_admin, u.role, u.created_at, u.created_by_id,
|
||||
creator.username AS creator_username
|
||||
FROM users u
|
||||
LEFT JOIN users creator ON creator.id = u.created_by_id
|
||||
WHERE """ + where_sql + """ ORDER BY u.id LIMIT %s OFFSET %s""",
|
||||
tuple(params) + (page_size, offset),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
cur.execute(
|
||||
"SELECT COUNT(*) as total FROM users u WHERE " + where_sql,
|
||||
tuple(params),
|
||||
)
|
||||
total = cur.fetchone()['total']
|
||||
admins = []
|
||||
items = [
|
||||
{
|
||||
'id': r['id'],
|
||||
'username': r['username'],
|
||||
'is_admin': bool(r.get('is_admin')),
|
||||
'role': r.get('role') or 'normal',
|
||||
'created_by_id': r.get('created_by_id'),
|
||||
'creator_username': r.get('creator_username') or '',
|
||||
'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r.get('created_at') else '',
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
conn.close()
|
||||
payload = {
|
||||
'success': True,
|
||||
'items': items,
|
||||
'total': total,
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
'current_user_role': role,
|
||||
'admins': admins,
|
||||
}
|
||||
return jsonify(payload)
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)})
|
||||
|
||||
|
||||
@admin_bp.route('/api/admin/user', methods=['POST'])
|
||||
@admin_required
|
||||
def admin_create_user():
|
||||
data = request.get_json() or {}
|
||||
username = (data.get('username') or '').strip()
|
||||
password = data.get('password') or ''
|
||||
role, current_row = _get_current_admin_role()
|
||||
if not role:
|
||||
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
|
||||
want_role = (data.get('role') or 'normal').strip() or 'normal'
|
||||
if want_role not in ('admin', 'normal'):
|
||||
want_role = 'normal'
|
||||
if role == 'admin' and want_role == 'admin':
|
||||
return jsonify({'success': False, 'error': '仅超级管理员可创建管理员'})
|
||||
if want_role == 'admin':
|
||||
want_created_by = current_row['id']
|
||||
elif role == 'super_admin':
|
||||
want_created_by = data.get('created_by_id')
|
||||
else:
|
||||
want_created_by = current_row['id']
|
||||
if not username or not password:
|
||||
return jsonify({'success': False, 'error': '用户名和密码不能为空'})
|
||||
if len(username) < 2:
|
||||
return jsonify({'success': False, 'error': '用户名至少2个字符'})
|
||||
if len(password) < 6:
|
||||
return jsonify({'success': False, 'error': '密码至少6个字符'})
|
||||
if want_role == 'normal' and role == 'super_admin' and want_created_by is None:
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1")
|
||||
r = cur.fetchone()
|
||||
conn.close()
|
||||
want_created_by = r['id'] if r else current_row['id']
|
||||
except Exception:
|
||||
want_created_by = current_row['id']
|
||||
if want_role == 'normal' and want_created_by is None:
|
||||
want_created_by = current_row['id']
|
||||
is_admin = 1 if want_role in ('super_admin', 'admin') else 0
|
||||
pwd_hash = generate_password_hash(password, method='pbkdf2:sha256')
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO users (username, password_hash, is_admin, role, created_by_id) VALUES (%s, %s, %s, %s, %s)",
|
||||
(username, pwd_hash, is_admin, want_role, want_created_by),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'success': True, 'msg': '用户创建成功'})
|
||||
except pymysql.IntegrityError:
|
||||
return jsonify({'success': False, 'error': '用户名已存在'})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)})
|
||||
|
||||
|
||||
@admin_bp.route('/api/admin/user/<int:uid>', methods=['PUT'])
|
||||
@admin_required
|
||||
def admin_update_user(uid):
|
||||
data = request.get_json() or {}
|
||||
password = data.get('password')
|
||||
want_role = (data.get('role') or '').strip() or data.get('role')
|
||||
role, current_row = _get_current_admin_role()
|
||||
if not role:
|
||||
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
|
||||
if want_role is None and not password:
|
||||
return jsonify({'success': False, 'error': '请提供要修改的内容'})
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT id, role, created_by_id FROM users WHERE id = %s", (uid,))
|
||||
target = cur.fetchone()
|
||||
if not target:
|
||||
conn.close()
|
||||
return jsonify({'success': False, 'error': '用户不存在'})
|
||||
if role == 'admin':
|
||||
if target['role'] != 'normal' or target.get('created_by_id') != current_row['id']:
|
||||
conn.close()
|
||||
return jsonify({'success': False, 'error': '只能编辑自己创建的普通用户'}), 403
|
||||
want_role = None
|
||||
else:
|
||||
if target.get('role') == 'super_admin':
|
||||
conn.close()
|
||||
return jsonify({'success': False, 'error': '不能修改超级管理员'})
|
||||
if want_role == 'super_admin':
|
||||
return jsonify({'success': False, 'error': '不能将用户设为超级管理员'})
|
||||
if want_role not in ('admin', 'normal', None, ''):
|
||||
want_role = None
|
||||
if password:
|
||||
if len(password) < 6:
|
||||
conn.close()
|
||||
return jsonify({'success': False, 'error': '密码至少6个字符'})
|
||||
pwd_hash = generate_password_hash(password, method='pbkdf2:sha256')
|
||||
cur.execute("UPDATE users SET password_hash = %s WHERE id = %s", (pwd_hash, uid))
|
||||
if want_role is not None and want_role != '':
|
||||
is_admin = 1 if want_role == 'admin' else 0
|
||||
cur.execute(
|
||||
"UPDATE users SET is_admin = %s, role = %s WHERE id = %s",
|
||||
(is_admin, want_role, uid),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'success': True, 'msg': '更新成功'})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)})
|
||||
|
||||
|
||||
@admin_bp.route('/api/admin/user/<int:uid>', methods=['DELETE'])
|
||||
@admin_required
|
||||
def admin_delete_user(uid):
|
||||
from flask import session
|
||||
if session.get('user_id') == uid:
|
||||
return jsonify({'success': False, 'error': '不能删除当前登录账号'})
|
||||
role, current_row = _get_current_admin_role()
|
||||
if not role:
|
||||
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT id, role, created_by_id FROM users WHERE id = %s", (uid,))
|
||||
target = cur.fetchone()
|
||||
if not target:
|
||||
conn.close()
|
||||
return jsonify({'success': False, 'error': '用户不存在'})
|
||||
if target.get('role') == 'super_admin':
|
||||
conn.close()
|
||||
return jsonify({'success': False, 'error': '不能删除超级管理员'})
|
||||
if role == 'admin':
|
||||
if target.get('role') != 'normal' or target.get('created_by_id') != current_row['id']:
|
||||
conn.close()
|
||||
return jsonify({'success': False, 'error': '只能删除自己创建的普通用户'}), 403
|
||||
cur.execute("DELETE FROM users WHERE id = %s", (uid,))
|
||||
affected = cur.rowcount
|
||||
conn.commit()
|
||||
conn.close()
|
||||
if affected == 0:
|
||||
return jsonify({'success': False, 'error': '用户不存在'})
|
||||
return jsonify({'success': True, 'msg': '删除成功'})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)})
|
||||
|
||||
|
||||
@admin_bp.route('/api/admin/user/<int:uid>/column-permissions')
|
||||
@login_required
|
||||
def admin_user_column_permissions(uid):
|
||||
"""获取指定用户的栏目权限列表:当前用户只能查自己,管理员可查任意用户。超级管理员返回全部栏目。"""
|
||||
current_uid = session.get('user_id')
|
||||
menu_type = (request.args.get('menu_type') or '').strip().lower()
|
||||
if current_uid != uid:
|
||||
role, _ = _get_current_admin_role()
|
||||
if not role:
|
||||
return jsonify({'success': False, 'error': '无权查看该用户的栏目权限'}), 403
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT role FROM users WHERE id = %s", (uid,))
|
||||
user_row = cur.fetchone()
|
||||
valid_menu_type = menu_type if menu_type in ('app', 'admin') else ''
|
||||
if user_row and (user_row.get('role') or '').strip() == 'super_admin':
|
||||
sql = """
|
||||
SELECT id, name, column_key, menu_type, route_path, sort_order, created_at
|
||||
FROM columns
|
||||
"""
|
||||
params = []
|
||||
if valid_menu_type:
|
||||
sql += " WHERE menu_type = %s"
|
||||
params.append(valid_menu_type)
|
||||
sql += " ORDER BY sort_order ASC, id ASC"
|
||||
cur.execute(sql, tuple(params))
|
||||
rows = cur.fetchall()
|
||||
else:
|
||||
sql = """
|
||||
SELECT c.id, c.name, c.column_key, c.menu_type, c.route_path, c.sort_order, c.created_at
|
||||
FROM columns c
|
||||
INNER JOIN user_column_permission ucp ON ucp.column_id = c.id
|
||||
WHERE ucp.user_id = %s
|
||||
"""
|
||||
params = [uid]
|
||||
if valid_menu_type:
|
||||
sql += " AND c.menu_type = %s"
|
||||
params.append(valid_menu_type)
|
||||
sql += " ORDER BY c.sort_order ASC, c.id ASC"
|
||||
cur.execute(sql, tuple(params))
|
||||
rows = cur.fetchall()
|
||||
conn.close()
|
||||
items = [
|
||||
{
|
||||
'id': r['id'],
|
||||
'name': r['name'],
|
||||
'column_key': r['column_key'],
|
||||
'menu_type': r.get('menu_type') or 'app',
|
||||
'route_path': r.get('route_path') or '',
|
||||
'sort_order': r.get('sort_order') or 0,
|
||||
'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r.get('created_at') else '',
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
return jsonify({'success': True, 'items': items})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)})
|
||||
|
||||
|
||||
@admin_bp.route('/api/admin/history')
|
||||
@admin_required
|
||||
def admin_history():
|
||||
"""管理员分页获取所有生成记录,支持按用户和时间筛选"""
|
||||
page = max(1, int(request.args.get('page', 1)))
|
||||
page_size = min(50, max(10, int(request.args.get('page_size', 15))))
|
||||
offset = (page - 1) * page_size
|
||||
user_id = request.args.get('user_id', type=int)
|
||||
time_start = (request.args.get('time_start') or '').strip()
|
||||
time_end = (request.args.get('time_end') or '').strip()
|
||||
conditions, params = [], []
|
||||
if user_id:
|
||||
conditions.append("h.user_id = %s")
|
||||
params.append(user_id)
|
||||
if time_start:
|
||||
conditions.append("h.created_at >= %s")
|
||||
params.append(time_start)
|
||||
if time_end:
|
||||
conditions.append("h.created_at <= %s")
|
||||
params.append(time_end + ' 23:59:59' if len(time_end) <= 10 else time_end)
|
||||
where_clause = " AND ".join(conditions) if conditions else "1=1"
|
||||
params_count = params[:]
|
||||
params.extend([page_size, offset])
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""SELECT h.id, h.user_id, h.created_at, h.panel_type, h.original_urls, h.params, h.result_urls,
|
||||
h.long_image_url, u.username
|
||||
FROM image_history h
|
||||
LEFT JOIN users u ON h.user_id = u.id
|
||||
WHERE """ + where_clause + """ ORDER BY h.created_at DESC LIMIT %s OFFSET %s""",
|
||||
params,
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
cur.execute("SELECT COUNT(*) as total FROM image_history h WHERE " + where_clause, params_count)
|
||||
total = cur.fetchone()['total']
|
||||
conn.close()
|
||||
items = []
|
||||
for r in rows:
|
||||
items.append({
|
||||
'id': r['id'],
|
||||
'user_id': r['user_id'],
|
||||
'username': r.get('username') or '-',
|
||||
'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r['created_at'] else '',
|
||||
'panel_type': r['panel_type'] or '',
|
||||
'original_urls': _parse_json(r['original_urls'], []),
|
||||
'params': _parse_json(r['params'], {}),
|
||||
'result_urls': _parse_json(r['result_urls'], []),
|
||||
'long_image_url': (r.get('long_image_url') or '').strip() or None,
|
||||
})
|
||||
return jsonify({'success': True, 'items': items, 'total': total, 'page': page, 'page_size': page_size})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)})
|
||||
"""
|
||||
管理员蓝图:仅保留 /admin 页面渲染。
|
||||
用户管理、栏目权限、生成历史等接口已全部迁移至 Java 端。
|
||||
"""
|
||||
from flask import Blueprint
|
||||
|
||||
from app_common import _render_html, admin_required, login_required
|
||||
from config import JAVA_API_BASE
|
||||
|
||||
admin_bp = Blueprint('admin', __name__)
|
||||
|
||||
|
||||
@admin_bp.route('/admin')
|
||||
@login_required
|
||||
@admin_required
|
||||
def admin_page():
|
||||
return _render_html('admin.html', java_api_base=JAVA_API_BASE)
|
||||
|
||||
@@ -1,107 +1,56 @@
|
||||
"""
|
||||
认证蓝图:登录、登出、登录状态校验
|
||||
"""
|
||||
from flask import Blueprint, request, redirect, url_for, session, jsonify
|
||||
from werkzeug.security import check_password_hash
|
||||
|
||||
from app_common import (
|
||||
get_db,
|
||||
_render_html,
|
||||
_is_session_user_valid,
|
||||
login_required,
|
||||
BASE_DIR,
|
||||
)
|
||||
from tool.devices import DeviceIDGenerator
|
||||
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
|
||||
|
||||
@auth_bp.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if session.get('user_id') and _is_session_user_valid():
|
||||
return redirect(url_for('main.home'))
|
||||
if request.method == 'POST':
|
||||
data = request.get_json() if request.is_json else request.form
|
||||
username = (data.get('username') or '').strip()
|
||||
password = data.get('password') or ''
|
||||
if not username or not password:
|
||||
if request.is_json:
|
||||
return jsonify({'success': False, 'error': '请输入用户名和密码'})
|
||||
return _render_html('login.html', error='请输入用户名和密码')
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT id, password_hash, machine, is_admin FROM users WHERE username = %s",
|
||||
(username,)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row and check_password_hash(row['password_hash'], password):
|
||||
current_machine = DeviceIDGenerator().get_device_id()
|
||||
stored_machine = (row.get('machine') or '').strip()
|
||||
if not stored_machine:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("UPDATE users SET machine = %s WHERE id = %s", (current_machine, row['id']))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
session.permanent = True
|
||||
session['user_id'] = row['id']
|
||||
session['username'] = username
|
||||
if request.is_json:
|
||||
return jsonify({'success': True, 'redirect': url_for('main.home')})
|
||||
return redirect(url_for('main.home'))
|
||||
print("验证设备",stored_machine)
|
||||
print("当前设备",current_machine)
|
||||
if stored_machine != current_machine and row.get("is_admin") != 1:
|
||||
conn.close()
|
||||
err_msg = '当前设备与首次登录设备不一致,请在原设备上登录'
|
||||
if request.is_json:
|
||||
return jsonify({'success': False, 'error': err_msg})
|
||||
return _render_html('login.html', error=err_msg)
|
||||
conn.close()
|
||||
session.permanent = True
|
||||
session['user_id'] = row['id']
|
||||
session['username'] = username
|
||||
if request.is_json:
|
||||
return jsonify({'success': True, 'redirect': url_for('main.home')})
|
||||
return redirect(url_for('main.home'))
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
if request.is_json:
|
||||
return jsonify({'success': False, 'error': str(e)})
|
||||
return _render_html('login.html', error='登录失败,请稍后重试')
|
||||
if request.is_json:
|
||||
return jsonify({'success': False, 'error': '用户名或密码错误'})
|
||||
return _render_html('login.html', error='用户名或密码错误')
|
||||
return _render_html('login.html')
|
||||
|
||||
|
||||
@auth_bp.route('/api/auth/check')
|
||||
@login_required
|
||||
def api_auth_check():
|
||||
"""校验登录状态,用于页面加载时判断是否已登录;同时校验机器码是否与首次登录设备一致"""
|
||||
if not session.get('user_id'):
|
||||
return jsonify({'logged_in': False})
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT machine, is_admin FROM users WHERE id = %s", (session['user_id'],))
|
||||
row = cur.fetchone()
|
||||
conn.close()
|
||||
if not row:
|
||||
return jsonify({'logged_in': False})
|
||||
stored_machine = (row.get('machine') or '').strip()
|
||||
if stored_machine:
|
||||
current_machine = DeviceIDGenerator().get_device_id()
|
||||
if stored_machine != current_machine and row.get("is_admin") != 1:
|
||||
session.clear()
|
||||
return jsonify({'logged_in': False, 'error': '当前设备与首次登录设备不一致'})
|
||||
except Exception:
|
||||
return jsonify({'logged_in': False})
|
||||
return jsonify({'logged_in': True, 'redirect': url_for('main.home')})
|
||||
|
||||
|
||||
@auth_bp.route('/logout')
|
||||
def logout():
|
||||
session.clear()
|
||||
return redirect(url_for('auth.login'))
|
||||
"""
|
||||
认证蓝图:登录页(仅 GET 渲染模板)+ 登出(清 JWT cookie)+ token 同步
|
||||
登录表单提交已经直连 Java 后端 /login,Python 这边只负责:
|
||||
1. 渲染登录页模板
|
||||
2. 把 Java 签发的 JWT 从前端写到 Python 同源 cookie,供后续页面跳转携带
|
||||
3. 登出:清 cookie 跳回登录页
|
||||
"""
|
||||
import sys
|
||||
|
||||
from flask import Blueprint, redirect, url_for, make_response, request, jsonify
|
||||
|
||||
from app_common import _render_html, current_user_id
|
||||
from config import JAVA_API_BASE
|
||||
from jwt_util import COOKIE_NAME, parse_token_with_reason
|
||||
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
|
||||
|
||||
@auth_bp.route('/login', methods=['GET'])
|
||||
def login():
|
||||
if current_user_id():
|
||||
return redirect(url_for('main.home'))
|
||||
return _render_html('login.html', java_api_base=JAVA_API_BASE)
|
||||
|
||||
|
||||
@auth_bp.route('/api/auth/sync', methods=['POST'])
|
||||
def api_auth_sync():
|
||||
"""前端拿到 Java 返回的 JWT 后调用,把 token 写进 Python 同源 cookie。"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
token = (data.get('token') or '').strip()
|
||||
|
||||
if not token:
|
||||
return jsonify({'success': False, 'error': '缺少 token'}), 400
|
||||
payload, reason = parse_token_with_reason(token)
|
||||
if not payload:
|
||||
# 把根因打到服务端日志,并回传给前端,便于现场排查
|
||||
print(f"[auth] /api/auth/sync 校验失败: {reason}", file=sys.stderr)
|
||||
return jsonify({'success': False, 'error': f'token 无效: {reason}'}), 401
|
||||
resp = make_response(jsonify({'success': True}))
|
||||
resp.set_cookie(
|
||||
COOKIE_NAME,
|
||||
token,
|
||||
max_age=7 * 24 * 3600,
|
||||
path='/',
|
||||
httponly=True,
|
||||
samesite='Lax',
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
@auth_bp.route('/logout')
|
||||
def logout():
|
||||
resp = make_response(redirect(url_for('auth.login')))
|
||||
resp.delete_cookie(COOKIE_NAME, path='/')
|
||||
return resp
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@ from queue import Queue, Empty
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from flask import Blueprint, request, jsonify, session, send_file, redirect, Response
|
||||
from flask import Blueprint, request, jsonify, send_file, redirect, Response
|
||||
|
||||
from app_common import get_db, login_required, BASE_DIR
|
||||
from app_common import get_db, login_required, current_user_id, BASE_DIR
|
||||
from config import bucket_path,JAVA_API_BASE
|
||||
from brand_spider.main import single_file_handle, TaskCancelledError
|
||||
|
||||
@@ -70,12 +70,12 @@ def _get_uid_from_request_headers():
|
||||
|
||||
def _resolve_user_id():
|
||||
"""
|
||||
优先使用请求头 uid;没有请求头 uid 时回退到 session['user_id']。
|
||||
优先使用请求头 uid;没有请求头 uid 时回退到 JWT 解析的当前用户。
|
||||
若二者同时存在但不一致,则视为无效请求并返回 None。
|
||||
"""
|
||||
req_uid = _get_uid_from_request_headers()
|
||||
if req_uid is not None:
|
||||
sess_uid = session.get('user_id')
|
||||
sess_uid = current_user_id()
|
||||
if sess_uid is not None:
|
||||
try:
|
||||
if int(sess_uid) != int(req_uid):
|
||||
@@ -84,7 +84,7 @@ def _resolve_user_id():
|
||||
if str(sess_uid) != str(req_uid):
|
||||
return None
|
||||
return req_uid
|
||||
return session.get('user_id')
|
||||
return current_user_id()
|
||||
|
||||
|
||||
def _get_user_id_or_error():
|
||||
|
||||
@@ -1,726 +0,0 @@
|
||||
"""
|
||||
品牌爬虫蓝图:展开文件夹、运行任务、任务列表/详情、下载结果
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import threading
|
||||
import traceback
|
||||
import zipfile
|
||||
import io
|
||||
import time
|
||||
import requests
|
||||
from queue import Queue, Empty
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from flask import Blueprint, request, jsonify, session, send_file, redirect, Response
|
||||
|
||||
from app_common import get_db, login_required, BASE_DIR
|
||||
from config import bucket_path,JAVA_API_BASE
|
||||
from brand_spider.main import single_file_handle, TaskCancelledError
|
||||
|
||||
brand_bp = Blueprint('brand', __name__)
|
||||
BRAND_OUTPUT_DIR = os.path.join(BASE_DIR, 'brand_output')
|
||||
OSS_PREFIX = "brand_results"
|
||||
|
||||
# 任务完成时推送给前端的 SSE 队列:task_id -> [Queue, ...]
|
||||
_task_event_queues = {}
|
||||
_task_event_lock = threading.Lock()
|
||||
|
||||
# 任务行级进度(当前处理文件的行数/总行数),仅存内存,不落库:
|
||||
# task_id -> {
|
||||
# 'file_index': int, # 当前处理第几个文件
|
||||
# 'file_total': int, # 总文件数
|
||||
# 'file_path': str, # 当前文件路径
|
||||
# 'current_line': int, # 当前行号(或当前品牌序号)
|
||||
# 'total_lines': int, # 总行数(或总品牌数)
|
||||
# }
|
||||
_task_line_progress = {}
|
||||
_task_line_progress_lock = threading.Lock()
|
||||
_cancelled_brand_tasks = set()
|
||||
_cancelled_brand_tasks_lock = threading.Lock()
|
||||
|
||||
|
||||
def _mark_task_cancelled(task_id):
|
||||
with _cancelled_brand_tasks_lock:
|
||||
_cancelled_brand_tasks.add(task_id)
|
||||
|
||||
|
||||
def _is_task_cancelled(task_id):
|
||||
with _cancelled_brand_tasks_lock:
|
||||
return task_id in _cancelled_brand_tasks
|
||||
|
||||
|
||||
def _get_uid_from_request_headers():
|
||||
"""
|
||||
从请求头读取 uid。
|
||||
前端会在 headers 里携带 uid,用于确定本次请求的归属用户。
|
||||
"""
|
||||
uid = request.headers.get('uid')
|
||||
if uid is None:
|
||||
return None
|
||||
uid = str(uid).strip()
|
||||
if not uid:
|
||||
return None
|
||||
try:
|
||||
return int(uid)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_user_id():
|
||||
"""
|
||||
优先使用请求头 uid;没有请求头 uid 时回退到 session['user_id']。
|
||||
若二者同时存在但不一致,则视为无效请求并返回 None。
|
||||
"""
|
||||
req_uid = _get_uid_from_request_headers()
|
||||
if req_uid is not None:
|
||||
sess_uid = session.get('user_id')
|
||||
if sess_uid is not None:
|
||||
try:
|
||||
if int(sess_uid) != int(req_uid):
|
||||
return None
|
||||
except Exception:
|
||||
if str(sess_uid) != str(req_uid):
|
||||
return None
|
||||
return req_uid
|
||||
return session.get('user_id')
|
||||
|
||||
|
||||
def _get_user_id_or_error():
|
||||
user_id = _resolve_user_id()
|
||||
if not user_id:
|
||||
return None, (jsonify({'success': False, 'error': '未登录或缺少uid'}), 401)
|
||||
try:
|
||||
return int(user_id), None
|
||||
except Exception:
|
||||
return None, (jsonify({'success': False, 'error': 'uid无效'}), 400)
|
||||
|
||||
|
||||
def _push_task_event(task_id, event):
|
||||
"""向订阅了该任务的所有 SSE 连接推送事件,并移除该任务的队列列表。
|
||||
同时清理内存中的行级进度,不通过数据库中转行级进度。"""
|
||||
with _task_event_lock:
|
||||
queues = _task_event_queues.pop(task_id, [])
|
||||
for q in queues:
|
||||
try:
|
||||
q.put_nowait(event)
|
||||
except Exception:
|
||||
pass
|
||||
# 任务进入终态时,顺便清理行级进度缓存
|
||||
with _task_line_progress_lock:
|
||||
_task_line_progress.pop(task_id, None)
|
||||
status = (event or {}).get('status') if isinstance(event, dict) else None
|
||||
if status in ('success', 'failed', 'cancelled'):
|
||||
with _cancelled_brand_tasks_lock:
|
||||
_cancelled_brand_tasks.discard(task_id)
|
||||
|
||||
|
||||
def _expand_folder_xlsx(folder_path):
|
||||
"""返回文件夹下所有 .xlsx 文件的绝对路径列表"""
|
||||
if not folder_path or not os.path.isdir(folder_path):
|
||||
return []
|
||||
paths = []
|
||||
for name in os.listdir(folder_path):
|
||||
if name.endswith('.xlsx') or name.endswith('.XLSX'):
|
||||
paths.append(os.path.normpath(os.path.join(folder_path, name)))
|
||||
return paths
|
||||
|
||||
|
||||
def _upload_local_xlsx_to_oss(local_path, user_id):
|
||||
"""将本地 xlsx 文件上传到 OSS,返回 (url, None) 或 (None, error_message)。"""
|
||||
if not local_path or not os.path.isfile(local_path):
|
||||
return None, "文件不存在"
|
||||
try:
|
||||
from ali_oss import upload_file as oss_upload_file
|
||||
except ImportError:
|
||||
return None, "OSS 模块未配置"
|
||||
base = os.path.basename(local_path)
|
||||
safe_base = "".join(c if c.isalnum() or c in '-_.' else '_' for c in base)
|
||||
if not safe_base.endswith('.xlsx'):
|
||||
safe_base = safe_base + '.xlsx'
|
||||
key = f"{bucket_path}brand_input/{user_id}/{int(time.time() * 1000)}/{safe_base}"
|
||||
try:
|
||||
with open(local_path, "rb") as f:
|
||||
content = f.read()
|
||||
url = oss_upload_file(content, key)
|
||||
return url, None
|
||||
except Exception as e:
|
||||
return None, str(e)
|
||||
|
||||
|
||||
def _run_brand_single(taskid,brand_ls, fileUrl,strategy,totalLines,chunkTotal,chunkIndex):
|
||||
""""""
|
||||
invalidBrands = [] # 不符合品牌的数据
|
||||
queryFailedBrands = [] # 查询失败的数据
|
||||
keptRows = []
|
||||
for brand in brand_ls:
|
||||
if _is_task_cancelled(taskid):
|
||||
raise TaskCancelledError("任务已取消")
|
||||
faild_data,query_faild_data = single_file_handle(brand,strategy)
|
||||
invalidBrands.extend(faild_data)
|
||||
queryFailedBrands.extend(query_faild_data)
|
||||
if len(faild_data) == 0 and len(query_faild_data) == 0:
|
||||
keptRows.append(brand)
|
||||
|
||||
data = { "strategy": strategy ,
|
||||
"files": [
|
||||
{
|
||||
"fileUrl": fileUrl,
|
||||
"originalFilename": "",
|
||||
"relativePath": "",
|
||||
"mainSheetName": "",
|
||||
"chunkIndex": chunkIndex,
|
||||
"chunkTotal": chunkTotal,
|
||||
"totalLines": totalLines,
|
||||
"keptRows": keptRows,
|
||||
"invalidBrands": invalidBrands,
|
||||
"queryFailedBrands": queryFailedBrands
|
||||
}
|
||||
]
|
||||
}
|
||||
resp = requests.post(f"{JAVA_API_BASE}/api/brand/tasks/{taskid}/result",headers={"accept":"application/json"},
|
||||
# json={ "strategy": strategy ,
|
||||
# "files": [
|
||||
# { "fileUrl": fileUrl,"originalFilename": "","relativePath": "",
|
||||
# "mainSheetName": "","columns": [],"keptRows": [],
|
||||
# "invalidBrands": invalidBrands,"queryFailedBrands": queryFailedBrands
|
||||
# }]})
|
||||
json=data)
|
||||
# print(data)
|
||||
# print(taskid,brand_ls, fileUrl)
|
||||
print("提交结果",data,"\n-->",resp.text)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
def _background_brand_task(task_id,data):
|
||||
"""
|
||||
后台执行爬虫任务
|
||||
参数示例:
|
||||
data : {
|
||||
"taskId": 348,
|
||||
"strategy": "Terms",
|
||||
"files": [
|
||||
{
|
||||
"fileIndex": 1,
|
||||
"fileUrl": "ab8cce4878754bfd89b4cd3ed20e1395",
|
||||
"originalFilename": "品牌样例.xlsx",
|
||||
"relativePath": "店铺A/品牌样例.xlsx",
|
||||
"sheetName": "Sheet1",
|
||||
"columns": [
|
||||
"品牌",
|
||||
"ASIH",
|
||||
"状态",
|
||||
"时间"
|
||||
],
|
||||
"rows": [
|
||||
{
|
||||
"品牌": "YQAUTEC",
|
||||
"ASIH": "B0F28NZ752",
|
||||
"状态": "",
|
||||
"时间": "",
|
||||
"__rowIndex": 2
|
||||
}
|
||||
],
|
||||
"uniqueBrands": [
|
||||
"YQAUTEC"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
try:
|
||||
file_ls = data.get("files")
|
||||
strategy = data.get("strategy")
|
||||
if not isinstance(file_ls, list):
|
||||
file_ls = []
|
||||
tasks = []
|
||||
for file_data in file_ls:
|
||||
rows = file_data.get("rows") or []
|
||||
brand_ls = [i.get("品牌") for i in rows if i.get("品牌")]
|
||||
fileUrl = file_data.get("fileUrl")
|
||||
if not brand_ls:
|
||||
continue
|
||||
for i in range(0, len(brand_ls), 5):
|
||||
chunk = brand_ls[i:i + 5]
|
||||
tasks.append((chunk, fileUrl))
|
||||
|
||||
if tasks:
|
||||
max_workers = min(8, len(tasks))
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
if _is_task_cancelled(task_id):
|
||||
_push_task_event(task_id, {'status': 'cancelled'})
|
||||
return
|
||||
futures = [
|
||||
executor.submit(_run_brand_single, task_id, chunk, file_url, strategy,len(brand_ls),len(tasks),chunkIndex+1)
|
||||
for chunkIndex,(chunk, file_url) in enumerate(tasks)
|
||||
]
|
||||
for future in as_completed(futures):
|
||||
if _is_task_cancelled(task_id):
|
||||
for f in futures:
|
||||
f.cancel()
|
||||
_push_task_event(task_id, {'status': 'cancelled'})
|
||||
return
|
||||
future.result()
|
||||
_push_task_event(task_id, {'status': 'success'})
|
||||
except Exception as e:
|
||||
print("执行出错",traceback.format_exc())
|
||||
_push_task_event(task_id, {'status': 'failed', 'error_message': str(e)})
|
||||
print("执行完成")
|
||||
|
||||
|
||||
def _parse_json(val, default=None):
|
||||
if val is None:
|
||||
return default if default is not None else []
|
||||
if isinstance(val, (list, dict)):
|
||||
return val
|
||||
try:
|
||||
return json.loads(val)
|
||||
except Exception:
|
||||
return default if default is not None else []
|
||||
|
||||
|
||||
def _is_url(s):
|
||||
if not isinstance(s, str) or not s.strip():
|
||||
return False
|
||||
return s.strip().startswith('http://') or s.strip().startswith('https://')
|
||||
|
||||
|
||||
def _normalize_result_paths(raw):
|
||||
"""
|
||||
将 result_paths 规范化为 (url_list, zip_url)。
|
||||
支持旧格式 list 或新格式 dict {"urls": [...], "zip_url": "..."}。
|
||||
"""
|
||||
if not raw:
|
||||
return [], None
|
||||
if isinstance(raw, dict):
|
||||
urls = raw.get('urls') or []
|
||||
if not isinstance(urls, list):
|
||||
urls = []
|
||||
zip_url = raw.get('zip_url')
|
||||
if zip_url and not isinstance(zip_url, str):
|
||||
zip_url = None
|
||||
return urls, zip_url
|
||||
if isinstance(raw, list):
|
||||
return raw, None
|
||||
return [], None
|
||||
|
||||
|
||||
@brand_bp.route('/api/brand/expand-folder', methods=['POST'])
|
||||
@login_required
|
||||
def api_brand_expand_folder():
|
||||
"""展开文件夹,返回其下所有 .xlsx 文件路径列表"""
|
||||
try:
|
||||
_, err = _get_user_id_or_error()
|
||||
if err:
|
||||
return err
|
||||
data = request.get_json() or {}
|
||||
folder = (data.get('folder') or '').strip()
|
||||
if not folder:
|
||||
return jsonify({'success': False, 'error': '请提供 folder 路径'}), 400
|
||||
paths = _expand_folder_xlsx(folder)
|
||||
return jsonify({'success': True, 'paths': paths})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
def _expand_folder_xlsx_recursive_items(folder_path):
|
||||
"""返回文件夹下所有 .xlsx 文件的绝对路径和相对路径列表(递归)"""
|
||||
if not folder_path or not os.path.isdir(folder_path):
|
||||
return []
|
||||
items = []
|
||||
root = os.path.normpath(folder_path)
|
||||
for current_root, _, filenames in os.walk(root):
|
||||
for name in filenames:
|
||||
if not (name.endswith('.xlsx') or name.endswith('.XLSX')):
|
||||
continue
|
||||
absolute_path = os.path.normpath(os.path.join(current_root, name))
|
||||
relative_path = os.path.relpath(absolute_path, root).replace('\\', '/')
|
||||
items.append({
|
||||
'absolutePath': absolute_path,
|
||||
'relativePath': relative_path,
|
||||
})
|
||||
items.sort(key=lambda item: item['relativePath'])
|
||||
return items
|
||||
|
||||
@brand_bp.route('/api/brand/expand-folder-recursive', methods=['POST'])
|
||||
@login_required
|
||||
def api_brand_expand_folder_recursive():
|
||||
"""递归展开文件夹,返回 .xlsx 文件绝对路径及相对路径列表"""
|
||||
try:
|
||||
_, err = _get_user_id_or_error()
|
||||
if err:
|
||||
return err
|
||||
data = request.get_json() or {}
|
||||
folder = (data.get('folder') or '').strip()
|
||||
if not folder:
|
||||
return jsonify({'success': False, 'error': '请提供 folder 路径'}), 400
|
||||
items = _expand_folder_xlsx_recursive_items(folder)
|
||||
return jsonify({'success': True, 'items': items})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@brand_bp.route('/api/brand/run', methods=['POST'])
|
||||
# @login_required
|
||||
def api_brand_run():
|
||||
"""立即运行:创建任务并后台执行,立即返回 task_id,前端可轮询进度与取消"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
paths = data.get('paths') or []
|
||||
# task_type: 1=立即执行,2=添加任务;此接口默认 1
|
||||
try:
|
||||
task_type = int(data.get('task_type') or 1)
|
||||
except Exception:
|
||||
task_type = 1
|
||||
if task_type not in (1, 2):
|
||||
task_type = 1
|
||||
strategy = (data.get('strategy') or 'Terms').strip()
|
||||
if strategy not in ('Terms', 'Simple'):
|
||||
strategy = 'Terms'
|
||||
if not isinstance(paths, list):
|
||||
paths = []
|
||||
paths = [p.strip() for p in paths if p and isinstance(p, str) and os.path.isfile(p.strip())]
|
||||
if not paths:
|
||||
return jsonify({'success': False, 'error': '没有有效的 xlsx 文件路径'}), 400
|
||||
# 先上传到 OSS,获取链接再入库
|
||||
user_id, err = _get_user_id_or_error()
|
||||
if err:
|
||||
return err
|
||||
urls = []
|
||||
for p in paths:
|
||||
url, err = _upload_local_xlsx_to_oss(p, user_id)
|
||||
if err:
|
||||
return jsonify({'success': False, 'error': f'上传文件失败: {os.path.basename(p)} - {err}'}), 500
|
||||
base_name = os.path.basename(p)
|
||||
urls.append({"fileUrl": url,"originalFilename": base_name,"relativePath": p })
|
||||
|
||||
# 请求提交
|
||||
resp = requests.post(f"{JAVA_API_BASE}/api/brand/tasks?userId={user_id}",headers={
|
||||
"content-type":"application/json",
|
||||
},data=json.dumps({ "files": urls, "strategy": strategy,"taskType": 1,"archiveName": ""}))
|
||||
print({ "files": urls, "strategy": strategy,"taskType": 1,"archiveName": ""})
|
||||
print(f"{JAVA_API_BASE}/api/brand/tasks?userId={user_id}")
|
||||
resp_data = resp.json()
|
||||
# print(f"{JAVA_API_BASE}/api/brand/tasks?userId={user_id} 返回:",resp_data,"状态:",resp.status_code)
|
||||
if resp_data.get("success"):
|
||||
task_id = resp_data.get("data").get("taskId")
|
||||
data = resp_data.get("data")
|
||||
threading.Thread(target=_background_brand_task, args=(task_id,data), daemon=True).start()
|
||||
return jsonify({'success': True, 'task_id': task_id})
|
||||
else:
|
||||
print(resp_data)
|
||||
return jsonify({'success': False, 'error': "请求接口失败"}), 500
|
||||
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@brand_bp.route('/api/brand/tasks', methods=['GET', 'POST'])
|
||||
# @login_required
|
||||
def api_brand_tasks():
|
||||
"""GET: 获取当前用户的任务列表;POST: 添加任务(后台执行)"""
|
||||
if request.method == 'GET':
|
||||
try:
|
||||
user_id, err = _get_user_id_or_error()
|
||||
if err:
|
||||
return err
|
||||
resp = requests.get(f"{JAVA_API_BASE}/api/brand/tasks", params={
|
||||
"userId": user_id
|
||||
})
|
||||
resp_data = resp.json()
|
||||
print("获取列表",resp.text)
|
||||
if resp_data.get("success"):
|
||||
items = resp_data.get("data").get("items")
|
||||
return jsonify({'success': True, 'items': items})
|
||||
return jsonify({'success': True, 'items': []})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
print("获取列表失败",e)
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
# POST
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
paths = data.get('paths') or []
|
||||
# task_type: 1=立即执行,2=添加任务;此接口默认 2
|
||||
try:
|
||||
task_type = int(data.get('task_type') or 2)
|
||||
except Exception:
|
||||
task_type = 2
|
||||
if task_type not in (1, 2):
|
||||
task_type = 2
|
||||
strategy = (data.get('strategy') or 'Terms').strip()
|
||||
if strategy not in ('Terms', 'Simple'):
|
||||
strategy = 'Terms'
|
||||
if not isinstance(paths, list):
|
||||
paths = []
|
||||
paths = [p.strip() for p in paths if p and isinstance(p, str)]
|
||||
if not paths:
|
||||
return jsonify({'success': False, 'error': '请提供至少一个文件路径或链接'}), 400
|
||||
user_id, err = _get_user_id_or_error()
|
||||
if err:
|
||||
return err
|
||||
urls = []
|
||||
for p in paths:
|
||||
url, err = _upload_local_xlsx_to_oss(p, user_id)
|
||||
if err:
|
||||
return jsonify({'success': False, 'error': f'上传文件失败: {os.path.basename(p)} - {err}'}), 500
|
||||
base_name = os.path.basename(p)
|
||||
urls.append({"fileUrl": url, "originalFilename": base_name, "relativePath": p})
|
||||
params = {
|
||||
"userId": user_id
|
||||
}
|
||||
# resp = requests.post(f"{JAVA_API_BASE}/api/brand/tasks?userId={user_id}",headers={
|
||||
# "content-type":"application/json",
|
||||
# },data=json.dumps({ "files": urls, "strategy": strategy,"taskType": 1,"archiveName": ""}))
|
||||
req_data = {"files": urls, "strategy": strategy, "taskType": 2, "archiveName": ""}
|
||||
resp = requests.post(f"{JAVA_API_BASE}/api/brand/tasks?userId={user_id}", headers={"content-type": "application/json"},
|
||||
data=json.dumps(req_data))
|
||||
print(req_data)
|
||||
# print(f"{JAVA_API_BASE}/api/brand/tasks?userId={user_id},返回", resp.text)
|
||||
resp_data = resp.json()
|
||||
if resp_data.get("success"):
|
||||
task_id = resp_data.get("data").get("taskId")
|
||||
data = resp_data.get("data")
|
||||
return jsonify({'success': True, 'task_id': task_id})
|
||||
return jsonify({'success': False, 'task_id': "请求异常"})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@brand_bp.route('/api/brand/tasks/<int:task_id>')
|
||||
# @login_required
|
||||
def api_brand_task_detail(task_id):
|
||||
"""获取单个任务详情(用于轮询状态)"""
|
||||
try:
|
||||
resp = requests.get(f"{JAVA_API_BASE}/api/brand/tasks/{task_id}")
|
||||
resp_data = resp.json()
|
||||
if resp_data.get("success"):
|
||||
task = resp_data["data"]["task"]
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'task': task
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@brand_bp.route('/api/brand/tasks/<int:task_id>/line-progress')
|
||||
# @login_required
|
||||
def api_brand_task_line_progress(task_id):
|
||||
"""
|
||||
获取任务的“当前文件行级进度”(当前行数/总行数)。
|
||||
该信息仅存放在内存字典 _task_line_progress 中,不写入数据库。
|
||||
"""
|
||||
try:
|
||||
resp = requests.get(f"{JAVA_API_BASE}/api/brand/tasks/{task_id}")
|
||||
resp_data = resp.json()
|
||||
if resp_data.get("success"):
|
||||
if resp_data["data"]["line_progress"]["has_progress"]:
|
||||
info = resp_data["data"]["line_progress"]["info"]
|
||||
resp = {
|
||||
'file_index': int(info.get('file_index') or 0),
|
||||
'file_total': int(info.get('file_total') or 0),
|
||||
'file_name': info.get("file_name",""),
|
||||
'current_line': int(info.get('current_line') or 0),
|
||||
'total_lines': int(info.get('total_lines') or 0),
|
||||
}
|
||||
return jsonify({'success': True, 'has_progress': True, 'info': resp})
|
||||
raise RuntimeError("获取进度失败")
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@brand_bp.route('/api/brand/tasks/<int:task_id>/events')
|
||||
# @login_required
|
||||
def api_brand_task_events(task_id):
|
||||
"""SSE:任务完成时后端主动推送事件,前端监听后隐藏进度条与取消按钮"""
|
||||
user_id, err = _get_user_id_or_error()
|
||||
if err:
|
||||
return err
|
||||
|
||||
def _task_status_event():
|
||||
conn = get_db()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT status, error_message FROM brand_crawl_tasks WHERE id = %s AND user_id = %s",
|
||||
(task_id, user_id)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
if not row:
|
||||
return None
|
||||
st = (row.get('status') or '').lower()
|
||||
if st in ('success', 'failed', 'cancelled'):
|
||||
return {'status': st, 'error_message': (row.get('error_message') or '').strip() or None}
|
||||
return None
|
||||
|
||||
# 若任务已处于终态,直接返回一条事件后结束
|
||||
ev = _task_status_event()
|
||||
if ev is not None:
|
||||
def _one_shot():
|
||||
yield "data: " + json.dumps(ev, ensure_ascii=False) + "\n\n"
|
||||
return Response(
|
||||
_one_shot(),
|
||||
mimetype='text/event-stream',
|
||||
headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}
|
||||
)
|
||||
|
||||
# 否则注册队列,等待后台任务完成时推送
|
||||
q = Queue()
|
||||
with _task_event_lock:
|
||||
_task_event_queues.setdefault(task_id, []).append(q)
|
||||
|
||||
def _stream():
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
event = q.get(timeout=20)
|
||||
yield "data: " + json.dumps(event, ensure_ascii=False) + "\n\n"
|
||||
return
|
||||
except Empty:
|
||||
yield ": keepalive\n\n"
|
||||
finally:
|
||||
with _task_event_lock:
|
||||
lst = _task_event_queues.get(task_id, [])
|
||||
if q in lst:
|
||||
lst.remove(q)
|
||||
if not lst:
|
||||
_task_event_queues.pop(task_id, None)
|
||||
|
||||
return Response(
|
||||
_stream(),
|
||||
mimetype='text/event-stream',
|
||||
headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}
|
||||
)
|
||||
|
||||
|
||||
@brand_bp.route('/api/brand/tasks/<int:task_id>/cancel', methods=['POST'])
|
||||
# @login_required
|
||||
def api_brand_task_cancel(task_id):
|
||||
"""取消正在执行或等待中的任务(pending/running 均可取消)"""
|
||||
try:
|
||||
user_id, err = _get_user_id_or_error()
|
||||
if err:
|
||||
return err
|
||||
|
||||
|
||||
resp = requests.post(
|
||||
f"{JAVA_API_BASE}/api/brand/tasks/{task_id}/cancel",
|
||||
headers={"accept": "application/json"},
|
||||
timeout=20
|
||||
)
|
||||
try:
|
||||
resp_data = resp.json()
|
||||
except Exception:
|
||||
return jsonify({'success': False, 'error': '取消接口返回非 JSON'}), 500
|
||||
if not resp_data.get('success'):
|
||||
return jsonify({'success': False, 'error': '第三方取消任务失败'}), 400
|
||||
|
||||
_mark_task_cancelled(task_id)
|
||||
_push_task_event(task_id, {'status': 'cancelled'})
|
||||
return jsonify({'success': True})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@brand_bp.route('/api/brand/tasks/<int:task_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def api_brand_task_delete(task_id):
|
||||
"""删除任务(仅限非 running 状态)"""
|
||||
try:
|
||||
user_id, err = _get_user_id_or_error()
|
||||
if err:
|
||||
return err
|
||||
|
||||
resp = requests.delete(
|
||||
f"{JAVA_API_BASE}/api/brand/tasks/{task_id}",
|
||||
headers={"accept": "application/json"},
|
||||
timeout=20
|
||||
)
|
||||
try:
|
||||
resp_data = resp.json()
|
||||
except Exception:
|
||||
return jsonify({'success': False, 'error': '取消接口返回非 JSON'}), 500
|
||||
if not resp_data.get('success'):
|
||||
return jsonify({'success': False, 'error': '第三方取消任务失败'}), 400
|
||||
return jsonify({'success': True})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@brand_bp.route('/api/brand/download/<int:task_id>')
|
||||
@login_required
|
||||
def api_brand_download(task_id):
|
||||
"""下载任务结果:优先使用 OSS zip_url 重定向;否则本地/单链接/多链接按原逻辑处理"""
|
||||
try:
|
||||
user_id, err = _get_user_id_or_error()
|
||||
if err:
|
||||
return err
|
||||
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT result_paths FROM brand_crawl_tasks WHERE id = %s AND user_id = %s",
|
||||
(task_id, user_id)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.close()
|
||||
if not row or not row.get('result_paths'):
|
||||
return jsonify({'success': False, 'error': '无结果可下载'}), 404
|
||||
raw = row['result_paths']
|
||||
if isinstance(raw, str):
|
||||
raw = _parse_json(raw)
|
||||
url_list, zip_url = _normalize_result_paths(raw)
|
||||
# 新格式:有 zip_url 直接重定向到 OSS
|
||||
if zip_url and _is_url(zip_url):
|
||||
return redirect(zip_url, code=302)
|
||||
|
||||
# 兼容旧格式:result_paths 可能为 list(本地路径或 url)
|
||||
if not url_list and isinstance(raw, list):
|
||||
url_list = raw
|
||||
local_paths = [p for p in url_list if isinstance(p, str) and not _is_url(p) and os.path.isfile(p)]
|
||||
url_paths = [p.strip() for p in url_list if isinstance(p, str) and _is_url(p)]
|
||||
|
||||
if len(url_paths) == 1 and not local_paths:
|
||||
return redirect(url_paths[0], code=302)
|
||||
|
||||
if len(local_paths) == 1 and not url_paths:
|
||||
return send_file(
|
||||
local_paths[0],
|
||||
as_attachment=True,
|
||||
download_name=os.path.basename(local_paths[0])
|
||||
)
|
||||
|
||||
if local_paths or url_paths:
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
for p in local_paths:
|
||||
zf.write(p, os.path.basename(p))
|
||||
for url in url_paths:
|
||||
try:
|
||||
import requests as req
|
||||
r = req.get(url, timeout=30, stream=True)
|
||||
r.raise_for_status()
|
||||
name = os.path.basename(urlparse(url).path) or ('file_%s' % (url_paths.index(url)))
|
||||
if not name or name == 'file_%s' % url_paths.index(url):
|
||||
name = 'download_%s' % url_paths.index(url)
|
||||
zf.writestr(name, r.content)
|
||||
except Exception:
|
||||
pass
|
||||
buf.seek(0)
|
||||
return send_file(
|
||||
buf,
|
||||
mimetype='application/zip',
|
||||
as_attachment=True,
|
||||
download_name='brand_task_%s.zip' % task_id
|
||||
)
|
||||
|
||||
return jsonify({'success': False, 'error': '结果文件不存在或链接不可用'}), 404
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@@ -13,10 +13,10 @@ import subprocess
|
||||
import requests
|
||||
from urllib.parse import urlparse, quote
|
||||
|
||||
from flask import Blueprint, request, jsonify, Response, session, send_file
|
||||
from flask import Blueprint, request, jsonify, Response, send_file
|
||||
from PIL import Image
|
||||
|
||||
from app_common import get_db, login_required, BASE_DIR
|
||||
from app_common import get_db, login_required, current_user_id, BASE_DIR
|
||||
from config import STITCH_WORKFLOW_ID,client_name
|
||||
|
||||
image_bp = Blueprint('image', __name__)
|
||||
@@ -146,11 +146,12 @@ def api_generate():
|
||||
except (TypeError, ValueError):
|
||||
hid = None
|
||||
conn = get_db()
|
||||
_uid = current_user_id()
|
||||
with conn.cursor() as cur:
|
||||
if hid is not None and hid > 0:
|
||||
cur.execute(
|
||||
"SELECT result_urls FROM image_history WHERE id=%s AND user_id=%s",
|
||||
(hid, session['user_id']),
|
||||
(hid, _uid),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
existing_urls = []
|
||||
@@ -182,7 +183,7 @@ def api_generate():
|
||||
_json.dumps(_sanitize_params_for_history(params)),
|
||||
_json.dumps(merged_result_urls),
|
||||
hid,
|
||||
session['user_id'],
|
||||
_uid,
|
||||
),
|
||||
)
|
||||
if cur.rowcount > 0:
|
||||
@@ -192,7 +193,7 @@ def api_generate():
|
||||
"""INSERT INTO image_history (user_id, panel_type, original_urls, params, result_urls, long_image_url)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)""",
|
||||
(
|
||||
session['user_id'],
|
||||
_uid,
|
||||
params.get('panel_type', ''),
|
||||
_json.dumps(result.get('original_urls') or []),
|
||||
_json.dumps(_sanitize_params_for_history(params)),
|
||||
@@ -366,7 +367,7 @@ def api_history():
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
where_user = "user_id = %s"
|
||||
params_where = [session['user_id']]
|
||||
params_where = [current_user_id()]
|
||||
if panel_type:
|
||||
where_user += " AND panel_type = %s"
|
||||
params_where.append(panel_type)
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
"""
|
||||
主页面蓝图:首页、home、图片工作台、品牌页、静态文件、Logo
|
||||
"""
|
||||
"""
|
||||
主页面蓝图:首页、home、图片工作台、品牌页、静态文件、Logo
|
||||
"""
|
||||
import os
|
||||
from flask import Blueprint, send_file, render_template_string
|
||||
|
||||
from app_common import (
|
||||
get_db,
|
||||
_render_html,
|
||||
_is_session_user_valid,
|
||||
login_required,
|
||||
admin_required,
|
||||
STATIC_DIR,
|
||||
BASE_DIR,
|
||||
ASSETS_DIR
|
||||
)
|
||||
from flask import redirect, url_for, session
|
||||
from flask import Flask, request, Response, stream_with_context
|
||||
import requests
|
||||
|
||||
|
||||
from app_common import (
|
||||
get_db,
|
||||
_render_html,
|
||||
login_required,
|
||||
admin_required,
|
||||
current_user_id,
|
||||
current_username,
|
||||
STATIC_DIR,
|
||||
BASE_DIR,
|
||||
ASSETS_DIR
|
||||
)
|
||||
from flask import redirect, url_for
|
||||
from flask import Flask, request, Response, stream_with_context
|
||||
import requests
|
||||
|
||||
from config import base_url,version,JAVA_API_BASE
|
||||
|
||||
main_bp = Blueprint('main', __name__)
|
||||
@@ -63,35 +64,36 @@ def _resolve_asset_filename(filename):
|
||||
return matches[0]
|
||||
|
||||
return safe_name
|
||||
|
||||
|
||||
@main_bp.route('/')
|
||||
def index():
|
||||
if session.get('user_id') and _is_session_user_valid():
|
||||
return redirect(url_for('main.home'))
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
|
||||
@main_bp.route('/home')
|
||||
@login_required
|
||||
def home():
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT username, is_admin FROM users WHERE id = %s", (session['user_id'],))
|
||||
row = cur.fetchone()
|
||||
conn.close()
|
||||
return _render_html('home.html', username=row.get('username', ''), is_admin=bool(row.get('is_admin')), user_id=session.get('user_id'),baseUrl=base_url,version=version)
|
||||
except Exception:
|
||||
return _render_html('home.html', username=session.get('username', ''), is_admin=False, user_id=session.get('user_id'),baseUrl=base_url,version=version)
|
||||
|
||||
|
||||
@main_bp.route('/image')
|
||||
@login_required
|
||||
def wb():
|
||||
return _render_html('index.html')
|
||||
|
||||
|
||||
|
||||
|
||||
@main_bp.route('/')
|
||||
def index():
|
||||
if current_user_id():
|
||||
return redirect(url_for('main.home'))
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
|
||||
@main_bp.route('/home')
|
||||
@login_required
|
||||
def home():
|
||||
uid = current_user_id()
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT username, is_admin FROM users WHERE id = %s", (uid,))
|
||||
row = cur.fetchone()
|
||||
conn.close()
|
||||
return _render_html('home.html', username=row.get('username', ''), is_admin=bool(row.get('is_admin')), user_id=uid, baseUrl=base_url, version=version, java_api_base=JAVA_API_BASE)
|
||||
except Exception:
|
||||
return _render_html('home.html', username=current_username(), is_admin=False, user_id=uid, baseUrl=base_url, version=version, java_api_base=JAVA_API_BASE)
|
||||
|
||||
|
||||
@main_bp.route('/image')
|
||||
@login_required
|
||||
def wb():
|
||||
return _render_html('index.html')
|
||||
|
||||
|
||||
@main_bp.route('/brand')
|
||||
@login_required
|
||||
def brand_page():
|
||||
@@ -112,41 +114,41 @@ def brand_page():
|
||||
if raw[:7] == b'gAAAAAB':
|
||||
raise
|
||||
content = raw.decode('utf-8', errors='replace')
|
||||
return render_template_string(content, user_id=session.get('user_id'))
|
||||
return render_template_string(content, user_id=current_user_id(), java_api_base=JAVA_API_BASE)
|
||||
except Exception:
|
||||
continue
|
||||
return _render_html('brand.html', user_id=session.get('user_id'))
|
||||
|
||||
|
||||
@main_bp.route('/brand/legacy')
|
||||
@login_required
|
||||
def brand_page_legacy():
|
||||
legacy_path = os.path.join(BASE_DIR, 'web_source', 'templates_backup', 'brand.html')
|
||||
if not os.path.isfile(legacy_path):
|
||||
return '', 404
|
||||
with open(legacy_path, 'rb') as f:
|
||||
content = f.read().decode('utf-8', errors='replace')
|
||||
content = content.replace(
|
||||
'<header class="top-bar">',
|
||||
'<header class="top-bar" style="display:none !important;">',
|
||||
1,
|
||||
return _render_html('brand.html', user_id=current_user_id(), java_api_base=JAVA_API_BASE)
|
||||
|
||||
|
||||
@main_bp.route('/brand/legacy')
|
||||
@login_required
|
||||
def brand_page_legacy():
|
||||
legacy_path = os.path.join(BASE_DIR, 'web_source', 'templates_backup', 'brand.html')
|
||||
if not os.path.isfile(legacy_path):
|
||||
return '', 404
|
||||
with open(legacy_path, 'rb') as f:
|
||||
content = f.read().decode('utf-8', errors='replace')
|
||||
content = content.replace(
|
||||
'<header class="top-bar">',
|
||||
'<header class="top-bar" style="display:none !important;">',
|
||||
1,
|
||||
)
|
||||
content = content.replace('height: calc(100vh - 56px);', 'height: 100vh;', 1)
|
||||
return render_template_string(content, user_id=session.get('user_id'))
|
||||
|
||||
|
||||
|
||||
|
||||
@main_bp.route('/static/<path:filename>')
|
||||
def serve_static(filename):
|
||||
"""提供 static 目录及子目录下的静态文件访问。"""
|
||||
filepath = os.path.normpath(os.path.join(STATIC_DIR, filename))
|
||||
static_abs = os.path.abspath(STATIC_DIR)
|
||||
file_abs = os.path.abspath(filepath)
|
||||
if not file_abs.startswith(static_abs) or not os.path.isfile(file_abs):
|
||||
return '', 404
|
||||
return send_file(file_abs, as_attachment=False)
|
||||
|
||||
return render_template_string(content, user_id=current_user_id())
|
||||
|
||||
|
||||
|
||||
|
||||
@main_bp.route('/static/<path:filename>')
|
||||
def serve_static(filename):
|
||||
"""提供 static 目录及子目录下的静态文件访问。"""
|
||||
filepath = os.path.normpath(os.path.join(STATIC_DIR, filename))
|
||||
static_abs = os.path.abspath(STATIC_DIR)
|
||||
file_abs = os.path.abspath(filepath)
|
||||
if not file_abs.startswith(static_abs) or not os.path.isfile(file_abs):
|
||||
return '', 404
|
||||
return send_file(file_abs, as_attachment=False)
|
||||
|
||||
|
||||
@main_bp.route('/assets/<path:filename>')
|
||||
def serve_assets(filename):
|
||||
@@ -165,8 +167,8 @@ def serve_assets(filename):
|
||||
if os.path.isfile(file_abs):
|
||||
return send_file(file_abs, as_attachment=False)
|
||||
return '', 404
|
||||
|
||||
|
||||
|
||||
|
||||
@main_bp.route('/new_web_source/<path:filename>')
|
||||
@login_required
|
||||
def serve_new_web_source(filename):
|
||||
@@ -179,7 +181,7 @@ def serve_new_web_source(filename):
|
||||
if filename.lower().endswith('.html'):
|
||||
with open(file_abs, 'rb') as f:
|
||||
content = f.read().decode('utf-8', errors='replace')
|
||||
raw_uid = session.get('user_id')
|
||||
raw_uid = current_user_id()
|
||||
uid_value = str(int(raw_uid)) if str(raw_uid or '').isdigit() else '""'
|
||||
uid_script = (
|
||||
"\n<script>"
|
||||
@@ -195,22 +197,22 @@ def serve_new_web_source(filename):
|
||||
|
||||
@main_bp.route('/logo.jpg', methods=['GET'])
|
||||
def get_logo_image():
|
||||
return send_file(os.path.join(BASE_DIR, "logo.jpg"), mimetype='image/jpeg')
|
||||
|
||||
|
||||
@main_bp.route('/newApi/<path:path>', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'])
|
||||
def proxy(path):
|
||||
target_url = f"{JAVA_API_BASE}/{path}"
|
||||
|
||||
# 复制请求参数
|
||||
params = request.args.to_dict()
|
||||
|
||||
# 处理请求头
|
||||
headers = {}
|
||||
for key, value in request.headers:
|
||||
if key.lower() in ['host', 'content-length', 'connection']:
|
||||
continue
|
||||
headers[key] = value
|
||||
return send_file(os.path.join(BASE_DIR, "logo.jpg"), mimetype='image/jpeg')
|
||||
|
||||
|
||||
@main_bp.route('/newApi/<path:path>', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'])
|
||||
def proxy(path):
|
||||
target_url = f"{JAVA_API_BASE}/{path}"
|
||||
|
||||
# 复制请求参数
|
||||
params = request.args.to_dict()
|
||||
|
||||
# 处理请求头
|
||||
headers = {}
|
||||
for key, value in request.headers:
|
||||
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/tasks/progress/batch",
|
||||
f"{JAVA_API_BASE}/api/delete-brand/history",
|
||||
@@ -229,36 +231,36 @@ def proxy(path):
|
||||
print("=============================")
|
||||
except Exception as e:
|
||||
print("打印失败", e)
|
||||
|
||||
try:
|
||||
# 使用流式请求
|
||||
req = requests.request(
|
||||
method=request.method,
|
||||
url=target_url,
|
||||
params=params,
|
||||
headers=headers,
|
||||
data=request.get_data() if request.get_data() else None,
|
||||
cookies=request.cookies,
|
||||
stream=True, # 启用流式传输
|
||||
|
||||
try:
|
||||
# 使用流式请求
|
||||
req = requests.request(
|
||||
method=request.method,
|
||||
url=target_url,
|
||||
params=params,
|
||||
headers=headers,
|
||||
data=request.get_data() if request.get_data() else None,
|
||||
cookies=request.cookies,
|
||||
stream=True, # 启用流式传输
|
||||
timeout=proxy_timeout
|
||||
)
|
||||
|
||||
# 流式响应
|
||||
def generate():
|
||||
for chunk in req.iter_content(chunk_size=8192):
|
||||
if chunk:
|
||||
yield chunk
|
||||
|
||||
# 构建响应
|
||||
response = Response(stream_with_context(generate()), status=req.status_code)
|
||||
|
||||
# 复制响应头
|
||||
for key, value in req.headers.items():
|
||||
if key.lower() not in ['content-encoding', 'content-length', 'transfer-encoding', 'connection']:
|
||||
response.headers[key] = value
|
||||
|
||||
return response
|
||||
|
||||
)
|
||||
|
||||
# 流式响应
|
||||
def generate():
|
||||
for chunk in req.iter_content(chunk_size=8192):
|
||||
if chunk:
|
||||
yield chunk
|
||||
|
||||
# 构建响应
|
||||
response = Response(stream_with_context(generate()), status=req.status_code)
|
||||
|
||||
# 复制响应头
|
||||
for key, value in req.headers.items():
|
||||
if key.lower() not in ['content-encoding', 'content-length', 'transfer-encoding', 'connection']:
|
||||
response.headers[key] = value
|
||||
|
||||
return response
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
print(f"后端服务超时:target_url={target_url}, timeout={proxy_timeout}")
|
||||
return Response("后端服务超时", status=504)
|
||||
|
||||
@@ -22,7 +22,7 @@ proxy_url = os.getenv("proxy_url")
|
||||
proxy_mode = int(os.getenv("proxy_mode",1))
|
||||
|
||||
client_name=os.getenv("client_name") + ".exe"
|
||||
JAVA_API_BASE = os.getenv("java_api_base", "http://127.0.0.1:18080")
|
||||
JAVA_API_BASE = os.getenv("java_api_base", "http://api.aishufu.top:18080/").rstrip("/")
|
||||
|
||||
# 紫鸟浏览器配置
|
||||
from urllib.parse import unquote
|
||||
@@ -32,7 +32,7 @@ ZN_USERNAME = unquote(ZN_USERNAME)
|
||||
ZN_PASSWORD = os.getenv("zn_password", "#20zsg25")
|
||||
|
||||
# 删除品牌API配置
|
||||
DELETE_BRAND_API_BASE = os.getenv("DELETE_BRAND_API_BASE", JAVA_API_BASE)
|
||||
DELETE_BRAND_API_BASE = os.getenv("DELETE_BRAND_API_BASE", JAVA_API_BASE).rstrip("/")
|
||||
|
||||
|
||||
cache_path = "./user_data"
|
||||
@@ -50,7 +50,6 @@ file_url_pre = f"https://{bucket}.oss-cn-hangzhou.aliyuncs.com/"
|
||||
|
||||
os.environ['OSS_ACCESS_KEY_ID'] = accessKeyId
|
||||
os.environ['OSS_ACCESS_KEY_SECRET'] = accessKeySecret
|
||||
os.environ['SECRET_KEY'] = "ddffc7c1d02121d9554d7b080b2511b6"
|
||||
|
||||
|
||||
debug = True
|
||||
|
||||
119
app/jwt_util.py
Normal file
119
app/jwt_util.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""
|
||||
JWT 工具:解析 Java 端签发的 token、从请求中提取 token。
|
||||
与 Java 端 JwtService 共用同一个 HS256 密钥(AIIMAGE_JWT_SECRET)。
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from flask import request
|
||||
|
||||
try:
|
||||
import jwt as pyjwt
|
||||
_PYJWT_IMPORT_ERROR = None
|
||||
except ImportError as _e:
|
||||
pyjwt = None
|
||||
_PYJWT_IMPORT_ERROR = _e
|
||||
# 启动时立刻给出醒目提示,避免上线后才发现一直 401
|
||||
print(
|
||||
"[auth] WARNING: PyJWT 未安装,所有依赖 JWT 的接口都会返回 401。"
|
||||
" 请执行 `pip install PyJWT==2.10.1` 或 `pip install -r requirements.txt`。",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
_DEFAULT_SECRET = "please-change-this-secret-please-rotate-at-least-32-bytes"
|
||||
COOKIE_NAME = os.getenv("AIIMAGE_AUTH_COOKIE_NAME", "aiimage_token")
|
||||
|
||||
# 桌面端与 Java 服务器时钟可能漂移(笔记本休眠、用户手动改时间、跨时区等),
|
||||
# 给 JWT exp/nbf 校验留出容差,避免 /api/auth/sync 因为时间不同步而 401。
|
||||
# 默认 5 分钟,可通过 AIIMAGE_JWT_LEEWAY_SECONDS 覆盖。
|
||||
try:
|
||||
_JWT_LEEWAY_SECONDS = int(os.getenv("AIIMAGE_JWT_LEEWAY_SECONDS", "300"))
|
||||
except ValueError:
|
||||
_JWT_LEEWAY_SECONDS = 300
|
||||
|
||||
|
||||
def _signing_key() -> bytes:
|
||||
"""与 Java JwtService.signingKey 保持一致:UTF-8 字节,不足 32 字节右侧补 0。"""
|
||||
secret = os.getenv("AIIMAGE_JWT_SECRET", _DEFAULT_SECRET)
|
||||
key_bytes = secret.encode("utf-8")
|
||||
if len(key_bytes) < 32:
|
||||
key_bytes = key_bytes + b"\x00" * (32 - len(key_bytes))
|
||||
return key_bytes
|
||||
|
||||
|
||||
def _describe_clock_skew(token: str) -> str:
|
||||
"""解析 token 里的 iat/exp,与本地时钟比较,返回 ' iat=.. exp=.. now=.. skew=..s' 字符串。
|
||||
仅供日志使用,不做安全决策;解析失败返回空串,不影响主流程。"""
|
||||
if pyjwt is None:
|
||||
return ""
|
||||
try:
|
||||
unverified = pyjwt.decode(token, options={"verify_signature": False, "verify_exp": False, "verify_iat": False, "verify_nbf": False})
|
||||
except Exception:
|
||||
return ""
|
||||
iat = unverified.get("iat")
|
||||
exp = unverified.get("exp")
|
||||
now = int(time.time())
|
||||
parts = [f"now={now}"]
|
||||
if isinstance(iat, (int, float)):
|
||||
parts.append(f"iat={int(iat)} skew_iat={now - int(iat)}s")
|
||||
if isinstance(exp, (int, float)):
|
||||
parts.append(f"exp={int(exp)} skew_exp={now - int(exp)}s")
|
||||
return ", " + " ".join(parts)
|
||||
|
||||
|
||||
def parse_token_with_reason(token: str) -> Tuple[Optional[dict], Optional[str]]:
|
||||
"""解析 JWT,返回 (payload, error_reason)。
|
||||
payload 命中时 error_reason 为 None;失败时 payload 为 None,error_reason 描述根因。"""
|
||||
if not token:
|
||||
return None, "token 为空"
|
||||
if pyjwt is None:
|
||||
return None, f"PyJWT 未安装({_PYJWT_IMPORT_ERROR})"
|
||||
# 打印 token 头,便于发现 alg 不是 HS256 等情况(不验签,仅 base64 解码 header)
|
||||
try:
|
||||
header = pyjwt.get_unverified_header(token)
|
||||
except Exception as e:
|
||||
return None, f"无法解析 token header: {type(e).__name__}: {e}"
|
||||
alg = header.get("alg") or "?"
|
||||
try:
|
||||
payload = pyjwt.decode(
|
||||
token,
|
||||
_signing_key(),
|
||||
algorithms=["HS256", "HS384", "HS512"],
|
||||
leeway=_JWT_LEEWAY_SECONDS,
|
||||
)
|
||||
except Exception as e:
|
||||
# 常见:ExpiredSignatureError / InvalidSignatureError / DecodeError / InvalidAlgorithmError
|
||||
# 把 token 内 exp/iat 与本地时钟一起打出来,定位"时间漂移"类失败更直接
|
||||
skew_info = _describe_clock_skew(token)
|
||||
return None, f"{type(e).__name__}: {e} (token alg={alg}{skew_info})"
|
||||
sub = payload.get("sub")
|
||||
try:
|
||||
user_id = int(sub) if sub is not None else None
|
||||
except (TypeError, ValueError):
|
||||
return None, f"sub 字段非法: {sub!r}"
|
||||
if user_id is None:
|
||||
return None, "payload 缺少 sub 字段"
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"username": payload.get("username") or "",
|
||||
"device_id": payload.get("deviceId") or "",
|
||||
}, None
|
||||
|
||||
|
||||
def parse_token(token: str) -> Optional[dict]:
|
||||
"""解析 JWT,返回 {user_id, username, device_id};失败返回 None。"""
|
||||
payload, _ = parse_token_with_reason(token)
|
||||
return payload
|
||||
|
||||
|
||||
def get_token_from_request() -> str:
|
||||
"""优先从 Authorization: Bearer 取,其次从 cookie 取。"""
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if auth.startswith("Bearer "):
|
||||
token = auth[len("Bearer "):].strip()
|
||||
if token:
|
||||
return token
|
||||
return (request.cookies.get(COOKIE_NAME) or "").strip()
|
||||
136
app/main.py
136
app/main.py
@@ -4,6 +4,8 @@ import json
|
||||
import sys
|
||||
import shutil
|
||||
import os
|
||||
import uuid
|
||||
import ctypes
|
||||
os.makedirs(cache_path,exist_ok=True)
|
||||
if not debug:
|
||||
today = datetime.datetime.now().strftime("%Y_%m_%d")
|
||||
@@ -20,6 +22,7 @@ import subprocess
|
||||
from amazon.del_brand import kill_process
|
||||
from app import run_app
|
||||
from generate_api import generate
|
||||
from tool.devices import DeviceIDGenerator
|
||||
|
||||
|
||||
with open("version.txt","w",encoding="utf-8") as file:
|
||||
@@ -185,17 +188,7 @@ class WindowAPI:
|
||||
if not result:
|
||||
return {'success': False, 'error': '用户取消'}
|
||||
path = result[0] if isinstance(result, (list, tuple)) else result
|
||||
try:
|
||||
import requests
|
||||
resp = requests.get(url.strip(), timeout=120, stream=True)
|
||||
resp.raise_for_status()
|
||||
with open(path, 'wb') as f:
|
||||
for chunk in resp.iter_content(chunk_size=65536):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
return {'success': True, 'path': path}
|
||||
except Exception as e:
|
||||
return {'success': False, 'error': str(e)}
|
||||
return self._download_url_to_path(url, path)
|
||||
|
||||
def save_file_from_url_new(self, url, default_filename='download.bin'):
|
||||
"""弹窗选择保存位置,从 url 下载文件并保存。根据文件后缀动态设置保存类型。"""
|
||||
@@ -229,18 +222,124 @@ class WindowAPI:
|
||||
return {'success': False, 'error': '用户取消'}
|
||||
|
||||
path = result[0] if isinstance(result, (list, tuple)) else result
|
||||
return self._download_url_to_path(url, path)
|
||||
|
||||
def save_file_from_url_with_progress(self, url, default_filename='download.bin', download_id=''):
|
||||
"""弹窗选择保存位置,从 url 下载文件并通过前端事件上报下载进度。"""
|
||||
print("调用到save_file_from_url_with_progress方法")
|
||||
if not url or not url.strip():
|
||||
return {'success': False, 'error': '下载地址为空'}
|
||||
|
||||
filename = str(default_filename or 'download.bin').strip() or 'download.bin'
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
if ext == '.zip':
|
||||
file_types = ('ZIP 压缩包 (*.zip)', '所有文件 (*.*)')
|
||||
elif ext in ('.xlsx', '.xls'):
|
||||
file_types = ('Excel 文件 (*.xlsx;*.xls)', '所有文件 (*.*)')
|
||||
elif ext == '.csv':
|
||||
file_types = ('CSV 文件 (*.csv)', '所有文件 (*.*)')
|
||||
elif ext == '.txt':
|
||||
file_types = ('文本文件 (*.txt)', '所有文件 (*.*)')
|
||||
elif ext == '.json':
|
||||
file_types = ('JSON 文件 (*.json)', '所有文件 (*.*)')
|
||||
else:
|
||||
file_types = ('所有文件 (*.*)',)
|
||||
|
||||
result = self._window.create_file_dialog(
|
||||
webview.SAVE_DIALOG,
|
||||
save_filename=filename,
|
||||
file_types=file_types
|
||||
)
|
||||
if not result:
|
||||
return {'success': False, 'error': '用户取消'}
|
||||
|
||||
path = result[0] if isinstance(result, (list, tuple)) else result
|
||||
progress_id = str(download_id or uuid.uuid4())
|
||||
return self._download_url_to_path(url, path, progress_id)
|
||||
|
||||
def _download_url_to_path(self, url, path, download_id=''):
|
||||
"""先下载到临时文件,完整下载后再替换为最终文件,避免用户打开半成品。"""
|
||||
final_path = os.path.abspath(path)
|
||||
final_dir = os.path.dirname(final_path) or os.getcwd()
|
||||
final_name = os.path.basename(final_path)
|
||||
temp_path = os.path.join(final_dir, f".{final_name}.crdownload")
|
||||
last_progress_emit = 0.0
|
||||
last_progress_percent = -1
|
||||
try:
|
||||
import requests
|
||||
os.makedirs(final_dir, exist_ok=True)
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
resp = requests.get(url.strip(), timeout=120, stream=True)
|
||||
resp.raise_for_status()
|
||||
with open(path, 'wb') as f:
|
||||
total = int(resp.headers.get('Content-Length') or 0)
|
||||
downloaded = 0
|
||||
self._emit_download_progress(download_id, 'running', final_path, downloaded, total)
|
||||
with open(temp_path, 'wb') as f:
|
||||
self._hide_file(temp_path)
|
||||
for chunk in resp.iter_content(chunk_size=65536):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
return {'success': True, 'path': path}
|
||||
downloaded += len(chunk)
|
||||
percent = int(downloaded * 100 / total) if total else 0
|
||||
now_time = time.time()
|
||||
if percent != last_progress_percent or now_time - last_progress_emit >= 0.2:
|
||||
self._emit_download_progress(download_id, 'running', final_path, downloaded, total)
|
||||
last_progress_emit = now_time
|
||||
last_progress_percent = percent
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
self._show_file(temp_path)
|
||||
os.replace(temp_path, final_path)
|
||||
self._emit_download_progress(download_id, 'success', final_path, downloaded, total)
|
||||
return {'success': True, 'path': final_path}
|
||||
except Exception as e:
|
||||
try:
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
except Exception:
|
||||
pass
|
||||
self._emit_download_progress(download_id, 'failed', final_path, 0, 0, str(e))
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def _emit_download_progress(self, download_id, status, path, downloaded, total, error=''):
|
||||
if not download_id:
|
||||
return
|
||||
payload = {
|
||||
'id': download_id,
|
||||
'status': status,
|
||||
'path': path,
|
||||
'downloaded': downloaded,
|
||||
'total': total,
|
||||
'percent': round(downloaded * 100 / total, 1) if total else 0,
|
||||
'error': error,
|
||||
}
|
||||
script = (
|
||||
"window.dispatchEvent(new CustomEvent('pywebview-download-progress', "
|
||||
f"{{ detail: {json.dumps(payload, ensure_ascii=False)} }}));"
|
||||
)
|
||||
try:
|
||||
self._window.evaluate_js(script)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _hide_file(self, path):
|
||||
"""Windows 下隐藏临时下载文件,减少用户误打开半成品的机会。"""
|
||||
if os.name != 'nt':
|
||||
return
|
||||
try:
|
||||
ctypes.windll.kernel32.SetFileAttributesW(path, 0x02)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _show_file(self, path):
|
||||
"""恢复普通文件属性,避免最终保存文件被隐藏。"""
|
||||
if os.name != 'nt':
|
||||
return
|
||||
try:
|
||||
ctypes.windll.kernel32.SetFileAttributesW(path, 0x80)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def save_template_xlsx(self):
|
||||
"""弹窗选择保存位置,将品牌文档格式模板 xlsx 保存到用户选择的位置。"""
|
||||
template_name = '品牌文档格式_模板.xlsx'
|
||||
@@ -347,6 +446,13 @@ class WindowAPI:
|
||||
except Exception as e:
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def get_device_id(self):
|
||||
"""暴露给 HTML 的设备指纹接口:复用桌面端硬件特征生成 64 位 SHA256 ID。"""
|
||||
try:
|
||||
return {'success': True, 'device_id': DeviceIDGenerator().get_device_id()}
|
||||
except Exception as e:
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def get_detail_del(self,task_id):
|
||||
"""获取正在执行的删除品牌任务详情"""
|
||||
task_info = runing_task.get(task_id)
|
||||
@@ -432,7 +538,7 @@ def main():
|
||||
|
||||
api = WindowAPI(window)
|
||||
window.expose(api.close, api.minimize, api.maximize, api.toggle_maximize, generate_images, api.save_image, api.save_image_to_folder, api.select_folder, api.select_brand_xlsx_files, api.select_brand_folder,
|
||||
api.save_file_from_url, api.save_template_xlsx,api.save_template_zip,api.upload_file_to_java,api.open_external_url,api.enqueue_json,api.get_detail_del,api.save_file_from_url_new)
|
||||
api.save_file_from_url, api.save_template_xlsx,api.save_template_zip,api.upload_file_to_java,api.open_external_url,api.enqueue_json,api.get_detail_del,api.save_file_from_url_new,api.save_file_from_url_with_progress,api.get_device_id)
|
||||
webview.start(
|
||||
debug=True,
|
||||
storage_path=cache_path,
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>格式转换 - 数富AI</title>
|
||||
<script type="module" crossorigin src="/assets/convert.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-3bFwh9Cg.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-D__LhhPM.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-B7UKcPVJ.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/convert-BKSNvX8i.css">
|
||||
<link rel="modulepreload" crossorigin href="/assets/java-modules-B8c-YG5x.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-COze15GJ.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/java-modules-l5anrOZ2.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/convert-6oxZNMye.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>数据去重 - 数富AI</title>
|
||||
<script type="module" crossorigin src="/assets/dedupe.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-3bFwh9Cg.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-D__LhhPM.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-B7UKcPVJ.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/dedupe-DNlVfFj-.css">
|
||||
<link rel="modulepreload" crossorigin href="/assets/java-modules-B8c-YG5x.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-COze15GJ.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/java-modules-l5anrOZ2.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/dedupe-DpfPYQDv.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>删除品牌 - 数富AI</title>
|
||||
<script type="module" crossorigin src="/assets/delete-brand.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-3bFwh9Cg.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-D__LhhPM.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/java-modules-B8c-YG5x.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-COze15GJ.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/categorized-timers-JPA-olTr.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-B7UKcPVJ.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/delete-brand-BJod9K65.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/java-modules-l5anrOZ2.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/delete-brand-DZ3x5hOX.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>数据拆分 - 数富AI</title>
|
||||
<script type="module" crossorigin src="/assets/split.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-3bFwh9Cg.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-D__LhhPM.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-B7UKcPVJ.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/split-BoBVrLdC.css">
|
||||
<link rel="modulepreload" crossorigin href="/assets/java-modules-B8c-YG5x.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-COze15GJ.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/java-modules-l5anrOZ2.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/split-CR1PtDQE.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -54,6 +54,7 @@ pyinstaller==6.19.0
|
||||
pyinstaller-hooks-contrib==2026.3
|
||||
PyMsgBox==2.0.1
|
||||
PyMySQL==1.1.2
|
||||
PyJWT==2.10.1
|
||||
pyperclip==1.11.0
|
||||
PyQt5==5.15.11
|
||||
PyQt5-Qt5==5.15.2
|
||||
|
||||
583
app/web_source/admin.html
Normal file
583
app/web_source/admin.html
Normal file
@@ -0,0 +1,583 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>管理后台 - 数富AI</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
|
||||
background: #f5f5f5;
|
||||
padding: 24px;
|
||||
}
|
||||
.nav { margin-bottom: 24px; }
|
||||
.nav a { color: #667eea; text-decoration: none; }
|
||||
.nav a:hover { text-decoration: underline; }
|
||||
h1 { font-size: 20px; margin-bottom: 20px; }
|
||||
|
||||
/* Tabs */
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
.tab {
|
||||
padding: 12px 24px;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
font-size: 15px;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.tab:hover { color: #667eea; }
|
||||
.tab.active { color: #667eea; font-weight: 600; border-bottom-color: #667eea; }
|
||||
.tab-panel { display: none; }
|
||||
.tab-panel.active { display: block; }
|
||||
|
||||
/* Form & Table */
|
||||
.form-box, .panel-box {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.form-group { margin-bottom: 16px; }
|
||||
.form-group label { display: block; font-size: 14px; margin-bottom: 6px; }
|
||||
.form-group input, .form-group select {
|
||||
width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 6px;
|
||||
}
|
||||
.form-group input[type="checkbox"] { width: auto; margin-right: 8px; }
|
||||
.form-row { display: flex; gap: 16px; flex-wrap: wrap; align-items: flex-end; margin-bottom: 16px; }
|
||||
.form-row .form-group { margin-bottom: 0; flex: 1; min-width: 120px; }
|
||||
|
||||
.btn { padding: 10px 20px; background: #667eea; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; }
|
||||
.btn:hover { opacity: 0.9; }
|
||||
.btn-sm { padding: 6px 12px; font-size: 13px; }
|
||||
.btn-danger { background: #e74c3c; }
|
||||
.btn-secondary { background: #95a5a6; }
|
||||
|
||||
.msg { margin-top: 12px; font-size: 14px; }
|
||||
.msg.ok { color: #27ae60; }
|
||||
.msg.err { color: #e74c3c; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { padding: 12px; text-align: left; border-bottom: 1px solid #eee; font-size: 14px; }
|
||||
th { background: #f8f9fa; font-weight: 600; }
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.pagination span { font-size: 14px; color: #666; }
|
||||
.pagination button { padding: 6px 12px; border: 1px solid #ddd; background: #fff; cursor: pointer; border-radius: 4px; }
|
||||
.pagination button:hover:not(:disabled) { background: #f0f0f0; }
|
||||
.pagination button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.thumb { width: 60px; height: 60px; object-fit: cover; border-radius: 4px; margin-right: 8px; vertical-align: middle; }
|
||||
.thumb-wrap { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.empty-tip { color: #999; font-size: 14px; padding: 24px; text-align: center; }
|
||||
.modal-mask {
|
||||
display: none;
|
||||
position: fixed; left: 0; top: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.4); z-index: 1000;
|
||||
align-items: center; justify-content: center;
|
||||
}
|
||||
.modal-mask.show { display: flex; }
|
||||
.modal { background: #fff; border-radius: 8px; padding: 24px; min-width: 320px; max-width: 90%; }
|
||||
.modal h3 { margin-bottom: 16px; font-size: 16px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="nav"><a href="/home">← 返回首页</a></div>
|
||||
<h1>管理后台</h1>
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tab active" data-tab="users">用户管理</div>
|
||||
<div class="tab" data-tab="history">查看生成记录</div>
|
||||
</div>
|
||||
|
||||
<!-- 用户管理 -->
|
||||
<div id="panel-users" class="tab-panel active">
|
||||
<div class="form-box">
|
||||
<h3 style="margin-bottom:16px;font-size:15px;">创建用户</h3>
|
||||
<p style="margin-bottom:16px;font-size:13px;color:#666;">无注册入口,仅管理员可在此创建用户。层级:超级管理员 → 管理员 → 普通号。</p>
|
||||
<div class="form-group">
|
||||
<label>用户名</label>
|
||||
<input type="text" id="username" placeholder="用户名(至少2个字符)">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>密码</label>
|
||||
<input type="password" id="password" placeholder="密码(至少6个字符)">
|
||||
</div>
|
||||
<div class="form-group" id="formGroupRole">
|
||||
<label>角色</label>
|
||||
<select id="createRole">
|
||||
<option value="normal">普通号</option>
|
||||
<option value="admin" id="optAdmin">管理员</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" id="formGroupCreatedBy" style="display:none;">
|
||||
<label>所属管理员</label>
|
||||
<select id="createCreatedBy">
|
||||
<option value="">请选择管理员</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn" id="btnCreate">创建用户</button>
|
||||
<p class="msg" id="msgCreate"></p>
|
||||
</div>
|
||||
|
||||
<div class="panel-box">
|
||||
<h3 style="margin-bottom:16px;font-size:15px;">用户列表</h3>
|
||||
<div class="form-row" style="margin-bottom:16px;">
|
||||
<div class="form-group" style="min-width:180px;">
|
||||
<label>用户名(模糊搜索)</label>
|
||||
<input type="text" id="searchUsername" placeholder="输入用户名关键字">
|
||||
</div>
|
||||
<div class="form-group" id="filterCreatedByGroup" style="min-width:160px;display:none;">
|
||||
<label>所属管理员</label>
|
||||
<select id="filterCreatedBy">
|
||||
<option value="">全部</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn" id="btnSearchUsers">查询</button>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>用户名</th>
|
||||
<th>角色</th>
|
||||
<th>所属管理员</th>
|
||||
<th>创建时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="userListBody"></tbody>
|
||||
</table>
|
||||
<div class="pagination" id="userPagination"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 查看生成记录 -->
|
||||
<div id="panel-history" class="tab-panel">
|
||||
<div class="form-box">
|
||||
<h3 style="margin-bottom:16px;font-size:15px;">筛选条件</h3>
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="min-width:140px;">
|
||||
<label>指定用户</label>
|
||||
<select id="filterUser">
|
||||
<option value="">全部用户</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="min-width:140px;">
|
||||
<label>开始时间</label>
|
||||
<input type="datetime-local" id="filterTimeStart">
|
||||
</div>
|
||||
<div class="form-group" style="min-width:140px;">
|
||||
<label>结束时间</label>
|
||||
<input type="datetime-local" id="filterTimeEnd">
|
||||
</div>
|
||||
<button class="btn" id="btnFilterHistory">查询</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-box">
|
||||
<h3 style="margin-bottom:16px;font-size:15px;">生成记录</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>用户</th>
|
||||
<th>类型</th>
|
||||
<th>创建时间</th>
|
||||
<th>结果预览</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="historyListBody"></tbody>
|
||||
</table>
|
||||
<div class="pagination" id="historyPagination"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑用户弹窗 -->
|
||||
<div class="modal-mask" id="editUserModal">
|
||||
<div class="modal">
|
||||
<h3>编辑用户</h3>
|
||||
<input type="hidden" id="editUserId">
|
||||
<div class="form-group">
|
||||
<label>用户名</label>
|
||||
<input type="text" id="editUsername" readonly style="background:#f5f5f5;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>新密码(不修改留空)</label>
|
||||
<input type="password" id="editPassword" placeholder="留空则不修改密码">
|
||||
</div>
|
||||
<div class="form-group" id="editFormGroupRole" style="display:none;">
|
||||
<label>角色</label>
|
||||
<select id="editRole">
|
||||
<option value="normal">普通号</option>
|
||||
<option value="admin">管理员</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" id="editFormGroupCreator" style="display:none;">
|
||||
<label>所属管理员</label>
|
||||
<input type="text" id="editCreatorName" readonly style="background:#f5f5f5;">
|
||||
</div>
|
||||
<p class="msg" id="msgEdit"></p>
|
||||
<div style="margin-top:16px;display:flex;gap:8px;">
|
||||
<button class="btn" id="btnSaveUser">保存</button>
|
||||
<button class="btn btn-secondary" id="btnCloseEdit">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var JAVA_API_BASE = "{{ java_api_base or '' }}".replace(/\/+$/, '');
|
||||
var AUTH_TOKEN_KEY = 'aiimage_auth_token';
|
||||
function buildJavaUrl(path) { return JAVA_API_BASE + path; }
|
||||
function getAuthToken() {
|
||||
try { return localStorage.getItem(AUTH_TOKEN_KEY) || ''; } catch (e) { return ''; }
|
||||
}
|
||||
function authHeaders(extra) {
|
||||
var headers = extra || {};
|
||||
var token = getAuthToken();
|
||||
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||
return headers;
|
||||
}
|
||||
function jsonHeaders() {
|
||||
return authHeaders({ 'Content-Type': 'application/json' });
|
||||
}
|
||||
function parseJson(r) { return r.json().catch(function() { return {}; }); }
|
||||
function errMsg(res) {
|
||||
return (res && (res.message || res.error)) || '请求失败';
|
||||
}
|
||||
if (!JAVA_API_BASE) {
|
||||
alert('Java 服务地址未配置,请联系管理员设置 java_api_base');
|
||||
}
|
||||
|
||||
// Tab 切换
|
||||
document.querySelectorAll('.tab').forEach(function(t) {
|
||||
t.onclick = function() {
|
||||
document.querySelectorAll('.tab').forEach(function(x) { x.classList.remove('active'); });
|
||||
document.querySelectorAll('.tab-panel').forEach(function(x) { x.classList.remove('active'); });
|
||||
t.classList.add('active');
|
||||
var id = 'panel-' + t.dataset.tab;
|
||||
document.getElementById(id).classList.add('active');
|
||||
if (t.dataset.tab === 'users') loadUsers(1);
|
||||
else if (t.dataset.tab === 'history') loadHistory(1);
|
||||
};
|
||||
});
|
||||
|
||||
// ========== 用户管理 ==========
|
||||
var userPage = 1, userPageSize = 15;
|
||||
var currentUserRole = 'admin';
|
||||
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(buildJavaUrl('/api/admin/users?' + buildUserListQuery(userPage)), {
|
||||
credentials: 'include',
|
||||
headers: authHeaders()
|
||||
})
|
||||
.then(parseJson)
|
||||
.then(function(res) {
|
||||
var tbody = document.getElementById('userListBody');
|
||||
if (!res.success) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="empty-tip">加载失败: ' + errMsg(res) + '</td></tr>';
|
||||
return;
|
||||
}
|
||||
var data = res.data || {};
|
||||
currentUserRole = data.current_user_role || 'admin';
|
||||
adminsList = data.admins || [];
|
||||
var items = data.items || [];
|
||||
if (items.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="empty-tip">暂无用户</td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = items.map(function(u) {
|
||||
return '<tr><td>' + u.id + '</td><td>' + (u.username || '') + '</td><td>' +
|
||||
roleLabel(u.role || 'normal') + '</td><td>' + (u.creator_username || '-') + '</td><td>' + (u.created_at || '') + '</td><td>' +
|
||||
'<button class="btn btn-sm" data-edit="' + u.id + '" data-user="' + (JSON.stringify(u).replace(/"/g, '"')) + '">编辑</button> ' +
|
||||
'<button class="btn btn-sm btn-danger" data-delete="' + u.id + '" data-name="' + (u.username || '').replace(/"/g, '"') + '">删除</button>' +
|
||||
'</td></tr>';
|
||||
}).join('');
|
||||
}
|
||||
renderPagination('userPagination', data.total, data.page, data.page_size, loadUsers);
|
||||
bindUserActions();
|
||||
updateCreateFormByRole();
|
||||
updateUserFilterByRole();
|
||||
})
|
||||
.catch(function() {
|
||||
document.getElementById('userListBody').innerHTML = '<tr><td colspan="6" class="empty-tip">请求失败</td></tr>';
|
||||
});
|
||||
}
|
||||
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 = '<option value="">全部</option>';
|
||||
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 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 = '<option value="">请选择管理员</option>';
|
||||
adminsList.forEach(function(a) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = a.id;
|
||||
opt.textContent = a.username;
|
||||
selCreatedBy.appendChild(opt);
|
||||
});
|
||||
} else {
|
||||
if (optAdmin) optAdmin.style.display = 'none';
|
||||
roleSel.value = 'normal';
|
||||
formCreatedBy.style.display = 'none';
|
||||
}
|
||||
}
|
||||
function bindUserActions() {
|
||||
document.querySelectorAll('[data-edit]').forEach(function(btn) {
|
||||
btn.onclick = function() {
|
||||
var raw = (btn.getAttribute('data-user') || '{}').replace(/"/g, '"');
|
||||
var u;
|
||||
try { u = JSON.parse(raw); } catch (e) { u = {}; }
|
||||
document.getElementById('editUserId').value = u.id || '';
|
||||
document.getElementById('editUsername').value = u.username || '';
|
||||
document.getElementById('editPassword').value = '';
|
||||
var editRole = document.getElementById('editRole');
|
||||
var editFormGroupRole = document.getElementById('editFormGroupRole');
|
||||
var editFormGroupCreator = document.getElementById('editFormGroupCreator');
|
||||
var editCreatorName = document.getElementById('editCreatorName');
|
||||
editFormGroupRole.style.display = (currentUserRole === 'super_admin' && u.role !== 'super_admin') ? 'block' : 'none';
|
||||
editFormGroupCreator.style.display = (u.role === 'normal' && u.creator_username) ? 'block' : 'none';
|
||||
editCreatorName.value = u.creator_username || '';
|
||||
if (u.role !== 'super_admin') { editRole.value = u.role || 'normal'; }
|
||||
document.getElementById('msgEdit').textContent = '';
|
||||
document.getElementById('editUserModal').classList.add('show');
|
||||
};
|
||||
});
|
||||
document.querySelectorAll('[data-delete]').forEach(function(btn) {
|
||||
btn.onclick = function() {
|
||||
if (!confirm('确定删除用户 "' + (btn.dataset.name || '') + '" 吗?')) return;
|
||||
fetch(buildJavaUrl('/api/admin/user/' + btn.dataset.delete), {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: authHeaders()
|
||||
})
|
||||
.then(parseJson)
|
||||
.then(function(res) {
|
||||
if (res.success) { loadUsers(userPage); }
|
||||
else { alert(errMsg(res)); }
|
||||
});
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('btnSearchUsers').onclick = function() { loadUsers(1); };
|
||||
document.getElementById('createRole').onchange = function() { updateCreateFormByRole(); };
|
||||
document.getElementById('btnCreate').onclick = function() {
|
||||
var username = (document.getElementById('username').value || '').trim();
|
||||
var password = document.getElementById('password').value || '';
|
||||
var role = document.getElementById('createRole').value || 'normal';
|
||||
var createdById = document.getElementById('createCreatedBy').value ? parseInt(document.getElementById('createCreatedBy').value, 10) : null;
|
||||
var msgEl = document.getElementById('msgCreate');
|
||||
msgEl.textContent = '';
|
||||
msgEl.className = 'msg';
|
||||
if (!username || username.length < 2) {
|
||||
msgEl.textContent = '用户名至少2个字符';
|
||||
msgEl.classList.add('err');
|
||||
return;
|
||||
}
|
||||
if (!password || password.length < 6) {
|
||||
msgEl.textContent = '密码至少6个字符';
|
||||
msgEl.classList.add('err');
|
||||
return;
|
||||
}
|
||||
var body = { username: username, password: password, role: role };
|
||||
if (role === 'normal' && currentUserRole === 'super_admin' && createdById) body.createdById = createdById;
|
||||
fetch(buildJavaUrl('/api/admin/user'), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: jsonHeaders(),
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
.then(parseJson)
|
||||
.then(function(res) {
|
||||
if (res.success) {
|
||||
msgEl.textContent = res.message || '创建成功';
|
||||
msgEl.classList.add('ok');
|
||||
document.getElementById('username').value = '';
|
||||
document.getElementById('password').value = '';
|
||||
loadUsers(1);
|
||||
} else {
|
||||
msgEl.textContent = errMsg(res);
|
||||
msgEl.classList.add('err');
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
msgEl.textContent = '请求失败';
|
||||
msgEl.classList.add('err');
|
||||
});
|
||||
};
|
||||
|
||||
document.getElementById('btnSaveUser').onclick = function() {
|
||||
var uid = document.getElementById('editUserId').value;
|
||||
var password = document.getElementById('editPassword').value;
|
||||
var editRoleEl = document.getElementById('editRole');
|
||||
var msgEl = document.getElementById('msgEdit');
|
||||
msgEl.textContent = '';
|
||||
msgEl.className = 'msg';
|
||||
var body = {};
|
||||
if (password) body.password = password;
|
||||
if (currentUserRole === 'super_admin' && editRoleEl && editRoleEl.offsetParent !== null)
|
||||
body.role = editRoleEl.value || 'normal';
|
||||
fetch(buildJavaUrl('/api/admin/user/' + uid), {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
headers: jsonHeaders(),
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
.then(parseJson)
|
||||
.then(function(res) {
|
||||
if (res.success) {
|
||||
msgEl.textContent = res.message || '保存成功';
|
||||
msgEl.classList.add('ok');
|
||||
document.getElementById('editUserModal').classList.remove('show');
|
||||
loadUsers(userPage);
|
||||
} else {
|
||||
msgEl.textContent = errMsg(res);
|
||||
msgEl.classList.add('err');
|
||||
}
|
||||
});
|
||||
};
|
||||
document.getElementById('btnCloseEdit').onclick = function() {
|
||||
document.getElementById('editUserModal').classList.remove('show');
|
||||
};
|
||||
|
||||
// ========== 生成记录 ==========
|
||||
var historyPage = 1, historyPageSize = 15;
|
||||
function toSqlDatetime(val) {
|
||||
if (!val) return '';
|
||||
return val.replace('T', ' ');
|
||||
}
|
||||
function buildHistoryQuery(page) {
|
||||
var q = 'page=' + (page || 1) + '&page_size=' + historyPageSize;
|
||||
var uid = document.getElementById('filterUser').value;
|
||||
var start = toSqlDatetime(document.getElementById('filterTimeStart').value);
|
||||
var end = toSqlDatetime(document.getElementById('filterTimeEnd').value);
|
||||
if (uid) q += '&user_id=' + uid;
|
||||
if (start) q += '&time_start=' + encodeURIComponent(start);
|
||||
if (end) q += '&time_end=' + encodeURIComponent(end);
|
||||
return q;
|
||||
}
|
||||
function loadHistory(page) {
|
||||
historyPage = page || 1;
|
||||
fetch(buildJavaUrl('/api/admin/history?' + buildHistoryQuery(historyPage)), {
|
||||
credentials: 'include',
|
||||
headers: authHeaders()
|
||||
})
|
||||
.then(parseJson)
|
||||
.then(function(res) {
|
||||
var tbody = document.getElementById('historyListBody');
|
||||
if (!res.success) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="empty-tip">加载失败: ' + errMsg(res) + '</td></tr>';
|
||||
return;
|
||||
}
|
||||
var data = res.data || {};
|
||||
var items = data.items || [];
|
||||
if (items.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="empty-tip">暂无记录</td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = items.map(function(h) {
|
||||
var urls = (h.result_urls || []);
|
||||
var thumbUrls = (h.long_image_url ? [h.long_image_url] : []).concat(urls);
|
||||
var thumbs = thumbUrls.slice(0, 3).map(function(url) {
|
||||
return '<img src="' + (url || '').replace(/"/g, '"') + '" class="thumb" alt="">';
|
||||
}).join('');
|
||||
return '<tr><td>' + h.id + '</td><td>' + (h.username || '-') + '</td><td>' +
|
||||
(h.panel_type || '-') + '</td><td>' + (h.created_at || '') + '</td><td>' +
|
||||
'<div class="thumb-wrap">' + (thumbs || '-') + '</div></td></tr>';
|
||||
}).join('');
|
||||
}
|
||||
renderPagination('historyPagination', data.total, data.page, data.page_size, loadHistory);
|
||||
})
|
||||
.catch(function() {
|
||||
document.getElementById('historyListBody').innerHTML = '<tr><td colspan="5" class="empty-tip">请求失败</td></tr>';
|
||||
});
|
||||
}
|
||||
function loadUserOptions() {
|
||||
fetch(buildJavaUrl('/api/admin/users?page=1&page_size=50'), {
|
||||
credentials: 'include',
|
||||
headers: authHeaders()
|
||||
})
|
||||
.then(parseJson)
|
||||
.then(function(res) {
|
||||
var sel = document.getElementById('filterUser');
|
||||
var cur = sel.value;
|
||||
sel.innerHTML = '<option value="">全部用户</option>';
|
||||
var data = res.data || {};
|
||||
(data.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); };
|
||||
|
||||
// ========== 分页 ==========
|
||||
function renderPagination(elId, total, page, pageSize, onPage) {
|
||||
var el = document.getElementById(elId);
|
||||
if (!el) return;
|
||||
total = total || 0;
|
||||
page = page || 1;
|
||||
pageSize = pageSize || 15;
|
||||
var totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
el.innerHTML = '<span>共 ' + total + ' 条</span>' +
|
||||
'<button ' + (page <= 1 ? 'disabled' : '') + ' data-p="' + (page - 1) + '">上一页</button>' +
|
||||
'<span>第 ' + page + ' / ' + totalPages + ' 页</span>' +
|
||||
'<button ' + (page >= totalPages ? 'disabled' : '') + ' data-p="' + (page + 1) + '">下一页</button>';
|
||||
el.querySelectorAll('[data-p]').forEach(function(b) {
|
||||
if (!b.disabled) b.onclick = function() { onPage(parseInt(b.dataset.p, 10)); };
|
||||
});
|
||||
}
|
||||
|
||||
// 初始化
|
||||
loadUsers(1);
|
||||
loadUserOptions();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1071
app/web_source/brand - 副本.html
Normal file
1071
app/web_source/brand - 副本.html
Normal file
File diff suppressed because it is too large
Load Diff
1001
app/web_source/brand-旧.html
Normal file
1001
app/web_source/brand-旧.html
Normal file
File diff suppressed because it is too large
Load Diff
1450
app/web_source/brand.html
Normal file
1450
app/web_source/brand.html
Normal file
File diff suppressed because it is too large
Load Diff
387
app/web_source/home.html
Normal file
387
app/web_source/home.html
Normal file
@@ -0,0 +1,387 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>首页 - 数富AI</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", sans-serif;
|
||||
background: linear-gradient(rgba(255,255,255,0.5), rgba(255,255,255,0.5)),
|
||||
url("/static/bg.jpg") center/cover no-repeat;;
|
||||
/*background-image: url("/static/bg.jpg");*/
|
||||
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
}
|
||||
.header {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
background: rgba(255,255,255,0.5);
|
||||
}
|
||||
.header-title { font-size: 18px; font-weight: 600; color: #333; }
|
||||
.header-right { display: flex; align-items: center; gap: 16px; position: relative; }
|
||||
.header-right a, .header-right span {
|
||||
font-size: 14px; color: #555; text-decoration: none;
|
||||
}
|
||||
.header-right a:hover { color: #667eea; }
|
||||
.entrances {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
.entrance-btn {
|
||||
width: 160px;
|
||||
height: 100px;
|
||||
background: #c5c1c1;
|
||||
border: 4px solid #b8d4e3;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-decoration: none;
|
||||
}
|
||||
.entrance-btn:hover {
|
||||
background: #f8fbfd;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
|
||||
}
|
||||
.entrance-btn.wb { cursor: pointer; }
|
||||
.entrance-btn.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 40px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0,0,0,0.75);
|
||||
color: #fff;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast.show { opacity: 1; }
|
||||
.admin-link {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
}
|
||||
.update-section {
|
||||
margin-top: 48px;
|
||||
padding: 20px 24px;
|
||||
background: rgba(255,255,255,0.85);
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0,0,0,0.06);
|
||||
max-width: 420px;
|
||||
}
|
||||
.update-section-title {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.update-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
.update-version-text {
|
||||
font-size: 13px;
|
||||
color: #555;
|
||||
}
|
||||
.btn-check-update {
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
background: #667eea;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-check-update:hover {
|
||||
background: #5a6fd6;
|
||||
}
|
||||
.update-hint {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-top: 8px;
|
||||
min-height: 18px;
|
||||
}
|
||||
.btn-download-update {
|
||||
margin-top: 8px;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
background: #28a745;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-download-update:hover {
|
||||
background: #218838;
|
||||
}
|
||||
.header-update-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(102,126,234,0.12);
|
||||
color: #4c5bd4;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 15px;
|
||||
padding: 0;
|
||||
}
|
||||
.header-update-btn:hover {
|
||||
background: rgba(102,126,234,0.2);
|
||||
}
|
||||
.header-update-panel {
|
||||
position: absolute;
|
||||
top: 44px;
|
||||
right: 0;
|
||||
width: 280px;
|
||||
padding: 14px 16px;
|
||||
background: rgba(255,255,255,0.98);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.12);
|
||||
border: 1px solid rgba(0,0,0,0.04);
|
||||
z-index: 10;
|
||||
display: none;
|
||||
}
|
||||
.header-update-title {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body data-user-id="{{ user_id or '' }}">
|
||||
<header class="header">
|
||||
<span class="header-title">数富AI</span>
|
||||
<div class="header-right">
|
||||
{% if is_admin %}
|
||||
<!-- <a href="/admin" class="admin-link">用户管理</a>-->
|
||||
{% endif %}
|
||||
<span>{{ username }}</span>
|
||||
<button type="button" class="header-update-btn" id="homeUpdateToggle" title="检测更新">↻</button>
|
||||
<a href="/logout" onclick="return handleLogout(event);">退出</a>
|
||||
<div class="header-update-panel" id="homeUpdatePanel">
|
||||
<div class="header-update-title"><span>↻</span> 软件更新</div>
|
||||
<div class="update-block">
|
||||
<span class="update-version-text" id="homeVersionText">当前版本: {{ version }}</span>
|
||||
<button type="button" class="btn-check-update" id="homeBtnCheckUpdate"><span>↻</span> 检测</button>
|
||||
</div>
|
||||
<div class="update-hint" id="homeUpdateHint"></div>
|
||||
<div style="margin-top: 4px;">
|
||||
<button type="button" class="btn-download-update" id="homeBtnDownloadUpdate" style="display: none;">立即更新</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="entrances" id="entrances">
|
||||
<a class="entrance-btn" href="/brand" data-column-key="brand">亚马逊</a>
|
||||
<a class="entrance-btn disabled" href="javascript:;" data-msg="暂未开通,敬请期待" data-column-key="wb">wildberries</a>
|
||||
<a class="entrance-btn image" href="/image" data-column-key="image">图片</a>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
function getAppPermissionCacheKey(uid) {
|
||||
return 'app_column_permissions:' + String(uid || '');
|
||||
}
|
||||
// 权限校验:根据 Java column-permissions 接口按 column_key 显示入口
|
||||
(function() {
|
||||
localStorage.setItem("uid",{{ user_id }})
|
||||
var uid = document.body.getAttribute('data-user-id');
|
||||
if (!uid) return;
|
||||
var cacheKey = getAppPermissionCacheKey(uid);
|
||||
var AUTH_TOKEN_KEY = 'aiimage_auth_token';
|
||||
var JAVA_API_BASE = "{{ java_api_base or '' }}".replace(/\/+$/, '');
|
||||
var token = '';
|
||||
try { token = localStorage.getItem(AUTH_TOKEN_KEY) || ''; } catch (e) {}
|
||||
if (!JAVA_API_BASE || !token) return;
|
||||
var apiUrl = JAVA_API_BASE + '/api/admin/permission-users/' + uid + '/column-permissions?menuType=app';
|
||||
fetch(apiUrl, {
|
||||
credentials: 'include',
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(res) {
|
||||
if (!res || !res.success || !Array.isArray(res.data)) return;
|
||||
try {
|
||||
localStorage.setItem(cacheKey, JSON.stringify(res.data));
|
||||
} catch (e) {}
|
||||
var allowedKeys = res.data.map(function(item) { return (item.columnKey || item.column_key || '').toLowerCase(); });
|
||||
document.querySelectorAll('.entrances .entrance-btn[data-column-key]').forEach(function(btn) {
|
||||
var key = (btn.getAttribute('data-column-key') || '').toLowerCase();
|
||||
btn.style.display = allowedKeys.indexOf(key) >= 0 ? '' : 'none';
|
||||
});
|
||||
})
|
||||
.catch(function() {});
|
||||
})();
|
||||
|
||||
document.querySelectorAll('.entrance-btn.disabled').forEach(function(btn) {
|
||||
btn.onclick = function(e) {
|
||||
e.preventDefault();
|
||||
var msg = btn.getAttribute('data-msg') || '暂未开通,敬请期待';
|
||||
var t = document.getElementById('toast');
|
||||
t.textContent = msg;
|
||||
t.classList.add('show');
|
||||
setTimeout(function() { t.classList.remove('show'); }, 2000);
|
||||
};
|
||||
});
|
||||
|
||||
function handleLogout(ev) {
|
||||
if (ev && ev.preventDefault) ev.preventDefault();
|
||||
var AUTH_TOKEN_KEY = 'aiimage_auth_token';
|
||||
var JAVA_API_BASE = "{{ java_api_base or '' }}".replace(/\/+$/, '');
|
||||
var token = '';
|
||||
try { token = localStorage.getItem(AUTH_TOKEN_KEY) || ''; } catch (e) {}
|
||||
|
||||
function cleanLocal() {
|
||||
try {
|
||||
localStorage.removeItem('maixiang_api_key');
|
||||
localStorage.removeItem(AUTH_TOKEN_KEY);
|
||||
var uid = document.body.getAttribute('data-user-id');
|
||||
if (uid) {
|
||||
localStorage.removeItem(getAppPermissionCacheKey(uid));
|
||||
}
|
||||
} catch (e) { console.log(e); }
|
||||
}
|
||||
|
||||
function gotoPythonLogout() {
|
||||
cleanLocal();
|
||||
window.location.href = '/logout';
|
||||
}
|
||||
|
||||
if (!JAVA_API_BASE || !token) {
|
||||
gotoPythonLogout();
|
||||
return false;
|
||||
}
|
||||
// 先让 Java 端清 cookie/会话,再走 Python /logout 清 Python cookie
|
||||
var headers = { 'Authorization': 'Bearer ' + token };
|
||||
try {
|
||||
fetch(JAVA_API_BASE + '/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: headers
|
||||
}).then(gotoPythonLogout).catch(gotoPythonLogout);
|
||||
} catch (e) {
|
||||
gotoPythonLogout();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 软件更新(与 index 设置中逻辑一致)
|
||||
(function() {
|
||||
const versionText = document.getElementById('homeVersionText');
|
||||
const updateHint = document.getElementById('homeUpdateHint');
|
||||
const btnDownload = document.getElementById('homeBtnDownloadUpdate');
|
||||
const updatePanel = document.getElementById('homeUpdatePanel');
|
||||
const toggleBtn = document.getElementById('homeUpdateToggle');
|
||||
if (!versionText || !updateHint || !btnDownload || !updatePanel || !toggleBtn) return;
|
||||
|
||||
let currentVersion = '{{version}}';
|
||||
versionText.textContent = '当前版本: v' + currentVersion;
|
||||
|
||||
// 打开/关闭浮层
|
||||
toggleBtn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
const isVisible = updatePanel.style.display === 'block';
|
||||
updatePanel.style.display = isVisible ? 'none' : 'block';
|
||||
});
|
||||
|
||||
// 点击外部关闭浮层
|
||||
document.addEventListener('click', function() {
|
||||
updatePanel.style.display = 'none';
|
||||
});
|
||||
updatePanel.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
document.getElementById('homeBtnCheckUpdate').onclick = async function() {
|
||||
updateHint.textContent = '正在检测更新...';
|
||||
btnDownload.style.display = 'none';
|
||||
try {
|
||||
const resp = await fetch('/api/version', { credentials: 'same-origin' }).catch(function() { return null; });
|
||||
if (resp && resp.ok) {
|
||||
const data = await resp.json();
|
||||
currentVersion = (data.version || currentVersion).replace(/^v/i, '');
|
||||
versionText.textContent = '当前版本: v' + currentVersion;
|
||||
if (data.has_update && data.latest_version) {
|
||||
updateHint.textContent = '发现新版本 v' + data.latest_version + (data.desc ? ':' + data.desc : '');
|
||||
btnDownload.style.display = 'inline-block';
|
||||
btnDownload.textContent = '立即更新';
|
||||
btnDownload.onclick = async function() {
|
||||
if (!confirm('有更新,是否现在更新?\n更新将下载安装包并重启程序。')) return;
|
||||
if (!data.file_url) {
|
||||
updateHint.textContent = '暂无下载地址,请关注官方渠道。';
|
||||
return;
|
||||
}
|
||||
updateHint.textContent = '正在下载并准备更新,程序将自动退出...';
|
||||
btnDownload.disabled = true;
|
||||
try {
|
||||
const updateResp = await fetch('/api/update/do', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ file_url: data.file_url })
|
||||
});
|
||||
const result = await updateResp.json().catch(function() { return {}; });
|
||||
if (result.success) {
|
||||
updateHint.textContent = '更新已启动,程序即将退出...';
|
||||
} else {
|
||||
updateHint.textContent = result.error || '更新启动失败';
|
||||
btnDownload.disabled = false;
|
||||
}
|
||||
} catch (e) {
|
||||
updateHint.textContent = '请求更新失败,请重试';
|
||||
btnDownload.disabled = false;
|
||||
}
|
||||
};
|
||||
} else {
|
||||
updateHint.textContent = '已是最新版本';
|
||||
}
|
||||
} else {
|
||||
updateHint.textContent = '无法连接更新服务,请稍后重试。';
|
||||
}
|
||||
} catch (e) {
|
||||
updateHint.textContent = '检测更新失败';
|
||||
}
|
||||
};
|
||||
btnDownload.style.display = 'none';
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
5833
app/web_source/index.html
Normal file
5833
app/web_source/index.html
Normal file
File diff suppressed because it is too large
Load Diff
263
app/web_source/login.html
Normal file
263
app/web_source/login.html
Normal file
@@ -0,0 +1,263 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>登录 - 南日AI</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", sans-serif;
|
||||
background: #d8e4ec;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
}
|
||||
.header {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
background: rgba(255,255,255,0.5);
|
||||
}
|
||||
.header-title { font-size: 18px; font-weight: 600; color: #333; }
|
||||
.login-box {
|
||||
background: #fff;
|
||||
border: 1px solid #b8d4e3;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
|
||||
padding: 40px;
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
}
|
||||
.login-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
color: #555;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
font-size: 14px;
|
||||
border: 1px solid #b8d4e3;
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
background: #fff;
|
||||
}
|
||||
.form-group input:focus {
|
||||
border-color: #3498db;
|
||||
}
|
||||
.error-msg {
|
||||
color: #e74c3c;
|
||||
font-size: 13px;
|
||||
margin-bottom: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
.btn-login {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #3498db 0%, #2980b9 100%);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.btn-login:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.btn-login:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<span class="header-title">南日AI</span>
|
||||
</header>
|
||||
|
||||
<div class="login-box">
|
||||
<h1 class="login-title">登录</h1>
|
||||
{% if error %}
|
||||
<p class="error-msg">{{ error }}</p>
|
||||
{% endif %}
|
||||
<form id="loginForm" method="POST" action="/login">
|
||||
<div class="form-group">
|
||||
<label>用户名</label>
|
||||
<input type="text" name="username" placeholder="请输入用户名" required autofocus>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>密码</label>
|
||||
<input type="password" name="password" placeholder="请输入密码" required>
|
||||
</div>
|
||||
<button type="submit" class="btn-login" id="btnLogin">登录</button>
|
||||
</form>
|
||||
</div>
|
||||
<script>
|
||||
var JAVA_API_BASE = "{{ java_api_base or '' }}";
|
||||
var AUTH_TOKEN_KEY = 'aiimage_auth_token';
|
||||
var DEVICE_ID_KEY = 'aiimage_device_id';
|
||||
|
||||
function clearAppPermissionCaches() {
|
||||
try {
|
||||
var keysToRemove = [];
|
||||
for (var i = 0; i < localStorage.length; i++) {
|
||||
var key = localStorage.key(i);
|
||||
if (key && key.indexOf('app_column_permissions:') === 0) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
keysToRemove.forEach(function(key) {
|
||||
localStorage.removeItem(key);
|
||||
});
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function waitForPywebview(timeoutMs) {
|
||||
return new Promise(function(resolve) {
|
||||
if (window.pywebview && window.pywebview.api && window.pywebview.api.get_device_id) {
|
||||
resolve(window.pywebview.api);
|
||||
return;
|
||||
}
|
||||
var done = false;
|
||||
var finish = function() {
|
||||
if (done) return;
|
||||
done = true;
|
||||
var api = (window.pywebview && window.pywebview.api) || null;
|
||||
resolve(api);
|
||||
};
|
||||
window.addEventListener('pywebviewready', finish, { once: true });
|
||||
setTimeout(finish, timeoutMs || 1500);
|
||||
});
|
||||
}
|
||||
|
||||
function fetchDeviceId() {
|
||||
return waitForPywebview(2000).then(function(api) {
|
||||
if (!api || typeof api.get_device_id !== 'function') {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
var ret = api.get_device_id();
|
||||
return Promise.resolve(ret).then(function(res) {
|
||||
if (res && res.success && res.device_id) {
|
||||
try { localStorage.setItem(DEVICE_ID_KEY, res.device_id); } catch (e) {}
|
||||
return res.device_id;
|
||||
}
|
||||
return '';
|
||||
}).catch(function() { return ''; });
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function buildJavaUrl(path) {
|
||||
var base = (JAVA_API_BASE || '').replace(/\/+$/, '');
|
||||
return base + path;
|
||||
}
|
||||
|
||||
(function() {
|
||||
var params = new URLSearchParams(window.location.search || '');
|
||||
if (params.get('logout') === '1' || params.get('switch') === '1') {
|
||||
try { localStorage.removeItem(AUTH_TOKEN_KEY); } catch (e) {}
|
||||
}
|
||||
})();
|
||||
|
||||
document.getElementById('loginForm').onsubmit = function(e) {
|
||||
e.preventDefault();
|
||||
var btn = document.getElementById('btnLogin');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '登录中...';
|
||||
var form = e.target;
|
||||
var username = (form.username.value || '').trim();
|
||||
var password = form.password.value || '';
|
||||
|
||||
function showError(msg) {
|
||||
var errEl = document.querySelector('.error-msg');
|
||||
if (!errEl) {
|
||||
errEl = document.createElement('p');
|
||||
errEl.className = 'error-msg';
|
||||
form.insertBefore(errEl, form.firstChild);
|
||||
}
|
||||
errEl.textContent = msg || '登录失败';
|
||||
btn.disabled = false;
|
||||
btn.textContent = '登录';
|
||||
}
|
||||
|
||||
fetchDeviceId().then(function(deviceId) {
|
||||
if (!deviceId) {
|
||||
showError('未获取到设备ID,请在桌面端打开');
|
||||
return;
|
||||
}
|
||||
var payload = { username: username, password: password, deviceId: deviceId };
|
||||
return fetch(buildJavaUrl('/login'), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Device-Id': deviceId,
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(res) {
|
||||
if (!res || !res.success) {
|
||||
showError((res && res.message) || '登录失败');
|
||||
return;
|
||||
}
|
||||
var data = res.data || {};
|
||||
if (data.token) {
|
||||
try { localStorage.setItem(AUTH_TOKEN_KEY, data.token); } catch (e) {}
|
||||
}
|
||||
// 把 Java 签发的 JWT 同步到 Python 套壳同源 cookie,供后续页面跳转携带
|
||||
return fetch('/api/auth/sync', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
body: JSON.stringify({ token: data.token })
|
||||
})
|
||||
.then(function(r) { return r.json().catch(function() { return {}; }); })
|
||||
.then(function() {
|
||||
clearAppPermissionCaches();
|
||||
window.location.href = '/home';
|
||||
})
|
||||
.catch(function() {
|
||||
clearAppPermissionCaches();
|
||||
window.location.href = '/home';
|
||||
});
|
||||
})
|
||||
.catch(function() {
|
||||
showError('网络异常,请稍后重试');
|
||||
});
|
||||
});
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -26,6 +26,7 @@
|
||||
<rocketmq-spring.version>2.3.5</rocketmq-spring.version>
|
||||
<minio.version>8.5.17</minio.version>
|
||||
<javassist.version>3.28.0-GA</javassist.version>
|
||||
<jjwt.version>0.12.6</jjwt.version>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
@@ -104,6 +105,23 @@
|
||||
<artifactId>minio</artifactId>
|
||||
<version>${minio.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
<version>${jjwt.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-impl</artifactId>
|
||||
<version>${jjwt.version}</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-jackson</artifactId>
|
||||
<version>${jjwt.version}</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
|
||||
@@ -9,12 +9,12 @@ import java.util.function.Predicate;
|
||||
|
||||
public final class FailedStatusRowFilter {
|
||||
|
||||
private static final String STATUS_HEADER_CN = "\u72b6\u6001";
|
||||
private static final String FAILED_STATUS_ERROR = "\u9519\u8bef";
|
||||
private static final String FAILED_STATUS_FAILED_CN = "\u5931\u8d25";
|
||||
private static final String STATUS_HEADER_CN = "状态";
|
||||
private static final String FAILED_STATUS_ERROR = "错误";
|
||||
private static final String FAILED_STATUS_FAILED_CN = "失败";
|
||||
private static final String FAILED_STATUS_FAILED_EN = "failed";
|
||||
private static final String NO_MATCHED_ROWS_MESSAGE =
|
||||
"\u72b6\u6001\u5217\u8fc7\u6ee4\u540e\u672a\u627e\u5230 \u9519\u8bef/\u5931\u8d25/FAILED \u6570\u636e";
|
||||
"状态列过滤后未找到 错误/失败/FAILED 数据";
|
||||
|
||||
private static final Set<String> STATUS_HEADER_ALIASES = Set.of(
|
||||
normalizeHeader(STATUS_HEADER_CN),
|
||||
@@ -56,8 +56,8 @@ public final class FailedStatusRowFilter {
|
||||
|
||||
/**
|
||||
* 暴露表头归一化逻辑给其它模块复用,避免每个模块各自维护一份规则。
|
||||
* 处理:去 BOM、全角空格转半角、首尾 trim、连续空白合一、转小写,
|
||||
* 并剥除常见分隔符(含中文全角括号、方括号、冒号)。
|
||||
* 处理:去 BOM、全角空格转半角、首尾 trim、连续空白合并、转小写,
|
||||
* 并剔除常见分隔符(含中文全角括号、方括号、冒号)。
|
||||
*/
|
||||
public static String canonicalizeHeader(String value) {
|
||||
return normalizeHeader(value);
|
||||
@@ -92,8 +92,8 @@ public final class FailedStatusRowFilter {
|
||||
}
|
||||
|
||||
private static String normalizeHeader(String value) {
|
||||
// 扩展:兼容中文全角括号 ()、中文方括号 【】、中文冒号 :,避免
|
||||
// "状态(结果)"、"标题维度(商标)" 等全角括号表头被识别失败。
|
||||
// 兼容中文全角括号()、中文方括号【】、中文冒号:,
|
||||
// 避免“状态(结果)”“标题维度(商标)”等表头识别失败。
|
||||
return normalizeValue(value).replaceAll("[\\s_\\-()\\[\\]{}::()【】]+", "");
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ public class SecurityConfig {
|
||||
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||
configuration.setAllowedHeaders(List.of("*"));
|
||||
configuration.setExposedHeaders(List.of("Content-Disposition"));
|
||||
configuration.setAllowCredentials(false);
|
||||
configuration.setAllowCredentials(true);
|
||||
configuration.setMaxAge(3600L);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
|
||||
@@ -16,7 +16,19 @@ public class SimilarAsinProperties {
|
||||
private String cozeToken = "";
|
||||
private List<CozeCredential> cozeCredentials = new ArrayList<>();
|
||||
private int cozeCredentialStripeSize = 0;
|
||||
private int cozeBatchSize = 10;
|
||||
/**
|
||||
* P0-1:单次提交 Coze 工作流的 row 数量。
|
||||
* 历史值 10,在含 puzzle 多图行的场景下频繁触发 720712008
|
||||
* "node executed out of limit: 1000"。降到 3 以避免节点上限被打爆。
|
||||
* 出现持续 720712008 时还会被 P1-1 滑窗自适应再降到 1。
|
||||
* 不影响 AppearancePatentProperties 的同名值。
|
||||
*/
|
||||
private int cozeBatchSize = 3;
|
||||
/**
|
||||
* img_switch=false 时单次提交 Coze 的 row 数。
|
||||
* 不走图片检测时工作流压力小,恢复到 10 行一批以提高吞吐;开启图片检测时仍使用 cozeBatchSize。
|
||||
*/
|
||||
private int cozeTextOnlyBatchSize = 10;
|
||||
private int cozeConnectTimeoutMillis = 10000;
|
||||
private int cozeReadTimeoutMillis = 60000;
|
||||
private int cozePollIntervalMillis = 30000;
|
||||
@@ -24,6 +36,54 @@ public class SimilarAsinProperties {
|
||||
private int staleTimeoutMinutes = 30;
|
||||
private String staleFinalizeCron = "0 */2 * * * *";
|
||||
|
||||
/**
|
||||
* 同一 credential 两次提交之间的最小间隔(毫秒)。
|
||||
* 历史值硬编码 30000(持锁 sleep),导致单凭证仅 2 batch/分钟。
|
||||
* 几千行任务场景下成为提交吞吐瓶颈,下调到 5000ms 并改为锁外冷却。
|
||||
* 出现 Coze 限流加重时可通过 AIIMAGE_SIMILAR_ASIN_COZE_SUBMIT_MIN_INTERVAL_MILLIS 调高。
|
||||
*/
|
||||
private long cozeSubmitMinIntervalMillis = 5000L;
|
||||
|
||||
/**
|
||||
* 末尾零头 batch 的强制 flush 阈值(分钟):当不足 cozeBatchSize 的零头 row
|
||||
* 长时间挂着(Python 慢回传)时触发提交。
|
||||
* 任务级实测:345 行 / 4h 总耗时中,约 2-3 小时是 batch 永远凑不满 batchSize 在等下一波回传,
|
||||
* 把阈值从 15 调到 1:最多 60s 后 1-2 行也强制提交,让 Coze 提交侧持续进票,
|
||||
* 总耗时降到与 Python 回传节奏接近。配合 cozeBatchSize=3、cozeSubmitMinIntervalMillis=5000,
|
||||
* 实际不会触发 Coze 限流。出现限流加重再调回 5/10。
|
||||
*/
|
||||
private int cozeFlushPendingMinutes = 1;
|
||||
|
||||
/**
|
||||
* 同 batch retry + split retry 共享的最大重试次数。原硬编码 5。
|
||||
*/
|
||||
private int cozeSubmitMaxRetryCount = 5;
|
||||
|
||||
/**
|
||||
* 图片嵌入下载线程池大小。原 SimilarAsinImageEmbedder.DOWNLOAD_POOL_SIZE = 8。
|
||||
* P2-10:1000+ 行 ×3 列图片场景下,pool=16 仍是 assemble 阶段瓶颈(实测下载 244s/918s),
|
||||
* 提到 32 配合 retry=2、global deadline 显著拉低尾延迟;
|
||||
* 受 2GB 堆约束,单图缩略图维持 300KB 以内,整体内存峰值 ≈ 32 * 300KB ≈ 10MB。
|
||||
*/
|
||||
private int imageDownloadPoolSize = 32;
|
||||
|
||||
/**
|
||||
* 单张图片下载超时(秒)。
|
||||
* P2-10:放宽到 8 + retry=1 在快源(aiproxy/m.media-amazon)下没问题,
|
||||
* 但慢源(cbu01.alicdn)会一直挂 8s 才进入 retry,整体串行时间放大。
|
||||
* 调到 5s + retry=2,让慢源更早重试新连接,单图最坏耗时 ≈ 5s * (1+2) = 15s。
|
||||
*/
|
||||
private int imageDownloadTimeoutSeconds = 5;
|
||||
|
||||
/**
|
||||
* assemble 阶段 taskImageCache 的字节上限。
|
||||
* 默认 256MB:5000 行 × 3 列 × 平均 100KB = 1.5GB 远超 2GB 堆,
|
||||
* 用 BoundedImageCache 按字节累计 LRU 淘汰避免爆堆。
|
||||
* 由于 embed() 写完即 remove(),活跃图片字节通常 ≤ 100MB,仅在极端 prefetch 领先场景才会触发淘汰。
|
||||
* 出现淘汰过频影响命中率时可上调到 512MB;2GB 堆约束下不建议超过 768MB。
|
||||
*/
|
||||
private long imageCacheMaxBytes = 256L * 1024L * 1024L;
|
||||
|
||||
/**
|
||||
* 是否在 Coze 请求 parameters 中附带 api_key 字段。
|
||||
* 默认 true:线上 Coze 工作流将该字段视为必填,缺失会得到 4000
|
||||
@@ -41,6 +101,31 @@ public class SimilarAsinProperties {
|
||||
*/
|
||||
private boolean cozeUseLegacyItemFieldOrder = false;
|
||||
|
||||
/**
|
||||
* 是否启用 P0-3 merge 增量缓冲:每个 batch DONE 时仅缓冲 cozeRows,
|
||||
* 不立即合并到 chunk;finalize 前一次性按 chunkScopeHash 分组合并,
|
||||
* 把 OSS chunk 读写从 1000+ 次降到 chunk 数量级。
|
||||
* 仅作用于"正常 poll DONE"路径;失败 batch / 单 batch 任务 / 其他
|
||||
* 11 个 mergeCozeRowsIntoChunk 调用点保留原立即 merge 行为。
|
||||
* 出现问题时可通过 AIIMAGE_SIMILAR_ASIN_COZE_RESULT_BUFFER_ENABLED=false
|
||||
* 一键回滚到老路径。
|
||||
*/
|
||||
private boolean cozeResultBufferEnabled = true;
|
||||
|
||||
/**
|
||||
* P0-4:单 credential 抢 Coze 提交锁的最长等待时间(毫秒)。
|
||||
* 原硬编码 1000ms,在高并发 split retry 时大量抛 "Coze submit throttle lock timeout"
|
||||
* 并把整批行 markFailed。应与 cozeSubmitMinIntervalMillis(5000ms)保持 1.5-2 倍关系,
|
||||
* 默认 10000ms 给抢锁更多时间。
|
||||
*/
|
||||
private long cozeSubmitLockWaitMillis = 10000L;
|
||||
|
||||
/**
|
||||
* P0-4:抢 Coze 提交锁失败后下次重试间隔(毫秒)。
|
||||
* 原硬编码 500ms,会在指数退避算法中作为基础值(500/1000/2000/4000ms 上限 4000)。
|
||||
*/
|
||||
private long cozeSubmitLockRetryDelayMillis = 500L;
|
||||
|
||||
@Data
|
||||
public static class CozeCredential {
|
||||
private String name;
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.nanri.aiimage.modules.admin.controller;
|
||||
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.modules.admin.model.dto.AdminUserCreateRequest;
|
||||
import com.nanri.aiimage.modules.admin.model.dto.AdminUserUpdateRequest;
|
||||
import com.nanri.aiimage.modules.admin.model.vo.AdminUserListVo;
|
||||
import com.nanri.aiimage.modules.admin.service.AdminUserService;
|
||||
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
|
||||
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/admin")
|
||||
@Tag(name = "管理后台-用户", description = "用户列表、创建、更新、删除")
|
||||
public class AdminUserController {
|
||||
|
||||
private final AdminAuthSupport adminAuthSupport;
|
||||
private final AdminUserService adminUserService;
|
||||
|
||||
@GetMapping("/users")
|
||||
@Operation(summary = "分页查询用户列表")
|
||||
public ApiResponse<AdminUserListVo> listUsers(HttpServletRequest request,
|
||||
@RequestParam(required = false, defaultValue = "1") Integer page,
|
||||
@RequestParam(name = "page_size", required = false, defaultValue = "15") Integer pageSize,
|
||||
@RequestParam(required = false) String username,
|
||||
@RequestParam(name = "search", required = false) String search,
|
||||
@RequestParam(name = "created_by_id", required = false) Long createdById,
|
||||
@RequestParam(name = "admin_id", required = false) Long adminId) {
|
||||
AdminUserEntity currentUser = adminAuthSupport.requireAdmin(request);
|
||||
String kw = (username == null || username.isBlank()) ? search : username;
|
||||
Long filterCreatedBy = createdById != null ? createdById : adminId;
|
||||
AdminUserListVo vo = adminUserService.listUsers(currentUser, page, pageSize, kw, filterCreatedBy);
|
||||
return ApiResponse.success(vo);
|
||||
}
|
||||
|
||||
@PostMapping("/user")
|
||||
@Operation(summary = "创建用户")
|
||||
public ApiResponse<Void> createUser(HttpServletRequest request,
|
||||
@RequestBody AdminUserCreateRequest body) {
|
||||
AdminUserEntity currentUser = adminAuthSupport.requireAdmin(request);
|
||||
adminUserService.createUser(currentUser, body);
|
||||
return ApiResponse.success("用户创建成功", null);
|
||||
}
|
||||
|
||||
@PutMapping("/user/{uid}")
|
||||
@Operation(summary = "更新用户")
|
||||
public ApiResponse<Void> updateUser(HttpServletRequest request,
|
||||
@PathVariable Long uid,
|
||||
@RequestBody AdminUserUpdateRequest body) {
|
||||
AdminUserEntity currentUser = adminAuthSupport.requireAdmin(request);
|
||||
adminUserService.updateUser(currentUser, uid, body);
|
||||
return ApiResponse.success("更新成功", null);
|
||||
}
|
||||
|
||||
@DeleteMapping("/user/{uid}")
|
||||
@Operation(summary = "删除用户")
|
||||
public ApiResponse<Void> deleteUser(HttpServletRequest request,
|
||||
@PathVariable Long uid) {
|
||||
AdminUserEntity currentUser = adminAuthSupport.requireAdmin(request);
|
||||
adminUserService.deleteUser(currentUser, uid);
|
||||
return ApiResponse.success("删除成功", null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.nanri.aiimage.modules.admin.model.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class AdminUserCreateRequest {
|
||||
private String username;
|
||||
private String password;
|
||||
private String role;
|
||||
private Long createdById;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.nanri.aiimage.modules.admin.model.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class AdminUserUpdateRequest {
|
||||
private String password;
|
||||
private String role;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.nanri.aiimage.modules.admin.model.vo;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class AdminBriefVo {
|
||||
private Long id;
|
||||
private String username;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.nanri.aiimage.modules.admin.model.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class AdminUserItemVo {
|
||||
private Long id;
|
||||
private String username;
|
||||
@JsonProperty("is_admin")
|
||||
private boolean admin;
|
||||
private String role;
|
||||
@JsonProperty("created_by_id")
|
||||
private Long createdById;
|
||||
@JsonProperty("creator_username")
|
||||
private String creatorUsername;
|
||||
@JsonProperty("created_at")
|
||||
private String createdAt;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.nanri.aiimage.modules.admin.model.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class AdminUserListVo {
|
||||
private List<AdminUserItemVo> items;
|
||||
private Long total;
|
||||
private Integer page;
|
||||
@JsonProperty("page_size")
|
||||
private Integer pageSize;
|
||||
@JsonProperty("current_user_role")
|
||||
private String currentUserRole;
|
||||
private List<AdminBriefVo> admins;
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package com.nanri.aiimage.modules.admin.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.modules.admin.model.dto.AdminUserCreateRequest;
|
||||
import com.nanri.aiimage.modules.admin.model.dto.AdminUserUpdateRequest;
|
||||
import com.nanri.aiimage.modules.admin.model.vo.AdminBriefVo;
|
||||
import com.nanri.aiimage.modules.admin.model.vo.AdminUserItemVo;
|
||||
import com.nanri.aiimage.modules.admin.model.vo.AdminUserListVo;
|
||||
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
|
||||
import com.nanri.aiimage.modules.auth.util.WerkzeugPasswordEncoder;
|
||||
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
|
||||
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AdminUserService {
|
||||
|
||||
private static final DateTimeFormatter CREATED_AT_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
|
||||
|
||||
private final AdminUserMapper adminUserMapper;
|
||||
private final WerkzeugPasswordEncoder passwordEncoder;
|
||||
private final AdminAuthSupport adminAuthSupport;
|
||||
|
||||
public AdminUserListVo listUsers(AdminUserEntity currentUser, Integer page, Integer pageSize,
|
||||
String username, Long createdById) {
|
||||
String role = adminAuthSupport.currentRole(currentUser);
|
||||
if (role == null) {
|
||||
throw new BusinessException(403, "需要管理员权限");
|
||||
}
|
||||
int safePage = page == null || page < 1 ? 1 : page;
|
||||
int safeSize = pageSize == null ? 15 : pageSize;
|
||||
if (safeSize < 5) safeSize = 5;
|
||||
if (safeSize > 50) safeSize = 50;
|
||||
|
||||
String kw = username == null ? "" : username.trim();
|
||||
Long filterCreatedBy = "super_admin".equals(role) ? createdById : null;
|
||||
|
||||
LambdaQueryWrapper<AdminUserEntity> query = new LambdaQueryWrapper<>();
|
||||
if ("super_admin".equals(role)) {
|
||||
if (!kw.isEmpty()) {
|
||||
query.like(AdminUserEntity::getUsername, kw);
|
||||
}
|
||||
if (filterCreatedBy != null) {
|
||||
query.eq(AdminUserEntity::getCreatedById, filterCreatedBy);
|
||||
}
|
||||
} else {
|
||||
Long adminId = currentUser.getId();
|
||||
query.and(w -> w
|
||||
.eq(AdminUserEntity::getId, adminId)
|
||||
.or(inner -> inner
|
||||
.eq(AdminUserEntity::getRole, "normal")
|
||||
.eq(AdminUserEntity::getCreatedById, adminId)));
|
||||
if (!kw.isEmpty()) {
|
||||
query.like(AdminUserEntity::getUsername, kw);
|
||||
}
|
||||
}
|
||||
query.orderByAsc(AdminUserEntity::getId);
|
||||
|
||||
Long total = adminUserMapper.selectCount(query);
|
||||
int offset = (safePage - 1) * safeSize;
|
||||
query.last("LIMIT " + safeSize + " OFFSET " + offset);
|
||||
List<AdminUserEntity> rows = adminUserMapper.selectList(query);
|
||||
|
||||
Map<Long, String> creatorMap = loadCreatorMap(rows);
|
||||
|
||||
List<AdminUserItemVo> items = rows.stream().map(r -> toItem(r, creatorMap)).toList();
|
||||
List<AdminBriefVo> admins = "super_admin".equals(role)
|
||||
? adminUserMapper.selectList(new LambdaQueryWrapper<AdminUserEntity>()
|
||||
.eq(AdminUserEntity::getRole, "admin")
|
||||
.orderByAsc(AdminUserEntity::getId)).stream()
|
||||
.map(u -> new AdminBriefVo(u.getId(), u.getUsername()))
|
||||
.toList()
|
||||
: List.of();
|
||||
|
||||
AdminUserListVo vo = new AdminUserListVo();
|
||||
vo.setItems(items);
|
||||
vo.setTotal(total == null ? 0L : total);
|
||||
vo.setPage(safePage);
|
||||
vo.setPageSize(safeSize);
|
||||
vo.setCurrentUserRole(role);
|
||||
vo.setAdmins(admins);
|
||||
return vo;
|
||||
}
|
||||
|
||||
public void createUser(AdminUserEntity currentUser, AdminUserCreateRequest request) {
|
||||
String role = adminAuthSupport.currentRole(currentUser);
|
||||
if (role == null) {
|
||||
throw new BusinessException(403, "需要管理员权限");
|
||||
}
|
||||
String username = request.getUsername() == null ? "" : request.getUsername().trim();
|
||||
String password = request.getPassword() == null ? "" : request.getPassword();
|
||||
String wantRole = request.getRole() == null ? "normal" : request.getRole().trim();
|
||||
if (wantRole.isEmpty()) wantRole = "normal";
|
||||
if (!"admin".equals(wantRole) && !"normal".equals(wantRole)) {
|
||||
wantRole = "normal";
|
||||
}
|
||||
if ("admin".equals(role) && "admin".equals(wantRole)) {
|
||||
throw new BusinessException("仅超级管理员可创建管理员");
|
||||
}
|
||||
if (username.isEmpty() || password.isEmpty()) {
|
||||
throw new BusinessException("用户名和密码不能为空");
|
||||
}
|
||||
if (username.length() < 2) {
|
||||
throw new BusinessException("用户名至少2个字符");
|
||||
}
|
||||
if (password.length() < 6) {
|
||||
throw new BusinessException("密码至少6个字符");
|
||||
}
|
||||
|
||||
Long wantCreatedBy;
|
||||
if ("admin".equals(wantRole)) {
|
||||
wantCreatedBy = currentUser.getId();
|
||||
} else if ("super_admin".equals(role)) {
|
||||
wantCreatedBy = request.getCreatedById();
|
||||
if (wantCreatedBy == null) {
|
||||
AdminUserEntity firstAdmin = adminUserMapper.selectOne(new LambdaQueryWrapper<AdminUserEntity>()
|
||||
.eq(AdminUserEntity::getRole, "admin")
|
||||
.orderByAsc(AdminUserEntity::getId)
|
||||
.last("LIMIT 1"));
|
||||
wantCreatedBy = firstAdmin != null ? firstAdmin.getId() : currentUser.getId();
|
||||
}
|
||||
} else {
|
||||
wantCreatedBy = currentUser.getId();
|
||||
}
|
||||
|
||||
int isAdmin = ("super_admin".equals(wantRole) || "admin".equals(wantRole)) ? 1 : 0;
|
||||
AdminUserEntity entity = new AdminUserEntity();
|
||||
entity.setUsername(username);
|
||||
entity.setPasswordHash(passwordEncoder.hash(password));
|
||||
entity.setIsAdmin(isAdmin);
|
||||
entity.setRole(wantRole);
|
||||
entity.setCreatedById(wantCreatedBy);
|
||||
try {
|
||||
adminUserMapper.insert(entity);
|
||||
} catch (DuplicateKeyException e) {
|
||||
throw new BusinessException("用户名已存在");
|
||||
}
|
||||
}
|
||||
|
||||
public void updateUser(AdminUserEntity currentUser, Long uid, AdminUserUpdateRequest request) {
|
||||
String role = adminAuthSupport.currentRole(currentUser);
|
||||
if (role == null) {
|
||||
throw new BusinessException(403, "需要管理员权限");
|
||||
}
|
||||
String password = request.getPassword();
|
||||
String wantRole = request.getRole() == null ? null : request.getRole().trim();
|
||||
|
||||
if ((password == null || password.isEmpty()) && (wantRole == null || wantRole.isEmpty())) {
|
||||
throw new BusinessException("请提供要修改的内容");
|
||||
}
|
||||
|
||||
AdminUserEntity target = adminUserMapper.selectById(uid);
|
||||
if (target == null) {
|
||||
throw new BusinessException("用户不存在");
|
||||
}
|
||||
String targetRole = target.getRole() == null ? "" : target.getRole();
|
||||
|
||||
if ("admin".equals(role)) {
|
||||
if (!"normal".equals(targetRole) || !currentUser.getId().equals(target.getCreatedById())) {
|
||||
throw new BusinessException(403, "只能编辑自己创建的普通用户");
|
||||
}
|
||||
wantRole = null;
|
||||
} else {
|
||||
if ("super_admin".equals(targetRole)) {
|
||||
throw new BusinessException("不能修改超级管理员");
|
||||
}
|
||||
if ("super_admin".equals(wantRole)) {
|
||||
throw new BusinessException("不能将用户设为超级管理员");
|
||||
}
|
||||
if (wantRole != null && !"admin".equals(wantRole) && !"normal".equals(wantRole) && !wantRole.isEmpty()) {
|
||||
wantRole = null;
|
||||
}
|
||||
}
|
||||
|
||||
LambdaUpdateWrapper<AdminUserEntity> update = new LambdaUpdateWrapper<AdminUserEntity>()
|
||||
.eq(AdminUserEntity::getId, uid);
|
||||
boolean dirty = false;
|
||||
if (password != null && !password.isEmpty()) {
|
||||
if (password.length() < 6) {
|
||||
throw new BusinessException("密码至少6个字符");
|
||||
}
|
||||
update.set(AdminUserEntity::getPasswordHash, passwordEncoder.hash(password));
|
||||
dirty = true;
|
||||
}
|
||||
if (wantRole != null && !wantRole.isEmpty()) {
|
||||
int isAdmin = "admin".equals(wantRole) ? 1 : 0;
|
||||
update.set(AdminUserEntity::getIsAdmin, isAdmin);
|
||||
update.set(AdminUserEntity::getRole, wantRole);
|
||||
dirty = true;
|
||||
}
|
||||
if (dirty) {
|
||||
adminUserMapper.update(null, update);
|
||||
}
|
||||
}
|
||||
|
||||
public void deleteUser(AdminUserEntity currentUser, Long uid) {
|
||||
String role = adminAuthSupport.currentRole(currentUser);
|
||||
if (role == null) {
|
||||
throw new BusinessException(403, "需要管理员权限");
|
||||
}
|
||||
if (currentUser.getId().equals(uid)) {
|
||||
throw new BusinessException("不能删除当前登录账号");
|
||||
}
|
||||
AdminUserEntity target = adminUserMapper.selectById(uid);
|
||||
if (target == null) {
|
||||
throw new BusinessException("用户不存在");
|
||||
}
|
||||
String targetRole = target.getRole() == null ? "" : target.getRole();
|
||||
if ("super_admin".equals(targetRole)) {
|
||||
throw new BusinessException("不能删除超级管理员");
|
||||
}
|
||||
if ("admin".equals(role)) {
|
||||
if (!"normal".equals(targetRole) || !currentUser.getId().equals(target.getCreatedById())) {
|
||||
throw new BusinessException(403, "只能删除自己创建的普通用户");
|
||||
}
|
||||
}
|
||||
int affected = adminUserMapper.deleteById(uid);
|
||||
if (affected == 0) {
|
||||
throw new BusinessException("用户不存在");
|
||||
}
|
||||
}
|
||||
|
||||
private Map<Long, String> loadCreatorMap(List<AdminUserEntity> rows) {
|
||||
Set<Long> creatorIds = new LinkedHashSet<>();
|
||||
for (AdminUserEntity r : rows) {
|
||||
if (r.getCreatedById() != null && r.getCreatedById() > 0) {
|
||||
creatorIds.add(r.getCreatedById());
|
||||
}
|
||||
}
|
||||
if (creatorIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
List<AdminUserEntity> creators = adminUserMapper.selectList(new LambdaQueryWrapper<AdminUserEntity>()
|
||||
.in(AdminUserEntity::getId, new ArrayList<>(creatorIds)));
|
||||
Map<Long, String> map = new HashMap<>(creators.size() * 2);
|
||||
for (AdminUserEntity c : creators) {
|
||||
map.put(c.getId(), c.getUsername() == null ? "" : c.getUsername());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private AdminUserItemVo toItem(AdminUserEntity entity, Map<Long, String> creatorMap) {
|
||||
AdminUserItemVo vo = new AdminUserItemVo();
|
||||
vo.setId(entity.getId());
|
||||
vo.setUsername(entity.getUsername());
|
||||
vo.setAdmin(entity.getIsAdmin() != null && entity.getIsAdmin() == 1);
|
||||
vo.setRole(entity.getRole() == null || entity.getRole().isEmpty() ? "normal" : entity.getRole());
|
||||
vo.setCreatedById(entity.getCreatedById());
|
||||
vo.setCreatorUsername(creatorMap.getOrDefault(entity.getCreatedById(), ""));
|
||||
LocalDateTime createdAt = entity.getCreatedAt();
|
||||
vo.setCreatedAt(createdAt == null ? "" : createdAt.format(CREATED_AT_FORMATTER));
|
||||
return vo;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.nanri.aiimage.modules.admin.support;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.modules.auth.service.JwtService;
|
||||
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
|
||||
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import com.nanri.aiimage.modules.auth.config.AuthProperties;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class AdminAuthSupport {
|
||||
|
||||
private final JwtService jwtService;
|
||||
private final AdminUserMapper adminUserMapper;
|
||||
private final AuthProperties authProperties;
|
||||
|
||||
/** 解析当前请求的用户;token 缺失或无效抛 401。 */
|
||||
public AdminUserEntity requireUser(HttpServletRequest request) {
|
||||
String token = resolveToken(request);
|
||||
if (token == null || token.isBlank()) {
|
||||
throw new BusinessException(401, "未登录");
|
||||
}
|
||||
Claims claims = jwtService.parse(token);
|
||||
if (claims == null) {
|
||||
throw new BusinessException(401, "登录已过期,请重新登录");
|
||||
}
|
||||
Long userId;
|
||||
try {
|
||||
userId = Long.parseLong(claims.getSubject());
|
||||
} catch (NumberFormatException e) {
|
||||
throw new BusinessException(401, "登录态无效");
|
||||
}
|
||||
AdminUserEntity user = adminUserMapper.selectById(userId);
|
||||
if (user == null) {
|
||||
throw new BusinessException(401, "用户不存在");
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
/** 当前用户必须是管理员或超级管理员,否则抛 403。 */
|
||||
public AdminUserEntity requireAdmin(HttpServletRequest request) {
|
||||
AdminUserEntity user = requireUser(request);
|
||||
String role = currentRole(user);
|
||||
if (role == null) {
|
||||
throw new BusinessException(403, "需要管理员权限");
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
/** 计算当前用户的管理角色:super_admin / admin / null。 */
|
||||
public String currentRole(AdminUserEntity user) {
|
||||
if (user == null) {
|
||||
return null;
|
||||
}
|
||||
String storedRole = user.getRole() == null ? "" : user.getRole().trim().toLowerCase();
|
||||
if ("super_admin".equals(storedRole)) {
|
||||
return "super_admin";
|
||||
}
|
||||
if ("admin".equals(storedRole)) {
|
||||
return resolveAdminRole(user);
|
||||
}
|
||||
boolean isAdminFlag = user.getIsAdmin() != null && user.getIsAdmin() == 1;
|
||||
if (isAdminFlag) {
|
||||
return resolveAdminRole(user);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 数据库里 role='admin' 中 id 最小者视作超级管理员(与 Python 行为一致)。 */
|
||||
private String resolveAdminRole(AdminUserEntity user) {
|
||||
AdminUserEntity superAdmin = adminUserMapper.selectOne(new LambdaQueryWrapper<AdminUserEntity>()
|
||||
.eq(AdminUserEntity::getRole, "admin")
|
||||
.orderByAsc(AdminUserEntity::getId)
|
||||
.last("LIMIT 1"));
|
||||
if (superAdmin != null && superAdmin.getId().equals(user.getId())) {
|
||||
return "super_admin";
|
||||
}
|
||||
return "admin";
|
||||
}
|
||||
|
||||
private String resolveToken(HttpServletRequest request) {
|
||||
String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
|
||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||
String t = authHeader.substring(7).trim();
|
||||
if (!t.isEmpty()) {
|
||||
return t;
|
||||
}
|
||||
}
|
||||
Cookie[] cookies = request.getCookies();
|
||||
if (cookies == null) {
|
||||
return null;
|
||||
}
|
||||
String cookieName = authProperties.getCookieName();
|
||||
for (Cookie cookie : cookies) {
|
||||
if (cookieName.equals(cookie.getName())) {
|
||||
return cookie.getValue();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -355,14 +355,18 @@ public class AppearancePatentCozeClient {
|
||||
List<String> rowIds = rows.stream().map(row -> nonBlank(row.getId(), "")).toList();
|
||||
List<String> asins = rows.stream().map(row -> nonBlank(row.getAsin(), "")).toList();
|
||||
List<String> countries = rows.stream().map(row -> nonBlank(row.getCountry(), "")).toList();
|
||||
List<String> skus = rows.stream().map(row -> nonBlank(row.getSku(), "")).toList();
|
||||
List<String> titles = rows.stream().map(row -> nonBlank(row.getTitle(), row.getAsin())).toList();
|
||||
List<String> urls = rows.stream().map(row -> nonBlank(row.getUrl(), "")).toList();
|
||||
|
||||
Map<String, Object> parameters = new LinkedHashMap<>();
|
||||
parameters.put("title_list", titles);
|
||||
parameters.put("url_list", urls);
|
||||
parameters.put("items", buildItemObjects(rows, groupKeys, rowIds, asins, countries, titles, urls));
|
||||
parameters.put("prompt", prompt == null ? "" : prompt);
|
||||
parameters.put("items", buildItemObjects(rows, groupKeys, rowIds, asins, countries, skus, titles, urls));
|
||||
// 前端没填 prompt 就一律不向 Coze 透传该字段,避免无关默认提示词污染工作流。
|
||||
if (prompt != null && !prompt.isBlank()) {
|
||||
parameters.put("prompt", prompt);
|
||||
}
|
||||
if (apiKey != null && !apiKey.isBlank()) {
|
||||
parameters.put("api_key", apiKey.trim());
|
||||
}
|
||||
@@ -400,6 +404,7 @@ public class AppearancePatentCozeClient {
|
||||
List<String> rowIds,
|
||||
List<String> asins,
|
||||
List<String> countries,
|
||||
List<String> skus,
|
||||
List<String> titles,
|
||||
List<String> urls) {
|
||||
List<Map<String, Object>> items = new ArrayList<>(rows.size());
|
||||
@@ -409,6 +414,7 @@ public class AppearancePatentCozeClient {
|
||||
item.put("row_id", rowIds.get(i));
|
||||
item.put("asin", asins.get(i));
|
||||
item.put("country", countries.get(i));
|
||||
item.put("sku", skus.get(i));
|
||||
item.put("title", titles.get(i));
|
||||
item.put("url", urls.get(i));
|
||||
items.add(item);
|
||||
@@ -462,7 +468,9 @@ public class AppearancePatentCozeClient {
|
||||
text(firstNonNull(node.get("patent_reason"),
|
||||
firstNonNull(firstNonNull(node.get("patentReason"), node.get("patent reason")),
|
||||
firstNonNull(itemNode.get("patent_reason"),
|
||||
firstNonNull(itemNode.get("patentReason"), itemNode.get("patent reason"))))))
|
||||
firstNonNull(itemNode.get("patentReason"), itemNode.get("patent reason")))))),
|
||||
text(firstNonNull(firstNonNull(node.get("score"), node.get("Score")),
|
||||
firstNonNull(itemNode.get("score"), itemNode.get("Score"))))
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -567,6 +575,7 @@ public class AppearancePatentCozeClient {
|
||||
row.setTitleReason(result.titleReason());
|
||||
row.setAppearanceReason(result.appearanceReason());
|
||||
row.setPatentReason(result.patentReason());
|
||||
row.setScore(result.score());
|
||||
}
|
||||
|
||||
private RestClient restClient() {
|
||||
@@ -585,6 +594,7 @@ public class AppearancePatentCozeClient {
|
||||
row.setId(source.getId());
|
||||
row.setAsin(source.getAsin());
|
||||
row.setCountry(source.getCountry());
|
||||
row.setSku(source.getSku());
|
||||
row.setUrl(source.getUrl());
|
||||
row.setTitle(source.getTitle());
|
||||
row.setError(source.getError());
|
||||
@@ -597,13 +607,14 @@ public class AppearancePatentCozeClient {
|
||||
row.setTitleReason(source.getTitleReason());
|
||||
row.setAppearanceReason(source.getAppearanceReason());
|
||||
row.setPatentReason(source.getPatentReason());
|
||||
row.setScore(source.getScore());
|
||||
return row;
|
||||
}
|
||||
|
||||
private AppearancePatentResultRowDto markFailed(AppearancePatentResultRowDto row, String failureMessage) {
|
||||
String reviewMessage = failureMessage == null || failureMessage.isBlank()
|
||||
? "Coze 检测失败,待人工复核"
|
||||
: "Coze 检测失败,待人工复核:" + failureMessage;
|
||||
? "Coze 检测失败"
|
||||
: "Coze 检测失败:" + failureMessage;
|
||||
if (row.getError() == null || row.getError().isBlank()) {
|
||||
row.setError(failureMessage);
|
||||
}
|
||||
@@ -794,7 +805,9 @@ public class AppearancePatentCozeClient {
|
||||
|| item.has("appearanceReason")
|
||||
|| item.has("patent_reason")
|
||||
|| item.has("patentReason")
|
||||
|| item.has("patent reason"));
|
||||
|| item.has("patent reason")
|
||||
|| item.has("score")
|
||||
|| item.has("Score"));
|
||||
}
|
||||
|
||||
private boolean isSuccessfulWorkflowStatus(String status) {
|
||||
@@ -1051,7 +1064,8 @@ public class AppearancePatentCozeClient {
|
||||
String status,
|
||||
String titleReason,
|
||||
String appearanceReason,
|
||||
String patentReason
|
||||
String patentReason,
|
||||
String score
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,10 @@ public class AppearancePatentResultRowDto {
|
||||
@Schema(description = "站点或国家。来自 Excel 的国家列,例如英国、德国、法国。", example = "英国")
|
||||
private String country;
|
||||
|
||||
@Schema(description = "商品 SKU。Java 调用 Coze 时会放入 items[].sku。", example = "SKU-001")
|
||||
@JsonAlias({"SKU", "sellerSku", "seller_sku", "merchantSku", "merchant_sku", "商品SKU", "商品 sku", "库存SKU"})
|
||||
private String sku;
|
||||
|
||||
@Schema(description = "商品主图或待检测图片 URL。Java 调用 Coze 时会放入 url_list。", example = "https://webstatic.aiproxy.vip/output/20260425/103322/demo.jpg")
|
||||
@JsonAlias({
|
||||
"imageUrl", "image_url", "imgUrl", "img_url", "pictureUrl", "picture_url",
|
||||
@@ -78,6 +82,10 @@ public class AppearancePatentResultRowDto {
|
||||
@Schema(description = "Coze patent reason", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
private String patentReason;
|
||||
|
||||
@JsonAlias({"score", "Score", "评分"})
|
||||
@Schema(description = "Coze 回流的评分(外观维度),透传到最终 xlsx 第一张 sheet 的“评分”列。", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
private String score;
|
||||
|
||||
/**
|
||||
* 仅内存生命周期标记,标识当前 status 是 markFailed 时合成出来的(而不是用户/Python 真实回传)。
|
||||
* 不入库、不参与 chunk 序列化(@JsonIgnore),用于导出 / 重新上传判定时区分"系统合成 FAILED"与"用户真正失败",
|
||||
|
||||
@@ -36,6 +36,9 @@ public class AppearancePatentParsedRowVo {
|
||||
@Schema(description = "价格。", example = "12.99")
|
||||
private String price;
|
||||
|
||||
@Schema(description = "商品 SKU。Java 调用 Coze 时会放入 items[].sku。", example = "SKU-001")
|
||||
private String sku;
|
||||
|
||||
@Schema(description = "商品图片 URL 或商品 URL,供 Coze 检测使用。", example = "https://webstatic.aiproxy.vip/output/demo.jpg")
|
||||
private String url;
|
||||
|
||||
|
||||
@@ -126,9 +126,9 @@ public class AppearancePatentTaskService {
|
||||
"价格",
|
||||
"标题维度(商标)",
|
||||
"外观维度(外观设计专利)",
|
||||
"专利维度(发明/实用新型专利)",
|
||||
"结论",
|
||||
"状态"
|
||||
"状态",
|
||||
"评分"
|
||||
);
|
||||
|
||||
private final LocalFileStorageService localFileStorageService;
|
||||
@@ -1254,6 +1254,7 @@ public class AppearancePatentTaskService {
|
||||
row.setId(sibling.getDisplayId());
|
||||
row.setAsin(sibling.getAsin());
|
||||
row.setCountry(sibling.getCountry());
|
||||
row.setSku(firstNonBlank(representative.getSku(), sibling.getSku()));
|
||||
row.setUrl(firstNonBlank(representative.getUrl(), sibling.getUrl()));
|
||||
row.setTitle(firstNonBlank(representative.getTitle(), sibling.getTitle()));
|
||||
row.setError(representative.getError());
|
||||
@@ -1265,6 +1266,7 @@ public class AppearancePatentTaskService {
|
||||
row.setTitleReason(representative.getTitleReason());
|
||||
row.setAppearanceReason(representative.getAppearanceReason());
|
||||
row.setPatentReason(representative.getPatentReason());
|
||||
row.setScore(representative.getScore());
|
||||
row.setDone(representative.getDone());
|
||||
return row;
|
||||
}
|
||||
@@ -2176,8 +2178,8 @@ public class AppearancePatentTaskService {
|
||||
|| normalized.contains("execution limit")
|
||||
|| normalized.contains("720712008")
|
||||
|| normalized.contains("720701002")
|
||||
|| normalized.contains("\u5de5\u4f5c\u6d41\u8282\u70b9\u6267\u884c\u8d85\u9650")
|
||||
|| normalized.contains("\u8c03\u7528\u8d85\u65f6");
|
||||
|| normalized.contains("工作流节点执行超限")
|
||||
|| normalized.contains("调用超时");
|
||||
}
|
||||
|
||||
private boolean isRetryableCozeFailure(String failureMessage) {
|
||||
@@ -2195,10 +2197,10 @@ public class AppearancePatentTaskService {
|
||||
|| normalized.contains("720712008")
|
||||
|| normalized.contains("720701002")
|
||||
|| normalized.contains("plugin limit")
|
||||
|| normalized.contains("\u9650\u6d41")
|
||||
|| normalized.contains("\u7a0d\u540e\u91cd\u8bd5")
|
||||
|| normalized.contains("\u5de5\u4f5c\u6d41\u8282\u70b9\u6267\u884c\u8d85\u9650")
|
||||
|| normalized.contains("\u8c03\u7528\u8d85\u65f6");
|
||||
|| normalized.contains("限流")
|
||||
|| normalized.contains("稍后重试")
|
||||
|| normalized.contains("工作流节点执行超限")
|
||||
|| normalized.contains("调用超时");
|
||||
}
|
||||
|
||||
private void updateCozeStateRunning(TaskScopeStateEntity state, String error) {
|
||||
@@ -3213,6 +3215,7 @@ public class AppearancePatentTaskService {
|
||||
List<String> resultHeaders = new ArrayList<>(RESULT_HEADERS);
|
||||
resultHeaders.add(4, "标题");
|
||||
resultHeaders.add(5, "图片链接");
|
||||
resultHeaders.add(6, "sku");
|
||||
Row header = sheet.createRow(0);
|
||||
for (int i = 0; i < resultHeaders.size(); i++) {
|
||||
Cell cell = header.createCell(i);
|
||||
@@ -3236,11 +3239,12 @@ public class AppearancePatentTaskService {
|
||||
row.createCell(col++).setCellValue(firstNonBlank(parsedRow.getPrice(), ""));
|
||||
row.createCell(col++).setCellValue(resultRow == null ? firstNonBlank(parsedRow.getTitle(), "") : firstNonBlank(resultRow.getTitle(), parsedRow.getTitle()));
|
||||
row.createCell(col++).setCellValue(resultRow == null ? firstNonBlank(parsedRow.getUrl(), "") : firstNonBlank(resultRow.getUrl(), parsedRow.getUrl()));
|
||||
row.createCell(col++).setCellValue(resultRow == null ? firstNonBlank(parsedRow.getSku(), "") : firstNonBlank(resultRow.getSku(), parsedRow.getSku()));
|
||||
row.createCell(col++).setCellValue(resultRow == null ? missingReason : userFacingCozeCellValue(resultRow, resultRow.getTitleRisk()));
|
||||
row.createCell(col++).setCellValue(resultRow == null ? missingReason : userFacingCozeCellValue(resultRow, resultRow.getAppearanceRisk()));
|
||||
row.createCell(col++).setCellValue(resultRow == null ? missingReason : userFacingCozeCellValue(resultRow, resultRow.getPatentRisk()));
|
||||
row.createCell(col++).setCellValue(resultRow == null ? "未送检" : userFacingConclusion(resultRow));
|
||||
row.createCell(col).setCellValue(resultRow == null ? "" : userFacingStatus(resultRow));
|
||||
row.createCell(col++).setCellValue(resultRow == null ? "" : userFacingStatus(resultRow));
|
||||
row.createCell(col).setCellValue(resultRow == null ? "" : firstNonBlank(resultRow.getScore(), ""));
|
||||
}
|
||||
writeReasonSheet(workbook, headerStyle, rowsToWrite, resultMap);
|
||||
workbook.write(fos);
|
||||
@@ -3376,6 +3380,8 @@ public class AppearancePatentTaskService {
|
||||
int asinCol = findRequiredHeader(headerMap, "asin");
|
||||
int countryCol = findRequiredHeader(headerMap, "国家", "country");
|
||||
int priceCol = findOptionalHeaderExact(headerMap, "价格", "price");
|
||||
int skuCol = findOptionalHeaderExact(headerMap,
|
||||
"sku", "seller sku", "seller_sku", "merchant sku", "merchant_sku", "商品sku", "商品 sku", "库存sku");
|
||||
int urlCol = findOptionalHeaderExact(headerMap,
|
||||
"url", "rul", "link", "image", "img", "pic", "picture",
|
||||
"链接", "商品链接", "图片", "商品图片", "主图", "商品主图", "图片链接", "主图链接");
|
||||
@@ -3423,6 +3429,7 @@ public class AppearancePatentTaskService {
|
||||
vo.setAsin(asin);
|
||||
vo.setCountry(country);
|
||||
vo.setPrice(priceCol >= 0 ? cell(row, priceCol, formatter) : "");
|
||||
vo.setSku(skuCol >= 0 ? cell(row, skuCol, formatter) : "");
|
||||
vo.setUrl(urlCol >= 0 ? cell(row, urlCol, formatter) : "");
|
||||
vo.setTitle(titleCol >= 0 ? cell(row, titleCol, formatter) : "");
|
||||
parsedRows.add(new ParsedAppearanceRow(vo, statusCol >= 0 ? cell(row, statusCol, formatter) : ""));
|
||||
@@ -3467,9 +3474,11 @@ public class AppearancePatentTaskService {
|
||||
for (List<AppearancePatentParsedRowVo> siblings : rowsByBaseId.values()) {
|
||||
String title = "";
|
||||
String url = "";
|
||||
String sku = "";
|
||||
for (AppearancePatentParsedRowVo sibling : siblings) {
|
||||
title = firstNonBlank(title, sibling.getTitle());
|
||||
url = firstNonBlank(url, sibling.getUrl());
|
||||
sku = firstNonBlank(sku, sibling.getSku());
|
||||
}
|
||||
for (AppearancePatentParsedRowVo sibling : siblings) {
|
||||
if (normalize(sibling.getTitle()).isBlank()) {
|
||||
@@ -3478,6 +3487,9 @@ public class AppearancePatentTaskService {
|
||||
if (normalize(sibling.getUrl()).isBlank()) {
|
||||
sibling.setUrl(url);
|
||||
}
|
||||
if (normalize(sibling.getSku()).isBlank()) {
|
||||
sibling.setSku(sku);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4041,11 +4053,12 @@ public class AppearancePatentTaskService {
|
||||
if (!normalizedValue.isBlank() && !isTechnicalCozeFailure(normalizedValue)) {
|
||||
return value;
|
||||
}
|
||||
// coze 技术性失败:有错误信息则放入错误信息,没有则留空
|
||||
if (row != null && isTechnicalCozeFailure(row.getError())) {
|
||||
return "待人工复核";
|
||||
return firstNonBlank(row.getError(), "");
|
||||
}
|
||||
if (row != null && isFailedCozeStatusValue(row.getStatus())) {
|
||||
return "待人工复核";
|
||||
return firstNonBlank(row.getError(), "");
|
||||
}
|
||||
return firstNonBlank(value, "");
|
||||
}
|
||||
@@ -4058,11 +4071,12 @@ public class AppearancePatentTaskService {
|
||||
if (!conclusion.isBlank() && !isTechnicalCozeFailure(conclusion)) {
|
||||
return row.getConclusion();
|
||||
}
|
||||
// coze 技术性失败:有错误信息则放入错误信息,没有则留空
|
||||
if (isTechnicalCozeFailure(row.getError())) {
|
||||
return "待人工复核";
|
||||
return firstNonBlank(row.getError(), "");
|
||||
}
|
||||
if (isFailedCozeStatusValue(row.getStatus())) {
|
||||
return "待人工复核";
|
||||
return firstNonBlank(row.getError(), "");
|
||||
}
|
||||
return firstNonBlank(row.getConclusion(), "");
|
||||
}
|
||||
@@ -4070,8 +4084,8 @@ public class AppearancePatentTaskService {
|
||||
/**
|
||||
* 导出最终 xlsx 时的状态展示:
|
||||
* - 如果 status 是 markFailed 合成的(failureSyntheticStatus=true),
|
||||
* 且 row 实际并没有任何风险维度结果,导出层降级为"待人工复核",避免用户原样
|
||||
* 重新上传时该行被反复识别为失败行重新触发 Coze。
|
||||
* 且 row 实际并没有任何风险维度结果,导出层展示错误信息(有则放入,无则留空),
|
||||
* 避免用户原样重新上传时该行被反复识别为失败行重新触发 Coze。
|
||||
* - 真正业务侧失败 / Python 真实回传 FAILED 仍按原值显示。
|
||||
*/
|
||||
private String userFacingStatus(AppearancePatentResultRowDto row) {
|
||||
@@ -4082,7 +4096,8 @@ public class AppearancePatentTaskService {
|
||||
if (!row.isFailureSyntheticStatus()) {
|
||||
return original;
|
||||
}
|
||||
return "待人工复核";
|
||||
// 合成状态:有错误信息则放入错误信息,没有则留空
|
||||
return firstNonBlank(row.getError(), "");
|
||||
}
|
||||
|
||||
private boolean isTechnicalCozeFailure(String value) {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.nanri.aiimage.modules.auth.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "aiimage.auth")
|
||||
public class AuthProperties {
|
||||
|
||||
private String jwtSecret = "please-change-this-secret-please-rotate-at-least-32-bytes";
|
||||
private long jwtTtlHours = 168L;
|
||||
private String cookieName = "aiimage_token";
|
||||
private boolean cookieSecure = false;
|
||||
private String cookieSameSite = "Lax";
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.nanri.aiimage.modules.auth.controller;
|
||||
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.modules.auth.model.dto.LoginRequest;
|
||||
import com.nanri.aiimage.modules.auth.model.vo.LoginResultVo;
|
||||
import com.nanri.aiimage.modules.auth.service.AuthService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.ResponseCookie;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "登录认证", description = "桌面端登录与会话校验")
|
||||
public class LoginController {
|
||||
|
||||
private static final String DEVICE_ID_HEADER = "X-Device-Id";
|
||||
|
||||
private final AuthService authService;
|
||||
|
||||
@PostMapping("/login")
|
||||
@Operation(summary = "用户名密码登录")
|
||||
public ResponseEntity<ApiResponse<LoginResultVo>> login(@RequestBody LoginRequest request) {
|
||||
LoginResultVo result = authService.login(request);
|
||||
ResponseCookie cookie = authService.buildAuthCookie(result.getToken());
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.SET_COOKIE, cookie.toString())
|
||||
.body(ApiResponse.success("登录成功", result));
|
||||
}
|
||||
|
||||
@GetMapping("/check_login")
|
||||
@Operation(summary = "校验登录态")
|
||||
public ResponseEntity<ApiResponse<LoginResultVo>> checkLogin(
|
||||
HttpServletRequest httpRequest,
|
||||
@RequestHeader(value = DEVICE_ID_HEADER, required = false) String deviceIdHeader) {
|
||||
String token = resolveToken(httpRequest);
|
||||
LoginResultVo result = authService.checkLogin(token, deviceIdHeader);
|
||||
ResponseCookie cookie = authService.buildAuthCookie(result.getToken());
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.SET_COOKIE, cookie.toString())
|
||||
.body(ApiResponse.success(result));
|
||||
}
|
||||
|
||||
@PostMapping("/logout")
|
||||
@Operation(summary = "登出")
|
||||
public ResponseEntity<ApiResponse<Void>> logout() {
|
||||
ResponseCookie cookie = authService.buildClearCookie();
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.SET_COOKIE, cookie.toString())
|
||||
.body(ApiResponse.success("已登出", null));
|
||||
}
|
||||
|
||||
private String resolveToken(HttpServletRequest request) {
|
||||
String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
|
||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||
return authHeader.substring(7).trim();
|
||||
}
|
||||
Cookie[] cookies = request.getCookies();
|
||||
if (cookies == null) {
|
||||
return null;
|
||||
}
|
||||
String cookieName = authService.cookieName();
|
||||
for (Cookie cookie : cookies) {
|
||||
if (cookieName.equals(cookie.getName())) {
|
||||
return cookie.getValue();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.nanri.aiimage.modules.auth.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.nanri.aiimage.modules.auth.model.entity.LoginUserEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface LoginUserMapper extends BaseMapper<LoginUserEntity> {
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.nanri.aiimage.modules.auth.model.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class LoginRequest {
|
||||
|
||||
private String username;
|
||||
private String password;
|
||||
private String deviceId;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.nanri.aiimage.modules.auth.model.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@TableName("users")
|
||||
public class LoginUserEntity {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private String username;
|
||||
@TableField("password_hash")
|
||||
private String passwordHash;
|
||||
private String machine;
|
||||
@TableField("is_admin")
|
||||
private Integer isAdmin;
|
||||
private String role;
|
||||
@TableField("created_by_id")
|
||||
private Long createdById;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.nanri.aiimage.modules.auth.model.vo;
|
||||
|
||||
import com.nanri.aiimage.modules.permission.model.vo.PermissionMenuItemVo;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class LoginResultVo {
|
||||
|
||||
private Long userId;
|
||||
private String username;
|
||||
private String role;
|
||||
private Boolean isAdmin;
|
||||
private String deviceId;
|
||||
private String token;
|
||||
private Long expiresIn;
|
||||
private List<PermissionMenuItemVo> appColumns;
|
||||
private List<PermissionMenuItemVo> adminColumns;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package com.nanri.aiimage.modules.auth.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.modules.auth.config.AuthProperties;
|
||||
import com.nanri.aiimage.modules.auth.mapper.LoginUserMapper;
|
||||
import com.nanri.aiimage.modules.auth.model.dto.LoginRequest;
|
||||
import com.nanri.aiimage.modules.auth.model.entity.LoginUserEntity;
|
||||
import com.nanri.aiimage.modules.auth.model.vo.LoginResultVo;
|
||||
import com.nanri.aiimage.modules.auth.util.WerkzeugPasswordEncoder;
|
||||
import com.nanri.aiimage.modules.permission.service.PermissionMenuService;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseCookie;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AuthService {
|
||||
|
||||
private final LoginUserMapper loginUserMapper;
|
||||
private final WerkzeugPasswordEncoder passwordEncoder;
|
||||
private final JwtService jwtService;
|
||||
private final PermissionMenuService permissionMenuService;
|
||||
private final AuthProperties authProperties;
|
||||
|
||||
public LoginResultVo login(LoginRequest request) {
|
||||
String username = trim(request.getUsername());
|
||||
String password = request.getPassword() == null ? "" : request.getPassword();
|
||||
String deviceId = trim(request.getDeviceId());
|
||||
if (username.isEmpty() || password.isEmpty()) {
|
||||
throw new BusinessException("请输入用户名和密码");
|
||||
}
|
||||
if (deviceId.isEmpty()) {
|
||||
throw new BusinessException("缺少设备ID,请在桌面端打开");
|
||||
}
|
||||
|
||||
LoginUserEntity user = loginUserMapper.selectOne(new LambdaQueryWrapper<LoginUserEntity>()
|
||||
.eq(LoginUserEntity::getUsername, username)
|
||||
.last("LIMIT 1"));
|
||||
if (user == null || !passwordEncoder.matches(password, user.getPasswordHash())) {
|
||||
throw new BusinessException("用户名或密码错误");
|
||||
}
|
||||
|
||||
boolean isAdmin = user.getIsAdmin() != null && user.getIsAdmin() == 1;
|
||||
String stored = user.getMachine() == null ? "" : user.getMachine().trim();
|
||||
if (stored.isEmpty()) {
|
||||
loginUserMapper.update(null, new LambdaUpdateWrapper<LoginUserEntity>()
|
||||
.eq(LoginUserEntity::getId, user.getId())
|
||||
.set(LoginUserEntity::getMachine, deviceId));
|
||||
stored = deviceId;
|
||||
log.info("[auth] first-login bind userId={} device={}", user.getId(), deviceId);
|
||||
} else if (!stored.equals(deviceId)) {
|
||||
if (isAdmin) {
|
||||
log.warn("[auth] device mismatch but admin bypass userId={} stored={} current={}",
|
||||
user.getId(), stored, deviceId);
|
||||
} else {
|
||||
log.warn("[auth] device mismatch reject userId={} stored={} current={}",
|
||||
user.getId(), stored, deviceId);
|
||||
throw new BusinessException("当前设备与首次登录设备不一致,请在原设备上登录");
|
||||
}
|
||||
} else {
|
||||
log.info("[auth] device match userId={} isAdmin={} device={}",
|
||||
user.getId(), isAdmin, deviceId);
|
||||
}
|
||||
|
||||
return buildResult(user, stored, isAdmin);
|
||||
}
|
||||
|
||||
public LoginResultVo checkLogin(String token, String currentDeviceId) {
|
||||
if (token == null || token.isBlank()) {
|
||||
throw new BusinessException(401, "未登录");
|
||||
}
|
||||
Claims claims = jwtService.parse(token);
|
||||
if (claims == null) {
|
||||
throw new BusinessException(401, "登录已过期,请重新登录");
|
||||
}
|
||||
Long userId;
|
||||
try {
|
||||
userId = Long.parseLong(claims.getSubject());
|
||||
} catch (NumberFormatException e) {
|
||||
throw new BusinessException(401, "登录态无效");
|
||||
}
|
||||
LoginUserEntity user = loginUserMapper.selectById(userId);
|
||||
if (user == null) {
|
||||
throw new BusinessException(401, "用户不存在");
|
||||
}
|
||||
boolean isAdmin = user.getIsAdmin() != null && user.getIsAdmin() == 1;
|
||||
String stored = user.getMachine() == null ? "" : user.getMachine().trim();
|
||||
String device = trim(currentDeviceId);
|
||||
if (device.isEmpty()) {
|
||||
// 没传设备 ID 时,回落到 token 内 deviceId
|
||||
Object claimDevice = claims.get("deviceId");
|
||||
device = claimDevice == null ? "" : claimDevice.toString().trim();
|
||||
}
|
||||
if (!stored.isEmpty() && !device.isEmpty() && !stored.equals(device)) {
|
||||
if (isAdmin) {
|
||||
log.warn("[auth] check_login device mismatch but admin bypass userId={} stored={} current={}",
|
||||
user.getId(), stored, device);
|
||||
} else {
|
||||
log.warn("[auth] check_login device mismatch reject userId={} stored={} current={}",
|
||||
user.getId(), stored, device);
|
||||
throw new BusinessException(401, "当前设备与首次登录设备不一致");
|
||||
}
|
||||
}
|
||||
return buildResult(user, stored.isEmpty() ? device : stored, isAdmin);
|
||||
}
|
||||
|
||||
public ResponseCookie buildAuthCookie(String token) {
|
||||
ResponseCookie.ResponseCookieBuilder builder = ResponseCookie.from(authProperties.getCookieName(), token)
|
||||
.httpOnly(true)
|
||||
.path("/")
|
||||
.maxAge(Duration.ofSeconds(jwtService.ttlSeconds()))
|
||||
.sameSite(authProperties.getCookieSameSite());
|
||||
if (authProperties.isCookieSecure()) {
|
||||
builder.secure(true);
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
public ResponseCookie buildClearCookie() {
|
||||
return ResponseCookie.from(authProperties.getCookieName(), "")
|
||||
.httpOnly(true)
|
||||
.path("/")
|
||||
.maxAge(Duration.ZERO)
|
||||
.sameSite(authProperties.getCookieSameSite())
|
||||
.secure(authProperties.isCookieSecure())
|
||||
.build();
|
||||
}
|
||||
|
||||
public String cookieName() {
|
||||
return authProperties.getCookieName();
|
||||
}
|
||||
|
||||
private LoginResultVo buildResult(LoginUserEntity user, String deviceId, boolean isAdmin) {
|
||||
String token = jwtService.issue(user.getId(), user.getUsername(), deviceId);
|
||||
LoginResultVo vo = new LoginResultVo();
|
||||
vo.setUserId(user.getId());
|
||||
vo.setUsername(user.getUsername());
|
||||
vo.setRole(user.getRole());
|
||||
vo.setIsAdmin(isAdmin);
|
||||
vo.setDeviceId(deviceId);
|
||||
vo.setToken(token);
|
||||
vo.setExpiresIn(jwtService.ttlSeconds());
|
||||
vo.setAppColumns(permissionMenuService.getUserColumnPermissions(user.getId(), PermissionMenuService.MENU_TYPE_APP));
|
||||
vo.setAdminColumns(permissionMenuService.getUserColumnPermissions(user.getId(), PermissionMenuService.MENU_TYPE_ADMIN));
|
||||
return vo;
|
||||
}
|
||||
|
||||
private static String trim(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.nanri.aiimage.modules.auth.service;
|
||||
|
||||
import com.nanri.aiimage.modules.auth.config.AuthProperties;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.JwtException;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Date;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class JwtService {
|
||||
|
||||
private final AuthProperties props;
|
||||
|
||||
private SecretKey signingKey() {
|
||||
byte[] keyBytes = props.getJwtSecret().getBytes(StandardCharsets.UTF_8);
|
||||
if (keyBytes.length < 32) {
|
||||
byte[] padded = new byte[32];
|
||||
System.arraycopy(keyBytes, 0, padded, 0, keyBytes.length);
|
||||
keyBytes = padded;
|
||||
}
|
||||
return Keys.hmacShaKeyFor(keyBytes);
|
||||
}
|
||||
|
||||
public String issue(Long userId, String username, String deviceId) {
|
||||
Instant now = Instant.now();
|
||||
Instant exp = now.plus(Duration.ofHours(props.getJwtTtlHours()));
|
||||
return Jwts.builder()
|
||||
.subject(String.valueOf(userId))
|
||||
.claim("username", username)
|
||||
.claim("deviceId", deviceId)
|
||||
.issuedAt(Date.from(now))
|
||||
.expiration(Date.from(exp))
|
||||
.signWith(signingKey())
|
||||
.compact();
|
||||
}
|
||||
|
||||
public Claims parse(String token) {
|
||||
try {
|
||||
return Jwts.parser()
|
||||
.verifyWith(signingKey())
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
} catch (JwtException | IllegalArgumentException e) {
|
||||
log.debug("[auth] jwt parse failed: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public long ttlSeconds() {
|
||||
return Duration.ofHours(props.getJwtTtlHours()).toSeconds();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.nanri.aiimage.modules.auth.util;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.SecretKeyFactory;
|
||||
import javax.crypto.spec.PBEKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.HexFormat;
|
||||
|
||||
/**
|
||||
* 兼容 Werkzeug `generate_password_hash` 的 PBKDF2-HMAC-SHA256 输出格式。
|
||||
* 形如:pbkdf2:sha256:iterations$salt$hexdigest(Werkzeug 默认 600000 轮、salt 16 位)。
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class WerkzeugPasswordEncoder {
|
||||
|
||||
private static final int DK_BITS = 32 * 8;
|
||||
private static final int DEFAULT_ITERATIONS = 600_000;
|
||||
private static final int SALT_LENGTH = 16;
|
||||
private static final String SALT_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
|
||||
public String hash(String rawPassword) {
|
||||
if (rawPassword == null) {
|
||||
throw new IllegalArgumentException("password cannot be null");
|
||||
}
|
||||
String salt = generateSalt();
|
||||
try {
|
||||
byte[] derived = pbkdf2Sha256(rawPassword.toCharArray(),
|
||||
salt.getBytes(StandardCharsets.UTF_8), DEFAULT_ITERATIONS, DK_BITS);
|
||||
String hex = HexFormat.of().formatHex(derived);
|
||||
return "pbkdf2:sha256:" + DEFAULT_ITERATIONS + "$" + salt + "$" + hex;
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("生成密码摘要失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean matches(String rawPassword, String storedHash) {
|
||||
if (rawPassword == null || storedHash == null || storedHash.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
int firstSep = storedHash.indexOf('$');
|
||||
int secondSep = storedHash.indexOf('$', firstSep + 1);
|
||||
if (firstSep < 0 || secondSep < 0) {
|
||||
return false;
|
||||
}
|
||||
String method = storedHash.substring(0, firstSep);
|
||||
String salt = storedHash.substring(firstSep + 1, secondSep);
|
||||
String expectedHex = storedHash.substring(secondSep + 1);
|
||||
if (!method.startsWith("pbkdf2:sha256")) {
|
||||
log.warn("[auth] unsupported password hash scheme: {}", method);
|
||||
return false;
|
||||
}
|
||||
String[] parts = method.split(":");
|
||||
int iterations = parts.length >= 3 ? Integer.parseInt(parts[2]) : 600_000;
|
||||
byte[] derived = pbkdf2Sha256(rawPassword.toCharArray(), salt.getBytes(StandardCharsets.UTF_8), iterations, DK_BITS);
|
||||
String actualHex = HexFormat.of().formatHex(derived);
|
||||
return constantTimeEquals(actualHex, expectedHex);
|
||||
} catch (Exception e) {
|
||||
log.warn("[auth] verify password failed: {}", e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] pbkdf2Sha256(char[] password, byte[] salt, int iterations, int keyBits) throws Exception {
|
||||
PBEKeySpec spec = new PBEKeySpec(password, salt, iterations, keyBits);
|
||||
try {
|
||||
return SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).getEncoded();
|
||||
} finally {
|
||||
spec.clearPassword();
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean constantTimeEquals(String a, String b) {
|
||||
if (a == null || b == null || a.length() != b.length()) {
|
||||
return false;
|
||||
}
|
||||
int diff = 0;
|
||||
for (int i = 0; i < a.length(); i++) {
|
||||
diff |= a.charAt(i) ^ b.charAt(i);
|
||||
}
|
||||
return diff == 0;
|
||||
}
|
||||
|
||||
private static String generateSalt() {
|
||||
StringBuilder sb = new StringBuilder(SALT_LENGTH);
|
||||
for (int i = 0; i < SALT_LENGTH; i++) {
|
||||
sb.append(SALT_ALPHABET.charAt(RANDOM.nextInt(SALT_ALPHABET.length())));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package com.nanri.aiimage.modules.collectdata.controller;
|
||||
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataCountryPreferenceSaveRequest;
|
||||
import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataParseRequest;
|
||||
import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataTaskBatchRequest;
|
||||
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataCountryPreferenceVo;
|
||||
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataDashboardVo;
|
||||
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataHistoryVo;
|
||||
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataItemsPageVo;
|
||||
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataParseVo;
|
||||
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataTaskBatchVo;
|
||||
import com.nanri.aiimage.modules.collectdata.service.CollectDataService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/collect-data")
|
||||
@Tag(name = "采集数据", description = "采集数据任务接口。前端上传 Excel 后由 Java 解析并落库;Python 端按页拉取明细数据进行采集。")
|
||||
public class CollectDataController {
|
||||
|
||||
private final CollectDataService service;
|
||||
|
||||
@PostMapping("/parse")
|
||||
@Operation(summary = "解析 Excel 并创建任务", description = "解析上传后的 Excel 文件,按行入库到 biz_collect_data_item,并保存任务筛选条件。任务初始状态为 PENDING。")
|
||||
public ApiResponse<CollectDataParseVo> parse(@Valid @RequestBody CollectDataParseRequest request) {
|
||||
return ApiResponse.success(service.parseAndCreateTask(request));
|
||||
}
|
||||
|
||||
@PostMapping("/tasks/{taskId}/activate")
|
||||
@Operation(summary = "激活任务", description = "前端推送 Python 队列前调用,将任务状态从 PENDING 修改为 RUNNING,表示已交给 Python 处理。")
|
||||
public ApiResponse<Void> activate(
|
||||
@Parameter(description = "采集任务 ID", required = true, example = "9001")
|
||||
@PathVariable Long taskId,
|
||||
@Parameter(description = "当前用户 ID", required = true, example = "1")
|
||||
@RequestParam("user_id") Long userId) {
|
||||
service.activateTask(taskId, userId);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@GetMapping("/tasks/{taskId}/items")
|
||||
@Operation(summary = "分页获取任务明细数据", description = "供 Python 端拉取,默认每页 50 条;返回任务关联的筛选条件,便于 Python 端按筛选条件采集。")
|
||||
public ApiResponse<CollectDataItemsPageVo> items(
|
||||
@Parameter(description = "采集任务 ID", required = true, example = "9001")
|
||||
@PathVariable Long taskId,
|
||||
@Parameter(description = "用户 ID,可选;传入时会校验任务归属")
|
||||
@RequestParam(value = "user_id", required = false) Long userId,
|
||||
@Parameter(description = "页码,从 1 开始", example = "1")
|
||||
@RequestParam(value = "page", required = false, defaultValue = "1") Integer page,
|
||||
@Parameter(description = "每页条数,默认 50,最大 200", example = "50")
|
||||
@RequestParam(value = "page_size", required = false, defaultValue = "50") Integer pageSize) {
|
||||
return ApiResponse.success(service.getItemsPage(taskId, userId, page, pageSize));
|
||||
}
|
||||
|
||||
@GetMapping("/dashboard")
|
||||
@Operation(summary = "查询采集任务总览")
|
||||
public ApiResponse<CollectDataDashboardVo> dashboard(
|
||||
@Parameter(description = "当前用户 ID", required = true, example = "1")
|
||||
@RequestParam("user_id") Long userId) {
|
||||
return ApiResponse.success(service.dashboard(userId));
|
||||
}
|
||||
|
||||
@GetMapping("/history")
|
||||
@Operation(summary = "查询采集任务历史")
|
||||
public ApiResponse<CollectDataHistoryVo> history(
|
||||
@Parameter(description = "当前用户 ID", required = true, example = "1")
|
||||
@RequestParam("user_id") Long userId,
|
||||
@Parameter(description = "返回数量上限,默认 50,最大 100", example = "50")
|
||||
@RequestParam(value = "limit", required = false, defaultValue = "50") Integer limit) {
|
||||
return ApiResponse.success(service.history(userId, limit));
|
||||
}
|
||||
|
||||
@PostMapping("/tasks/progress/batch")
|
||||
@Operation(summary = "批量查询任务进度")
|
||||
public ApiResponse<CollectDataTaskBatchVo> progressBatch(@Valid @RequestBody CollectDataTaskBatchRequest request) {
|
||||
return ApiResponse.success(service.progressBatch(request.getTaskIds()));
|
||||
}
|
||||
|
||||
@DeleteMapping("/tasks/{taskId}")
|
||||
@Operation(summary = "删除任务", description = "删除任务及其明细行、关联结果记录。")
|
||||
public ApiResponse<Void> deleteTask(
|
||||
@Parameter(description = "采集任务 ID", required = true, example = "9001")
|
||||
@PathVariable Long taskId,
|
||||
@Parameter(description = "当前用户 ID", required = true, example = "1")
|
||||
@RequestParam("user_id") Long userId) {
|
||||
service.deleteTask(taskId, userId);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@DeleteMapping("/history/{resultId}")
|
||||
@Operation(summary = "删除历史记录")
|
||||
public ApiResponse<Void> deleteHistory(
|
||||
@Parameter(description = "结果记录 ID", required = true, example = "1001")
|
||||
@PathVariable Long resultId,
|
||||
@Parameter(description = "当前用户 ID", required = true, example = "1")
|
||||
@RequestParam("user_id") Long userId) {
|
||||
service.deleteHistory(resultId, userId);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@GetMapping("/country-preference")
|
||||
@Operation(summary = "查询国家处理顺序", description = "返回当前用户保存的国家代码列表,顺序即处理顺序。")
|
||||
public ApiResponse<CollectDataCountryPreferenceVo> getCountryPreference(
|
||||
@Parameter(description = "当前用户 ID", required = true, example = "1")
|
||||
@RequestParam("user_id") Long userId) {
|
||||
return ApiResponse.success(service.getCountryPreference(userId));
|
||||
}
|
||||
|
||||
@PutMapping("/country-preference")
|
||||
@Operation(summary = "保存国家处理顺序", description = "保存当前用户的国家勾选与处理顺序。")
|
||||
public ApiResponse<CollectDataCountryPreferenceVo> saveCountryPreference(
|
||||
@Valid @RequestBody CollectDataCountryPreferenceSaveRequest request) {
|
||||
return ApiResponse.success(service.saveCountryPreference(request));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.nanri.aiimage.modules.collectdata.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.nanri.aiimage.modules.collectdata.model.entity.CollectDataCountryPrefEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface CollectDataCountryPrefMapper extends BaseMapper<CollectDataCountryPrefEntity> {
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.nanri.aiimage.modules.collectdata.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.nanri.aiimage.modules.collectdata.model.entity.CollectDataItemEntity;
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface CollectDataItemMapper extends BaseMapper<CollectDataItemEntity> {
|
||||
|
||||
@Insert("""
|
||||
<script>
|
||||
INSERT INTO biz_collect_data_item
|
||||
(task_id, row_index, source_file_key, source_filename, keyword, status_value, extra_json, created_at)
|
||||
VALUES
|
||||
<foreach collection="rows" item="row" separator=",">
|
||||
(#{row.taskId}, #{row.rowIndex}, #{row.sourceFileKey}, #{row.sourceFilename},
|
||||
#{row.keyword}, #{row.statusValue}, #{row.extraJson}, #{row.createdAt})
|
||||
</foreach>
|
||||
</script>
|
||||
""")
|
||||
int insertBatch(@Param("rows") List<CollectDataItemEntity> rows);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.nanri.aiimage.modules.collectdata.model.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "保存采集数据五国处理顺序:至少 1 个、最多 5 个,代码须为 DE、UK、FR、IT、ES,且不能重复")
|
||||
public class CollectDataCountryPreferenceSaveRequest {
|
||||
|
||||
@NotNull(message = "user_id 不能为空")
|
||||
@JsonProperty("user_id")
|
||||
@Schema(description = "当前用户 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
private Long userId;
|
||||
|
||||
@NotEmpty(message = "country_codes 不能为空")
|
||||
@Size(max = 5, message = "country_codes 最多 5 项")
|
||||
@JsonProperty("country_codes")
|
||||
@Schema(description = "国家代码列表(DE、UK、FR、IT、ES),顺序即处理顺序", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private List<String> countryCodes = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.nanri.aiimage.modules.collectdata.model.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import com.fasterxml.jackson.annotation.JsonAlias;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "采集数据筛选条件")
|
||||
public class CollectDataFiltersDto {
|
||||
|
||||
@Schema(description = "金额(手动输入),可为空", example = "59.99")
|
||||
private BigDecimal amount;
|
||||
|
||||
@JsonAlias({"min_amount"})
|
||||
@Schema(description = "最小金额,可为空", example = "10.00")
|
||||
private BigDecimal minAmount;
|
||||
|
||||
@JsonAlias({"max_amount"})
|
||||
@Schema(description = "最大金额,可为空", example = "59.99")
|
||||
private BigDecimal maxAmount;
|
||||
|
||||
@Schema(description = "是否启用排名条件")
|
||||
private Boolean rank;
|
||||
|
||||
@Schema(description = "是否包含 FBA 商品")
|
||||
private Boolean fba;
|
||||
|
||||
@Schema(description = "是否包含 FBM 商品")
|
||||
private Boolean fbm;
|
||||
|
||||
@Schema(description = "国家代码列表,按用户拖拽顺序保存", example = "[\"DE\",\"UK\"]")
|
||||
private List<String> countryCodes = new ArrayList<>();
|
||||
|
||||
@JsonAlias({"country_code"})
|
||||
@Schema(description = "单选国家代码,采集数据页面当前只选择一个国家", example = "DE")
|
||||
private String countryCode;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.nanri.aiimage.modules.collectdata.model.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "采集数据解析请求:提交筛选条件和上传后的 Excel 文件,由后端解析并落库。")
|
||||
public class CollectDataParseRequest {
|
||||
|
||||
@JsonProperty("user_id")
|
||||
@NotNull
|
||||
@Schema(description = "当前用户 ID", example = "1", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Long userId;
|
||||
|
||||
@NotEmpty
|
||||
@Schema(description = "已上传的 Excel 文件列表", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private List<CollectDataSourceFileDto> files;
|
||||
|
||||
@JsonProperty("task_type")
|
||||
@Schema(description = "任务类型,前端可自定义传入;为空时默认 collect-data", example = "collect-data")
|
||||
private String taskType;
|
||||
|
||||
@Valid
|
||||
@Schema(description = "筛选条件")
|
||||
private CollectDataFiltersDto filters;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.nanri.aiimage.modules.collectdata.model.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "采集数据源文件信息")
|
||||
public class CollectDataSourceFileDto {
|
||||
@Schema(description = "上传接口返回的临时文件 key", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String fileKey;
|
||||
|
||||
@Schema(description = "原始文件名")
|
||||
private String originalFilename;
|
||||
|
||||
@Schema(description = "相对目录路径")
|
||||
private String relativePath;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.nanri.aiimage.modules.collectdata.model.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "批量查询采集数据任务进度的请求")
|
||||
public class CollectDataTaskBatchRequest {
|
||||
@NotEmpty
|
||||
@Schema(description = "任务 ID 列表", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private List<Long> taskIds;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user