增加公共下载进度、增加接收SKU、密钥分别存放

This commit is contained in:
super
2026-05-28 16:41:20 +08:00
parent ca4a2cd07a
commit 2ed1250604
119 changed files with 4077 additions and 293 deletions

View File

@@ -12,8 +12,8 @@ zn_username=%E8%87%AA%E5%8A%A8%E5%8C%96_Robot
client_name=ShuFuAI
# java_api_base=http://47.111.163.154:18080
# java_api_base=http://127.0.0.1:18080
java_api_base=http://121.196.149.225:18080
java_api_base=http://127.0.0.1:18080
# java_api_base=http://121.196.149.225:18080
# 与 Java 后端共享的 JWT 签名密钥,必须与 backend-java 的 AIIMAGE_JWT_SECRET 完全一致
AIIMAGE_JWT_SECRET=please-change-this-secret-please-rotate-at-least-32-bytes

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
import{b8 as r}from"./pywebview-3bFwh9Cg.js";const n="";function s(e){return r(`${n}/api/brand/expand-folder-recursive`,{folder:e})}export{s as e};

View File

@@ -0,0 +1 @@
import{bN as r}from"./java-modules-DzYrjxib.js";const n="";function s(e){return r(`${n}/api/brand/expand-folder-recursive`,{folder:e})}export{s as e};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
.module-page[data-v-a27e815e]{min-height:100vh;background:#1a1a1a}.main-content[data-v-a27e815e]{display:flex;height:calc(100vh - 56px);min-height:calc(100vh - 56px)}.left-panel[data-v-a27e815e]{width:400px;background:#1e1e1e;padding:20px;overflow-y:auto;border-right:1px solid #2a2a2a}.right-panel[data-v-a27e815e]{flex:1;min-width:0;background:#1a1a1a;display:flex;flex-direction:column}.section-title[data-v-a27e815e],.subsection-title[data-v-a27e815e]{font-size:13px;color:#bbb;margin-bottom:10px}.upload-zone[data-v-a27e815e]{border:1px dashed #3a3a3a;border-radius:10px;padding:18px;background:#252525;margin-bottom:18px}.hint[data-v-a27e815e],.loading-msg[data-v-a27e815e],.files[data-v-a27e815e],.muted[data-v-a27e815e]{color:#888;font-size:12px;line-height:1.5}.link[data-v-a27e815e]{color:#6ea8fe;text-decoration:none}.link[data-v-a27e815e]:hover{color:#9fc5ff}.btns[data-v-a27e815e],.run-row[data-v-a27e815e]{display:flex;gap:10px;flex-wrap:wrap}.opt-btn[data-v-a27e815e],.btn-run[data-v-a27e815e],.btn-delete[data-v-a27e815e],.download[data-v-a27e815e]{border:none;cursor:pointer;border-radius:7px}.opt-btn[data-v-a27e815e]{padding:8px 14px;color:#ccc;background:#2a2a2a;border:1px solid #3a3a3a}.btn-run[data-v-a27e815e]{padding:10px 18px;color:#fff;background:#3498db;font-weight:600}.btn-queue[data-v-a27e815e]{background:#27ae60}.btn-run[data-v-a27e815e]:disabled{opacity:.55;cursor:not-allowed}.selected-files[data-v-a27e815e]{margin-top:14px;color:#999;font-size:12px;word-break:break-all}.selected-files span[data-v-a27e815e]{display:block;margin:4px 0}.parse-card[data-v-a27e815e],.queue-payload[data-v-a27e815e]{margin-top:14px;padding:12px;border:1px solid #2a2a2a;border-radius:8px;background:#202020;color:#b8c1cc;font-size:12px}.queue-payload[data-v-a27e815e]{max-height:220px;overflow:auto;color:#8fd3ff;white-space:pre-wrap}.panel-header[data-v-a27e815e]{padding:16px 20px;border-bottom:1px solid #2a2a2a;font-size:15px;font-weight:600;color:#ddd}.task-list-wrap[data-v-a27e815e]{flex:1;padding:16px 20px;overflow:auto}.clean-result-summary[data-v-a27e815e]{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin-bottom:16px}.summary-card[data-v-a27e815e]{padding:14px 16px;border:1px solid #2a2a2a;border-radius:8px;background:#1e1e1e}.summary-card strong[data-v-a27e815e]{display:block;margin-top:8px;color:#eaf4ff;font-size:22px}.summary-label[data-v-a27e815e]{color:#8d8d8d;font-size:12px}.result-list-wrap[data-v-a27e815e]{border:1px solid #2a2a2a;border-radius:8px;background:#1e1e1e;min-height:180px;margin:0 0 16px}.result-list-header[data-v-a27e815e]{display:flex;justify-content:space-between;padding:12px 16px;border-bottom:1px solid #2a2a2a;color:#ddd;font-size:14px}.empty-tasks[data-v-a27e815e]{color:#666;font-size:13px;padding:18px;text-align:center}.task-list[data-v-a27e815e]{list-style:none;margin:0;padding:12px}.task-item[data-v-a27e815e]{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;padding:12px 14px;border:1px solid #2a2a2a;border-radius:8px;margin-bottom:8px;background:#222}.left[data-v-a27e815e]{flex:1;min-width:0}.id[data-v-a27e815e]{color:#e0e0e0;font-size:13px;font-weight:600}.task-right[data-v-a27e815e]{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.status[data-v-a27e815e]{padding:4px 10px;border-radius:6px;font-size:12px}.status.success[data-v-a27e815e]{background:#2ecc712e;color:#2ecc71}.status.failed[data-v-a27e815e]{background:#e74c3c2e;color:#ff6b6b}.status.running[data-v-a27e815e]{background:#3498db2e;color:#3498db}.status.pending[data-v-a27e815e]{background:#95a5a62e;color:#bdc3c7}.result-hint[data-v-a27e815e]{margin-top:6px;color:#e0b96d}.file-progress[data-v-a27e815e]{margin-top:8px;max-width:520px}.file-progress-meta[data-v-a27e815e]{display:flex;justify-content:space-between;gap:12px;color:#d8c278;font-size:12px}.file-progress-track[data-v-a27e815e]{margin-top:5px;height:8px;border-radius:999px;overflow:hidden;background:#303030;border:1px solid #3b3b3b}.file-progress-bar[data-v-a27e815e]{height:100%;border-radius:inherit;background:linear-gradient(90deg,#4aa3ff,#f0c75e);transition:width .25s ease}.file-progress-count[data-v-a27e815e]{margin-top:4px;color:#858585;font-size:11px}.download[data-v-a27e815e]{padding:6px 10px;color:#d6ecff;background:#3498db2e}.btn-delete[data-v-a27e815e]{padding:6px 10px;color:#ff8f8f;background:#e74c3c1f}@media(max-width:1100px){.main-content[data-v-a27e815e]{flex-direction:column;height:auto}.left-panel[data-v-a27e815e]{width:100%;border-right:none;border-bottom:1px solid #2a2a2a}.clean-result-summary[data-v-a27e815e]{grid-template-columns:repeat(2,minmax(0,1fr))}}

View File

@@ -0,0 +1 @@
.module-page[data-v-39de0e76]{min-height:100vh;background:#1a1a1a}.main-content[data-v-39de0e76]{display:flex;height:calc(100vh - 56px);min-height:calc(100vh - 56px)}.left-panel[data-v-39de0e76]{width:400px;background:#1e1e1e;padding:20px;overflow-y:auto;border-right:1px solid #2a2a2a}.right-panel[data-v-39de0e76]{flex:1;min-width:0;background:#1a1a1a;display:flex;flex-direction:column}.section-title[data-v-39de0e76],.subsection-title[data-v-39de0e76]{font-size:13px;color:#bbb;margin-bottom:10px}.upload-zone[data-v-39de0e76]{border:1px dashed #3a3a3a;border-radius:10px;padding:18px;background:#252525;margin-bottom:18px}.hint[data-v-39de0e76],.loading-msg[data-v-39de0e76],.files[data-v-39de0e76],.muted[data-v-39de0e76]{color:#888;font-size:12px;line-height:1.5}.link[data-v-39de0e76]{color:#6ea8fe;text-decoration:none}.link[data-v-39de0e76]:hover{color:#9fc5ff}.btns[data-v-39de0e76],.run-row[data-v-39de0e76]{display:flex;gap:10px;flex-wrap:wrap}.opt-btn[data-v-39de0e76],.btn-run[data-v-39de0e76],.btn-delete[data-v-39de0e76],.download[data-v-39de0e76]{border:none;cursor:pointer;border-radius:7px}.opt-btn[data-v-39de0e76]{padding:8px 14px;color:#ccc;background:#2a2a2a;border:1px solid #3a3a3a}.btn-run[data-v-39de0e76]{padding:10px 18px;color:#fff;background:#3498db;font-weight:600}.btn-queue[data-v-39de0e76]{background:#27ae60}.btn-run[data-v-39de0e76]:disabled{opacity:.55;cursor:not-allowed}.selected-files[data-v-39de0e76]{margin-top:14px;color:#999;font-size:12px;word-break:break-all}.selected-files span[data-v-39de0e76]{display:block;margin:4px 0}.parse-card[data-v-39de0e76],.queue-payload[data-v-39de0e76]{margin-top:14px;padding:12px;border:1px solid #2a2a2a;border-radius:8px;background:#202020;color:#b8c1cc;font-size:12px}.queue-payload[data-v-39de0e76]{max-height:220px;overflow:auto;color:#8fd3ff;white-space:pre-wrap}.panel-header[data-v-39de0e76]{padding:16px 20px;border-bottom:1px solid #2a2a2a;font-size:15px;font-weight:600;color:#ddd}.task-list-wrap[data-v-39de0e76]{flex:1;padding:16px 20px;overflow:auto}.clean-result-summary[data-v-39de0e76]{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin-bottom:16px}.summary-card[data-v-39de0e76]{padding:14px 16px;border:1px solid #2a2a2a;border-radius:8px;background:#1e1e1e}.summary-card strong[data-v-39de0e76]{display:block;margin-top:8px;color:#eaf4ff;font-size:22px}.summary-label[data-v-39de0e76]{color:#8d8d8d;font-size:12px}.result-list-wrap[data-v-39de0e76]{border:1px solid #2a2a2a;border-radius:8px;background:#1e1e1e;min-height:180px;margin:0 0 16px}.result-list-header[data-v-39de0e76]{display:flex;justify-content:space-between;padding:12px 16px;border-bottom:1px solid #2a2a2a;color:#ddd;font-size:14px}.empty-tasks[data-v-39de0e76]{color:#666;font-size:13px;padding:18px;text-align:center}.task-list[data-v-39de0e76]{list-style:none;margin:0;padding:12px}.task-item[data-v-39de0e76]{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;padding:12px 14px;border:1px solid #2a2a2a;border-radius:8px;margin-bottom:8px;background:#222}.left[data-v-39de0e76]{flex:1;min-width:0}.id[data-v-39de0e76]{color:#e0e0e0;font-size:13px;font-weight:600}.task-right[data-v-39de0e76]{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.status[data-v-39de0e76]{padding:4px 10px;border-radius:6px;font-size:12px}.status.success[data-v-39de0e76]{background:#2ecc712e;color:#2ecc71}.status.failed[data-v-39de0e76]{background:#e74c3c2e;color:#ff6b6b}.status.running[data-v-39de0e76]{background:#3498db2e;color:#3498db}.status.pending[data-v-39de0e76]{background:#95a5a62e;color:#bdc3c7}.result-hint[data-v-39de0e76]{margin-top:6px;color:#e0b96d}.file-progress[data-v-39de0e76]{margin-top:8px;max-width:520px}.file-progress-meta[data-v-39de0e76]{display:flex;justify-content:space-between;gap:12px;color:#d8c278;font-size:12px}.file-progress-track[data-v-39de0e76]{margin-top:5px;height:8px;border-radius:999px;overflow:hidden;background:#303030;border:1px solid #3b3b3b}.file-progress-bar[data-v-39de0e76]{height:100%;border-radius:inherit;background:linear-gradient(90deg,#4aa3ff,#f0c75e);transition:width .25s ease}.file-progress-count[data-v-39de0e76]{margin-top:4px;color:#858585;font-size:11px}.download[data-v-39de0e76]{padding:6px 10px;color:#d6ecff;background:#3498db2e}.btn-delete[data-v-39de0e76]{padding:6px 10px;color:#ff8f8f;background:#e74c3c1f}@media(max-width:1100px){.main-content[data-v-39de0e76]{flex-direction:column;height:auto}.left-panel[data-v-39de0e76]{width:100%;border-right:none;border-bottom:1px solid #2a2a2a}.clean-result-summary[data-v-39de0e76]{grid-template-columns:repeat(2,minmax(0,1fr))}}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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")
@@ -186,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 下载文件并保存。根据文件后缀动态设置保存类型。"""
@@ -230,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'
@@ -440,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.get_device_id)
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,

View File

@@ -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-DzYrjxib.js">
<link rel="modulepreload" crossorigin href="/assets/brand-jr47vQUd.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>

View File

@@ -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-DzYrjxib.js">
<link rel="modulepreload" crossorigin href="/assets/brand-jr47vQUd.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>

View File

@@ -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-DzYrjxib.js">
<link rel="modulepreload" crossorigin href="/assets/brand-jr47vQUd.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>

View File

@@ -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-DzYrjxib.js">
<link rel="modulepreload" crossorigin href="/assets/brand-jr47vQUd.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>

View File

@@ -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_\\-()\\[\\]{}::()【】]+", "");
}

View File

@@ -355,13 +355,14 @@ 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("items", buildItemObjects(rows, groupKeys, rowIds, asins, countries, skus, titles, urls));
// 前端没填 prompt 就一律不向 Coze 透传该字段,避免无关默认提示词污染工作流。
if (prompt != null && !prompt.isBlank()) {
parameters.put("prompt", prompt);
@@ -403,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());
@@ -412,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);
@@ -591,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());

View File

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

View File

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

View File

@@ -1255,6 +1255,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());
@@ -2178,8 +2179,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) {
@@ -2197,10 +2198,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) {
@@ -3379,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",
"链接", "商品链接", "图片", "商品图片", "主图", "商品主图", "图片链接", "主图链接");
@@ -3426,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) : ""));
@@ -3470,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()) {
@@ -3481,6 +3487,9 @@ public class AppearancePatentTaskService {
if (normalize(sibling.getUrl()).isBlank()) {
sibling.setUrl(url);
}
if (normalize(sibling.getSku()).isBlank()) {
sibling.setSku(sku);
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,28 @@
package com.nanri.aiimage.modules.collectdata.model.dto;
import io.swagger.v3.oas.annotations.media.Schema;
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;
@Schema(description = "排名(手动输入),可为空", example = "100000")
private Integer rank;
@Schema(description = "是否包含 FBA 商品")
private Boolean fba;
@Schema(description = "是否包含 FBM 商品")
private Boolean fbm;
@Schema(description = "国家代码列表,按用户拖拽顺序保存", example = "[\"DE\",\"UK\"]")
private List<String> countryCodes = new ArrayList<>();
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,22 @@
package com.nanri.aiimage.modules.collectdata.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;
import java.time.LocalDateTime;
@Data
@TableName("biz_collect_data_country_pref")
public class CollectDataCountryPrefEntity {
@TableId(type = IdType.INPUT)
private Long userId;
@TableField("country_codes_json")
private String countryCodesJson;
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,24 @@
package com.nanri.aiimage.modules.collectdata.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("biz_collect_data_item")
public class CollectDataItemEntity {
@TableId(type = IdType.AUTO)
private Long id;
private Long taskId;
private Integer rowIndex;
private String sourceFileKey;
private String sourceFilename;
private String keyword;
private String statusValue;
private String extraJson;
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,17 @@
package com.nanri.aiimage.modules.collectdata.model.vo;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(description = "采集数据用户五国处理偏好:按顺序排列的国家代码列表,如 DE、UK")
public class CollectDataCountryPreferenceVo {
@JsonProperty("country_codes")
@Schema(description = "已选国家代码,顺序即处理顺序;未持久化时返回默认 DE -> UK -> FR -> IT -> ES")
private List<String> countryCodes = new ArrayList<>();
}

View File

@@ -0,0 +1,17 @@
package com.nanri.aiimage.modules.collectdata.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "采集数据总览统计")
public class CollectDataDashboardVo {
@Schema(description = "运行中任务数量")
private Long pendingTaskCount;
@Schema(description = "已结束任务数量")
private Long processedTaskCount;
@Schema(description = "成功任务数量")
private Long successTaskCount;
@Schema(description = "失败任务数量")
private Long failedTaskCount;
}

View File

@@ -0,0 +1,54 @@
package com.nanri.aiimage.modules.collectdata.model.vo;
import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataFiltersDto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "采集数据历史/任务记录")
public class CollectDataHistoryItemVo {
@Schema(description = "结果记录 ID")
private Long resultId;
@Schema(description = "任务 ID")
private Long taskId;
@Schema(description = "任务编号")
private String taskNo;
@Schema(description = "源文件聚合展示名")
private String sourceFilename;
@Schema(description = "结果文件名")
private String resultFilename;
@Schema(description = "下载地址")
private String downloadUrl;
@Schema(description = "任务状态PENDING / RUNNING / SUCCESS / FAILED")
private String taskStatus;
@Schema(description = "结果是否成功")
private Boolean success;
@Schema(description = "错误信息")
private String error;
@Schema(description = "落库行数")
private Integer rowCount;
@Schema(description = "记录创建时间")
private String createdAt;
@Schema(description = "任务开始时间")
private String startedAt;
@Schema(description = "任务结束时间")
private String finishedAt;
@Schema(description = "任务类型")
private String taskType;
@Schema(description = "解析时保存的筛选条件")
private CollectDataFiltersDto filters;
}

View File

@@ -0,0 +1,14 @@
package com.nanri.aiimage.modules.collectdata.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(description = "采集数据历史列表")
public class CollectDataHistoryVo {
@Schema(description = "历史记录列表")
private List<CollectDataHistoryItemVo> items = new ArrayList<>();
}

View File

@@ -0,0 +1,32 @@
package com.nanri.aiimage.modules.collectdata.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.LinkedHashMap;
import java.util.Map;
@Data
@Schema(description = "采集数据明细行")
public class CollectDataItemVo {
@Schema(description = "数据库主键")
private Long id;
@Schema(description = "行号,从 1 开始")
private Integer rowIndex;
@Schema(description = "来源文件 key")
private String sourceFileKey;
@Schema(description = "来源文件名")
private String sourceFilename;
@Schema(description = "关键词列内容")
private String keyword;
@Schema(description = "状态列内容")
private String statusValue;
@Schema(description = "其它列内容(列名 -> 字符串值)")
private Map<String, String> extra = new LinkedHashMap<>();
}

View File

@@ -0,0 +1,45 @@
package com.nanri.aiimage.modules.collectdata.model.vo;
import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataFiltersDto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(description = "采集数据明细分页结果,默认每页 50 条,供 Python 拉取")
public class CollectDataItemsPageVo {
@Schema(description = "任务 ID")
private Long taskId;
@Schema(description = "任务编号")
private String taskNo;
@Schema(description = "任务类型")
private String taskType;
@Schema(description = "任务状态")
private String taskStatus;
@Schema(description = "页码,从 1 开始")
private Integer page;
@Schema(description = "每页条数")
private Integer pageSize;
@Schema(description = "本页条数")
private Integer count;
@Schema(description = "总条数")
private Long total;
@Schema(description = "总页数")
private Integer totalPages;
@Schema(description = "本任务关联的筛选条件")
private CollectDataFiltersDto filters;
@Schema(description = "明细列表")
private List<CollectDataItemVo> items = new ArrayList<>();
}

View File

@@ -0,0 +1,33 @@
package com.nanri.aiimage.modules.collectdata.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "采集数据解析结果")
public class CollectDataParseVo {
@Schema(description = "新创建的任务 ID", example = "9001")
private Long taskId;
@Schema(description = "任务编号")
private String taskNo;
@Schema(description = "聚合源文件展示名", example = "采集前数据.xlsx 等 1 个文件")
private String sourceFilename;
@Schema(description = "源文件数量")
private Integer sourceFileCount;
@Schema(description = "Excel 中检测到的非空数据总行数")
private Integer totalRows;
@Schema(description = "落库的有效行数")
private Integer acceptedRows;
@Schema(description = "因缺少必要字段被丢弃的行数")
private Integer droppedRows;
@Schema(description = "推荐分页大小,便于 Python 拉取分页数据", example = "50")
private Integer pageSize;
}

View File

@@ -0,0 +1,17 @@
package com.nanri.aiimage.modules.collectdata.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(description = "批量任务进度返回")
public class CollectDataTaskBatchVo {
@Schema(description = "任务详情列表")
private List<CollectDataTaskDetailVo> items = new ArrayList<>();
@Schema(description = "未找到的任务 ID")
private List<Long> missingTaskIds = new ArrayList<>();
}

View File

@@ -0,0 +1,17 @@
package com.nanri.aiimage.modules.collectdata.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(description = "采集数据任务进度详情")
public class CollectDataTaskDetailVo {
@Schema(description = "任务摘要")
private CollectDataTaskSummaryVo task;
@Schema(description = "结果/历史记录")
private List<CollectDataHistoryItemVo> items = new ArrayList<>();
}

View File

@@ -0,0 +1,29 @@
package com.nanri.aiimage.modules.collectdata.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "采集数据任务摘要")
public class CollectDataTaskSummaryVo {
@Schema(description = "任务 ID")
private Long id;
@Schema(description = "任务编号")
private String taskNo;
@Schema(description = "任务状态")
private String status;
@Schema(description = "错误信息")
private String errorMessage;
@Schema(description = "创建时间")
private String createdAt;
@Schema(description = "更新时间")
private String updatedAt;
@Schema(description = "结束时间")
private String finishedAt;
}

View File

@@ -0,0 +1,732 @@
package com.nanri.aiimage.modules.collectdata.service;
import cn.hutool.core.util.IdUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.collectdata.mapper.CollectDataCountryPrefMapper;
import com.nanri.aiimage.modules.collectdata.mapper.CollectDataItemMapper;
import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataCountryPreferenceSaveRequest;
import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataFiltersDto;
import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataParseRequest;
import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataSourceFileDto;
import com.nanri.aiimage.modules.collectdata.model.entity.CollectDataCountryPrefEntity;
import com.nanri.aiimage.modules.collectdata.model.entity.CollectDataItemEntity;
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.CollectDataHistoryItemVo;
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataHistoryVo;
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataItemVo;
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.model.vo.CollectDataTaskDetailVo;
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataTaskSummaryVo;
import com.nanri.aiimage.modules.file.service.LocalFileStorageService;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.DataFormatter;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
import java.io.File;
import java.io.FileInputStream;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.regex.Pattern;
@Service
@RequiredArgsConstructor
@Slf4j
public class CollectDataService {
public static final String MODULE_TYPE = "COLLECT_DATA";
public static final int DEFAULT_PAGE_SIZE = 50;
/**
* 默认国家偏好顺序DE -> UK -> FR -> IT -> ES与前端保持一致。
*/
public static final List<String> DEFAULT_COUNTRY_PREFERENCE_ORDER = List.of("DE", "UK", "FR", "IT", "ES");
private static final String STATUS_PENDING = "PENDING";
private static final String STATUS_RUNNING = "RUNNING";
private static final String STATUS_SUCCESS = "SUCCESS";
private static final String STATUS_FAILED = "FAILED";
private static final String DEFAULT_TASK_TYPE = "collect-data";
private static final int ITEM_INSERT_BATCH_SIZE = 500;
private static final List<String> KEYWORD_HEADER_ALIASES = List.of("关键词", "keyword", "key word");
private static final List<String> STATUS_HEADER_ALIASES = List.of("状态", "status");
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+");
private final LocalFileStorageService localFileStorageService;
private final FileTaskMapper fileTaskMapper;
private final FileResultMapper fileResultMapper;
private final CollectDataItemMapper collectDataItemMapper;
private final CollectDataCountryPrefMapper collectDataCountryPrefMapper;
private final ObjectMapper objectMapper;
private final TransactionTemplate transactionTemplate;
public CollectDataParseVo parseAndCreateTask(CollectDataParseRequest request) {
long startedAt = System.currentTimeMillis();
if (request == null || request.getUserId() == null || request.getUserId() <= 0) {
throw new BusinessException("user_id 不合法");
}
if (request.getFiles() == null || request.getFiles().isEmpty()) {
throw new BusinessException("请先上传 Excel 文件");
}
List<CollectDataSourceFileDto> sources = request.getFiles().stream()
.filter(Objects::nonNull)
.filter(f -> f.getFileKey() != null && !f.getFileKey().isBlank())
.toList();
if (sources.isEmpty()) {
throw new BusinessException("请先上传 Excel 文件");
}
String requestedTaskType = normalize(request.getTaskType());
final String taskType = requestedTaskType.isBlank() ? DEFAULT_TASK_TYPE : requestedTaskType;
CollectDataFiltersDto filters = request.getFilters() == null ? new CollectDataFiltersDto() : request.getFilters();
List<ParsedRow> parsedRows = new ArrayList<>();
int totalRows = 0;
int droppedRows = 0;
for (CollectDataSourceFileDto source : sources) {
File input = localFileStorageService.findLocalSourceFile(source.getFileKey());
if (input == null || !input.exists()) {
throw new BusinessException("源文件不存在");
}
ParsedWorkbook parsed = parseWorkbook(input, source);
totalRows += parsed.totalRows();
droppedRows += parsed.droppedRows();
parsedRows.addAll(parsed.rows());
}
if (parsedRows.isEmpty()) {
throw new BusinessException("未解析到有效数据行");
}
long parsedAt = System.currentTimeMillis();
String aggregateFilename = buildAggregateSourceFilenameLabel(sources);
PersistedParse persisted = transactionTemplate.execute(status ->
persistParsedTask(request, sources, taskType, filters, parsedRows, aggregateFilename));
if (persisted == null) {
throw new BusinessException("创建采集任务失败");
}
long persistedAt = System.currentTimeMillis();
log.info("[collect-data] parseAndCreateTask userId={} files={} totalRows={} acceptedRows={} droppedRows={} parseMs={} persistMs={} totalMs={}",
request.getUserId(), sources.size(), totalRows, parsedRows.size(), droppedRows,
parsedAt - startedAt, persistedAt - parsedAt, persistedAt - startedAt);
CollectDataParseVo vo = new CollectDataParseVo();
vo.setTaskId(persisted.task().getId());
vo.setTaskNo(persisted.task().getTaskNo());
vo.setSourceFilename(aggregateFilename);
vo.setSourceFileCount(sources.size());
vo.setTotalRows(totalRows);
vo.setAcceptedRows(parsedRows.size());
vo.setDroppedRows(droppedRows);
vo.setPageSize(DEFAULT_PAGE_SIZE);
return vo;
}
private PersistedParse persistParsedTask(CollectDataParseRequest request,
List<CollectDataSourceFileDto> sources,
String taskType,
CollectDataFiltersDto filters,
List<ParsedRow> parsedRows,
String aggregateFilename) {
FileTaskEntity task = new FileTaskEntity();
task.setTaskNo(MODULE_TYPE + "-" + IdUtil.getSnowflakeNextIdStr());
task.setModuleType(MODULE_TYPE);
task.setTaskMode("PYTHON_QUEUE");
task.setStatus(STATUS_PENDING);
task.setSourceFileCount(sources.size());
task.setSuccessFileCount(0);
task.setFailedFileCount(0);
task.setCreatedBy("user:" + request.getUserId());
task.setUserId(request.getUserId());
task.setCreatedAt(LocalDateTime.now());
task.setUpdatedAt(LocalDateTime.now());
try {
Map<String, Object> requestPayload = new LinkedHashMap<>();
requestPayload.put("userId", request.getUserId());
requestPayload.put("taskType", taskType);
requestPayload.put("filters", filters);
requestPayload.put("files", sources);
task.setRequestJson(objectMapper.writeValueAsString(requestPayload));
task.setResultJson("{}");
} catch (Exception ex) {
throw new BusinessException("序列化任务信息失败");
}
fileTaskMapper.insert(task);
int rowIndex = 0;
LocalDateTime itemCreatedAt = LocalDateTime.now();
List<CollectDataItemEntity> itemBatch = new ArrayList<>(Math.min(parsedRows.size(), ITEM_INSERT_BATCH_SIZE));
for (ParsedRow parsedRow : parsedRows) {
rowIndex++;
CollectDataItemEntity entity = new CollectDataItemEntity();
entity.setTaskId(task.getId());
entity.setRowIndex(rowIndex);
entity.setSourceFileKey(parsedRow.sourceFileKey());
entity.setSourceFilename(parsedRow.sourceFilename());
entity.setKeyword(parsedRow.keyword());
entity.setStatusValue(parsedRow.statusValue());
try {
entity.setExtraJson(objectMapper.writeValueAsString(parsedRow.extra()));
} catch (Exception ex) {
entity.setExtraJson("{}");
}
entity.setCreatedAt(itemCreatedAt);
itemBatch.add(entity);
if (itemBatch.size() >= ITEM_INSERT_BATCH_SIZE) {
collectDataItemMapper.insertBatch(itemBatch);
itemBatch.clear();
}
}
if (!itemBatch.isEmpty()) {
collectDataItemMapper.insertBatch(itemBatch);
}
FileResultEntity result = new FileResultEntity();
result.setTaskId(task.getId());
result.setModuleType(MODULE_TYPE);
result.setSourceFilename(aggregateFilename);
result.setSourceFileUrl(buildAggregateSourceFileUrl(sources));
result.setRowCount(parsedRows.size());
result.setUserId(request.getUserId());
result.setCreatedAt(LocalDateTime.now());
fileResultMapper.insert(result);
return new PersistedParse(task, result);
}
@Transactional
public void activateTask(Long taskId, Long userId) {
FileTaskEntity task = requireTask(taskId, userId);
if (STATUS_SUCCESS.equals(task.getStatus()) || STATUS_FAILED.equals(task.getStatus())) {
throw new BusinessException("任务已结束");
}
task.setStatus(STATUS_RUNNING);
task.setUpdatedAt(LocalDateTime.now());
fileTaskMapper.updateById(task);
}
public CollectDataDashboardVo dashboard(Long userId) {
CollectDataDashboardVo vo = new CollectDataDashboardVo();
vo.setPendingTaskCount(countTask(userId, STATUS_RUNNING));
vo.setSuccessTaskCount(countTask(userId, STATUS_SUCCESS));
vo.setFailedTaskCount(countTask(userId, STATUS_FAILED));
long processed = (vo.getSuccessTaskCount() == null ? 0 : vo.getSuccessTaskCount())
+ (vo.getFailedTaskCount() == null ? 0 : vo.getFailedTaskCount());
vo.setProcessedTaskCount(processed);
return vo;
}
public CollectDataHistoryVo history(Long userId, Integer limit) {
CollectDataHistoryVo vo = new CollectDataHistoryVo();
if (userId == null || userId <= 0) {
return vo;
}
int safeLimit = Math.max(1, Math.min(limit == null ? 50 : limit, 100));
List<FileResultEntity> rows = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
.eq(FileResultEntity::getUserId, userId)
.orderByDesc(FileResultEntity::getCreatedAt)
.last("limit " + safeLimit));
if (rows == null || rows.isEmpty()) {
return vo;
}
List<Long> taskIds = rows.stream().map(FileResultEntity::getTaskId).filter(Objects::nonNull).distinct().toList();
Map<Long, FileTaskEntity> taskMap = loadTaskMap(taskIds);
for (FileResultEntity row : rows) {
FileTaskEntity task = row.getTaskId() == null ? null : taskMap.get(row.getTaskId());
vo.getItems().add(toHistoryItem(row, task));
}
return vo;
}
public CollectDataTaskBatchVo progressBatch(List<Long> taskIds) {
CollectDataTaskBatchVo vo = new CollectDataTaskBatchVo();
List<Long> normalizedIds = taskIds == null ? List.of() : taskIds.stream()
.filter(id -> id != null && id > 0)
.distinct()
.toList();
if (normalizedIds.isEmpty()) {
return vo;
}
Map<Long, FileTaskEntity> taskMap = loadTaskMap(normalizedIds);
List<FileResultEntity> resultRows = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
.in(FileResultEntity::getTaskId, normalizedIds)
.orderByDesc(FileResultEntity::getCreatedAt));
Map<Long, FileResultEntity> resultByTaskId = new LinkedHashMap<>();
if (resultRows != null) {
for (FileResultEntity row : resultRows) {
if (row.getTaskId() != null) {
resultByTaskId.putIfAbsent(row.getTaskId(), row);
}
}
}
for (Long taskId : normalizedIds) {
FileTaskEntity task = taskMap.get(taskId);
if (task == null) {
vo.getMissingTaskIds().add(taskId);
continue;
}
CollectDataTaskDetailVo detail = new CollectDataTaskDetailVo();
detail.setTask(toTaskSummary(task));
FileResultEntity row = resultByTaskId.get(taskId);
if (row != null) {
detail.getItems().add(toHistoryItem(row, task));
}
vo.getItems().add(detail);
}
return vo;
}
public CollectDataItemsPageVo getItemsPage(Long taskId, Long userId, Integer page, Integer pageSize) {
FileTaskEntity task = requireTask(taskId, userId);
int safePage = page == null || page <= 0 ? 1 : page;
int safePageSize = pageSize == null || pageSize <= 0 ? DEFAULT_PAGE_SIZE : Math.min(pageSize, 200);
Long total = collectDataItemMapper.selectCount(new LambdaQueryWrapper<CollectDataItemEntity>()
.eq(CollectDataItemEntity::getTaskId, taskId));
long totalRows = total == null ? 0L : total;
int totalPages = totalRows == 0 ? 0 : (int) ((totalRows + safePageSize - 1) / safePageSize);
int offset = (safePage - 1) * safePageSize;
List<CollectDataItemEntity> rows = collectDataItemMapper.selectList(new LambdaQueryWrapper<CollectDataItemEntity>()
.eq(CollectDataItemEntity::getTaskId, taskId)
.orderByAsc(CollectDataItemEntity::getRowIndex)
.last("limit " + safePageSize + " offset " + Math.max(offset, 0)));
CollectDataItemsPageVo vo = new CollectDataItemsPageVo();
vo.setTaskId(task.getId());
vo.setTaskNo(task.getTaskNo());
vo.setTaskStatus(task.getStatus());
vo.setPage(safePage);
vo.setPageSize(safePageSize);
vo.setTotal(totalRows);
vo.setTotalPages(totalPages);
vo.setFilters(extractFiltersFromRequest(task));
vo.setTaskType(extractTaskTypeFromRequest(task));
List<CollectDataItemVo> items = new ArrayList<>();
if (rows != null) {
for (CollectDataItemEntity row : rows) {
items.add(toItemVo(row));
}
}
vo.setItems(items);
vo.setCount(items.size());
return vo;
}
@Transactional
public void deleteTask(Long taskId, Long userId) {
FileTaskEntity task = requireTask(taskId, userId);
collectDataItemMapper.delete(new LambdaQueryWrapper<CollectDataItemEntity>()
.eq(CollectDataItemEntity::getTaskId, task.getId()));
fileResultMapper.delete(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getTaskId, task.getId())
.eq(FileResultEntity::getModuleType, MODULE_TYPE));
fileTaskMapper.deleteById(task.getId());
}
public void deleteHistory(Long resultId, Long userId) {
FileResultEntity row = fileResultMapper.selectById(resultId);
if (row == null || !MODULE_TYPE.equals(row.getModuleType()) || !Objects.equals(userId, row.getUserId())) {
throw new BusinessException("记录不存在");
}
fileResultMapper.deleteById(resultId);
}
public CollectDataCountryPreferenceVo getCountryPreference(Long userId) {
if (userId == null || userId <= 0) {
throw new BusinessException("user_id 不合法");
}
CollectDataCountryPrefEntity row = collectDataCountryPrefMapper.selectById(userId);
CollectDataCountryPreferenceVo vo = new CollectDataCountryPreferenceVo();
if (row == null || row.getCountryCodesJson() == null || row.getCountryCodesJson().isBlank()) {
vo.getCountryCodes().addAll(DEFAULT_COUNTRY_PREFERENCE_ORDER);
return vo;
}
try {
List<String> parsed = objectMapper.readValue(row.getCountryCodesJson(), new TypeReference<List<String>>() {
});
vo.getCountryCodes().addAll(sanitizeStoredCountryCodes(parsed));
} catch (Exception ex) {
vo.getCountryCodes().addAll(DEFAULT_COUNTRY_PREFERENCE_ORDER);
}
return vo;
}
@Transactional
public CollectDataCountryPreferenceVo saveCountryPreference(CollectDataCountryPreferenceSaveRequest request) {
if (request == null || request.getUserId() == null || request.getUserId() <= 0) {
throw new BusinessException("user_id 不合法");
}
List<String> normalized = validateCountryCodesForSave(request.getCountryCodes());
String json;
try {
json = objectMapper.writeValueAsString(normalized);
} catch (Exception ex) {
throw new BusinessException("保存国家偏好失败");
}
CollectDataCountryPrefEntity row = collectDataCountryPrefMapper.selectById(request.getUserId());
if (row == null) {
row = new CollectDataCountryPrefEntity();
row.setUserId(request.getUserId());
row.setCountryCodesJson(json);
collectDataCountryPrefMapper.insert(row);
} else {
row.setCountryCodesJson(json);
collectDataCountryPrefMapper.updateById(row);
}
CollectDataCountryPreferenceVo vo = new CollectDataCountryPreferenceVo();
vo.getCountryCodes().addAll(normalized);
return vo;
}
private static List<String> sanitizeStoredCountryCodes(List<String> raw) {
if (raw == null || raw.isEmpty()) {
return new ArrayList<>(DEFAULT_COUNTRY_PREFERENCE_ORDER);
}
LinkedHashSet<String> seen = new LinkedHashSet<>();
List<String> out = new ArrayList<>();
for (String code : raw) {
if (code == null || code.isBlank()) {
continue;
}
String upper = code.trim().toUpperCase(Locale.ROOT);
if (!DEFAULT_COUNTRY_PREFERENCE_ORDER.contains(upper)) {
continue;
}
if (seen.add(upper)) {
out.add(upper);
}
}
return out.isEmpty() ? new ArrayList<>(DEFAULT_COUNTRY_PREFERENCE_ORDER) : out;
}
private static List<String> validateCountryCodesForSave(List<String> raw) {
if (raw == null || raw.isEmpty()) {
throw new BusinessException("country_codes 至少选择 1 个国家");
}
LinkedHashSet<String> seen = new LinkedHashSet<>();
List<String> out = new ArrayList<>();
for (String code : raw) {
if (code == null || code.isBlank()) {
throw new BusinessException("country_codes 含空项");
}
String upper = code.trim().toUpperCase(Locale.ROOT);
if (!DEFAULT_COUNTRY_PREFERENCE_ORDER.contains(upper)) {
throw new BusinessException("非法国家代码: " + code);
}
if (!seen.add(upper)) {
throw new BusinessException("country_codes 存在重复: " + upper);
}
out.add(upper);
}
if (out.size() > DEFAULT_COUNTRY_PREFERENCE_ORDER.size()) {
throw new BusinessException("country_codes 最多 5 项");
}
return out;
}
private FileTaskEntity requireTask(Long taskId, Long userId) {
if (taskId == null || taskId <= 0) {
throw new BusinessException("任务不存在");
}
FileTaskEntity task = fileTaskMapper.selectById(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
throw new BusinessException("任务不存在");
}
if (userId != null && userId > 0 && !Objects.equals(userId, task.getUserId())) {
throw new BusinessException("任务不存在");
}
return task;
}
private long countTask(Long userId, String status) {
if (userId == null || userId <= 0) {
return 0L;
}
Long count = fileTaskMapper.selectCount(new LambdaQueryWrapper<FileTaskEntity>()
.eq(FileTaskEntity::getModuleType, MODULE_TYPE)
.eq(FileTaskEntity::getUserId, userId)
.eq(FileTaskEntity::getStatus, status));
return count == null ? 0L : count;
}
private Map<Long, FileTaskEntity> loadTaskMap(List<Long> taskIds) {
Map<Long, FileTaskEntity> taskMap = new LinkedHashMap<>();
if (taskIds == null || taskIds.isEmpty()) {
return taskMap;
}
List<FileTaskEntity> tasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
.in(FileTaskEntity::getId, taskIds));
if (tasks != null) {
for (FileTaskEntity task : tasks) {
if (task != null && MODULE_TYPE.equals(task.getModuleType())) {
taskMap.put(task.getId(), task);
}
}
}
return taskMap;
}
private CollectDataHistoryItemVo toHistoryItem(FileResultEntity row, FileTaskEntity task) {
CollectDataHistoryItemVo vo = new CollectDataHistoryItemVo();
vo.setResultId(row.getId());
vo.setTaskId(row.getTaskId());
vo.setSourceFilename(row.getSourceFilename());
vo.setResultFilename(row.getResultFilename());
vo.setRowCount(row.getRowCount());
vo.setSuccess(row.getSuccess() != null && row.getSuccess() == 1);
vo.setError(row.getErrorMessage());
vo.setCreatedAt(formatTime(row.getCreatedAt()));
if (task != null) {
vo.setTaskNo(task.getTaskNo());
vo.setTaskStatus(task.getStatus());
vo.setStartedAt(formatTime(task.getCreatedAt()));
vo.setFinishedAt(formatTime(task.getFinishedAt()));
vo.setTaskType(extractTaskTypeFromRequest(task));
vo.setFilters(extractFiltersFromRequest(task));
}
return vo;
}
private CollectDataTaskSummaryVo toTaskSummary(FileTaskEntity task) {
CollectDataTaskSummaryVo vo = new CollectDataTaskSummaryVo();
vo.setId(task.getId());
vo.setTaskNo(task.getTaskNo());
vo.setStatus(task.getStatus());
vo.setErrorMessage(task.getErrorMessage());
vo.setCreatedAt(formatTime(task.getCreatedAt()));
vo.setUpdatedAt(formatTime(task.getUpdatedAt()));
vo.setFinishedAt(formatTime(task.getFinishedAt()));
return vo;
}
private CollectDataItemVo toItemVo(CollectDataItemEntity row) {
CollectDataItemVo vo = new CollectDataItemVo();
vo.setId(row.getId());
vo.setRowIndex(row.getRowIndex());
vo.setSourceFileKey(row.getSourceFileKey());
vo.setSourceFilename(row.getSourceFilename());
vo.setKeyword(row.getKeyword());
vo.setStatusValue(row.getStatusValue());
if (row.getExtraJson() != null && !row.getExtraJson().isBlank()) {
try {
Map<String, String> extra = objectMapper.readValue(row.getExtraJson(), new TypeReference<>() {
});
if (extra != null) {
vo.getExtra().putAll(extra);
}
} catch (Exception ex) {
log.warn("[collect-data] parse extra json failed rowId={} err={}", row.getId(), ex.getMessage());
}
}
return vo;
}
private CollectDataFiltersDto extractFiltersFromRequest(FileTaskEntity task) {
if (task == null || task.getRequestJson() == null || task.getRequestJson().isBlank()) {
return new CollectDataFiltersDto();
}
try {
JsonNode root = objectMapper.readTree(task.getRequestJson());
JsonNode filtersNode = root.get("filters");
if (filtersNode == null || filtersNode.isNull()) {
return new CollectDataFiltersDto();
}
return objectMapper.treeToValue(filtersNode, CollectDataFiltersDto.class);
} catch (Exception ex) {
log.warn("[collect-data] parse request filters failed taskId={} err={}", task.getId(), ex.getMessage());
return new CollectDataFiltersDto();
}
}
private String extractTaskTypeFromRequest(FileTaskEntity task) {
if (task == null || task.getRequestJson() == null || task.getRequestJson().isBlank()) {
return DEFAULT_TASK_TYPE;
}
try {
JsonNode root = objectMapper.readTree(task.getRequestJson());
JsonNode taskTypeNode = root.get("taskType");
String value = taskTypeNode == null || taskTypeNode.isNull() ? null : taskTypeNode.asText(null);
return value == null || value.isBlank() ? DEFAULT_TASK_TYPE : value;
} catch (Exception ex) {
return DEFAULT_TASK_TYPE;
}
}
private String buildAggregateSourceFilenameLabel(List<CollectDataSourceFileDto> sources) {
if (sources == null || sources.isEmpty()) {
return "";
}
String first = firstNonBlank(sources.get(0).getOriginalFilename(), sources.get(0).getFileKey());
if (sources.size() == 1) {
return first;
}
return first + "" + sources.size() + " 个文件";
}
private String buildAggregateSourceFileUrl(List<CollectDataSourceFileDto> sources) {
if (sources == null || sources.isEmpty()) {
return "";
}
StringBuilder sb = new StringBuilder();
for (int i = 0; i < sources.size(); i++) {
if (i > 0) {
sb.append('|');
}
sb.append(sources.get(i).getFileKey());
}
return sb.toString();
}
private ParsedWorkbook parseWorkbook(File input, CollectDataSourceFileDto source) {
DataFormatter formatter = new DataFormatter();
try (FileInputStream fis = new FileInputStream(input); Workbook workbook = WorkbookFactory.create(fis)) {
Sheet sheet = workbook.getSheetAt(0);
Row header = sheet.getRow(0);
if (header == null) {
throw new BusinessException("Excel 表头为空");
}
List<String> headers = new ArrayList<>();
for (int i = 0; i < header.getLastCellNum(); i++) {
String value = normalize(formatter.formatCellValue(header.getCell(i)));
headers.add(value.isBlank() ? "" + (i + 1) : value);
}
int keywordCol = findHeaderIndex(headers, KEYWORD_HEADER_ALIASES);
int statusCol = findHeaderIndex(headers, STATUS_HEADER_ALIASES);
List<ParsedRow> rows = new ArrayList<>();
int totalRows = 0;
int droppedRows = 0;
String filename = firstNonBlank(source.getOriginalFilename(), input.getName());
for (int i = 1; i <= sheet.getLastRowNum(); i++) {
Row row = sheet.getRow(i);
if (row == null) {
continue;
}
Map<String, String> extra = new LinkedHashMap<>();
String keyword = "";
String statusValue = "";
boolean nonEmpty = false;
for (int c = 0; c < headers.size(); c++) {
Cell cell = row.getCell(c);
String value = normalize(cell == null ? "" : formatter.formatCellValue(cell));
if (!value.isBlank()) {
nonEmpty = true;
}
if (c == keywordCol) {
keyword = value;
} else if (c == statusCol) {
statusValue = value;
} else {
extra.put(headers.get(c), value);
}
}
if (!nonEmpty) {
continue;
}
totalRows++;
if (keywordCol >= 0 && keyword.isBlank()) {
droppedRows++;
continue;
}
rows.add(new ParsedRow(source.getFileKey(), filename, keyword, statusValue, extra));
}
return new ParsedWorkbook(totalRows, droppedRows, rows);
} catch (BusinessException ex) {
throw ex;
} catch (Exception ex) {
log.warn("[collect-data] parse workbook failed file={} err={}", input, ex.getMessage());
throw new BusinessException("解析 Excel 失败");
}
}
private int findHeaderIndex(List<String> headers, List<String> aliases) {
if (headers == null || headers.isEmpty() || aliases == null || aliases.isEmpty()) {
return -1;
}
for (int i = 0; i < headers.size(); i++) {
String lower = headers.get(i).toLowerCase(Locale.ROOT);
for (String alias : aliases) {
if (lower.contains(alias.toLowerCase(Locale.ROOT))) {
return i;
}
}
}
return -1;
}
private String formatTime(LocalDateTime time) {
return time == null ? null : time.format(TIME_FORMATTER);
}
private String firstNonBlank(String... values) {
if (values == null) {
return "";
}
for (String value : values) {
if (value != null && !value.isBlank()) {
return value;
}
}
return "";
}
private String normalize(String value) {
if (value == null) {
return "";
}
String normalized = value.replace(String.valueOf((char) 0xFEFF), "")
.replace((char) 0x3000, ' ')
.replace("\r\n", " ")
.replace("\r", " ")
.replace("\n", " ")
.replace("\t", " ")
.trim();
return WHITESPACE_PATTERN.matcher(normalized).replaceAll(" ");
}
private record ParsedRow(String sourceFileKey,
String sourceFilename,
String keyword,
String statusValue,
Map<String, String> extra) {
}
private record ParsedWorkbook(int totalRows, int droppedRows, List<ParsedRow> rows) {
}
private record PersistedParse(FileTaskEntity task, FileResultEntity result) {
}
}

View File

@@ -152,13 +152,13 @@ public class DeleteBrandRunController {
@Operation(summary = "Download one delete-brand result")
public void downloadResult(
@PathVariable Long resultId,
@Parameter(name = "user_id", description = "褰撳墠鐧诲綍鐢ㄦ埛 ID", required = true, in = ParameterIn.QUERY)
@Parameter(name = "user_id", description = "当前登录用户 ID", required = true, in = ParameterIn.QUERY)
@RequestParam("user_id") Long userId,
jakarta.servlet.http.HttpServletResponse response) {
String url = deleteBrandRunService.resolveResultDownloadUrl(resultId, userId);
String filename = deleteBrandRunService.resolveResultDownloadFilename(resultId, userId);
if (url == null || url.isBlank()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "缁撴灉鏃犲彲涓嬭浇");
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "结果无可下载");
}
if (filename == null || filename.isBlank()) {
String rawPath = URI.create(url).getPath();
@@ -177,7 +177,7 @@ public class DeleteBrandRunController {
response.getOutputStream().flush();
}
} catch (Exception ex) {
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "涓嬭浇澶辫触");
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "下载失败");
}
}

View File

@@ -628,7 +628,7 @@ public class DeleteBrandStaleTaskService {
.eq(FileTaskEntity::getModuleType, MODULE_TYPE_QUERY_ASIN)
.eq(FileTaskEntity::getStatus, "RUNNING")
.set(FileTaskEntity::getStatus, "FAILED")
.set(FileTaskEntity::getErrorMessage, "闀挎椂闂存湭鏀跺埌 Python 缁撴灉鍥炰紶锛屼换鍔″凡鑷姩澶辫触")
.set(FileTaskEntity::getErrorMessage, "长时间未收到 Python 结果回传,任务已自动失败")
.set(FileTaskEntity::getUpdatedAt, LocalDateTime.now())
.set(FileTaskEntity::getFinishedAt, LocalDateTime.now()));
if (updated > 0) {

View File

@@ -0,0 +1,80 @@
package com.nanri.aiimage.modules.invalidasin.controller;
import com.nanri.aiimage.common.api.ApiResponse;
import com.nanri.aiimage.modules.invalidasin.model.dto.InvalidAsinDataCreateRequest;
import com.nanri.aiimage.modules.invalidasin.model.dto.InvalidAsinDataUpdateRequest;
import com.nanri.aiimage.modules.invalidasin.model.vo.InvalidAsinDataItemVo;
import com.nanri.aiimage.modules.invalidasin.model.vo.InvalidAsinDataPageVo;
import com.nanri.aiimage.modules.invalidasin.service.InvalidAsinDataService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
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/admin/invalid-asin-data")
@Tag(name = "不符合ASIN数据", description = "维护不符合ASIN数据列表支持增删改查。")
public class InvalidAsinDataController {
private final InvalidAsinDataService invalidAsinDataService;
@GetMapping
@Operation(summary = "分页查询不符合ASIN数据", description = "分页查询不符合ASIN数据支持按 ASIN/品牌 模糊搜索。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功", content = @Content(schema = @Schema(implementation = InvalidAsinDataPageVo.class)))
})
public ApiResponse<InvalidAsinDataPageVo> page(
@Parameter(description = "页码") @RequestParam(defaultValue = "1") Long page,
@Parameter(description = "每页数量") @RequestParam(defaultValue = "15") Long pageSize,
@Parameter(description = "模糊搜索关键字") @RequestParam(required = false) String keyword) {
return ApiResponse.success(invalidAsinDataService.page(page, pageSize, keyword));
}
@PostMapping
@Operation(summary = "新增不符合ASIN数据", description = "新增一条不符合ASIN数据。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "创建成功", content = @Content(schema = @Schema(implementation = InvalidAsinDataItemVo.class))),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "参数不合法或数据重复")
})
public ApiResponse<InvalidAsinDataItemVo> create(@Valid @RequestBody InvalidAsinDataCreateRequest request) {
return ApiResponse.success("创建成功", invalidAsinDataService.create(request));
}
@PutMapping("/{id}")
@Operation(summary = "更新不符合ASIN数据", description = "按 ID 更新一条不符合ASIN数据。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "更新成功", content = @Content(schema = @Schema(implementation = InvalidAsinDataItemVo.class))),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "参数不合法或数据重复"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "数据不存在")
})
public ApiResponse<InvalidAsinDataItemVo> update(
@Parameter(description = "主键ID", required = true) @PathVariable Long id,
@Valid @RequestBody InvalidAsinDataUpdateRequest request) {
return ApiResponse.success("更新成功", invalidAsinDataService.update(id, request));
}
@DeleteMapping("/{id}")
@Operation(summary = "删除不符合ASIN数据", description = "按 ID 删除一条不符合ASIN数据。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "删除成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "数据不存在")
})
public ApiResponse<Void> delete(@Parameter(description = "主键ID", required = true) @PathVariable Long id) {
invalidAsinDataService.delete(id);
return ApiResponse.success("删除成功", null);
}
}

View File

@@ -0,0 +1,9 @@
package com.nanri.aiimage.modules.invalidasin.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.nanri.aiimage.modules.invalidasin.model.entity.InvalidAsinDataEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface InvalidAsinDataMapper extends BaseMapper<InvalidAsinDataEntity> {
}

View File

@@ -0,0 +1,20 @@
package com.nanri.aiimage.modules.invalidasin.model.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Data
@Schema(description = "不符合ASIN数据新增请求")
public class InvalidAsinDataCreateRequest {
@NotBlank(message = "ASIN 不能为空")
@Size(max = 128, message = "ASIN 长度不能超过128个字符")
@Schema(description = "ASIN 值", requiredMode = Schema.RequiredMode.REQUIRED)
private String dataValue;
@Size(max = 128, message = "品牌长度不能超过128个字符")
@Schema(description = "品牌名称")
private String brand;
}

View File

@@ -0,0 +1,20 @@
package com.nanri.aiimage.modules.invalidasin.model.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Data
@Schema(description = "不符合ASIN数据更新请求")
public class InvalidAsinDataUpdateRequest {
@NotBlank(message = "ASIN 不能为空")
@Size(max = 128, message = "ASIN 长度不能超过128个字符")
@Schema(description = "ASIN 值", requiredMode = Schema.RequiredMode.REQUIRED)
private String dataValue;
@Size(max = 128, message = "品牌长度不能超过128个字符")
@Schema(description = "品牌名称")
private String brand;
}

View File

@@ -0,0 +1,20 @@
package com.nanri.aiimage.modules.invalidasin.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("biz_invalid_asin_data")
public class InvalidAsinDataEntity {
@TableId(type = IdType.AUTO)
private Long id;
private String dataValue;
private String brand;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,23 @@
package com.nanri.aiimage.modules.invalidasin.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@Schema(description = "不符合ASIN数据项")
public class InvalidAsinDataItemVo {
@Schema(description = "主键ID")
private Long id;
@Schema(description = "ASIN 值")
private String dataValue;
@Schema(description = "品牌名称")
private String brand;
@Schema(description = "创建时间")
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,23 @@
package com.nanri.aiimage.modules.invalidasin.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
@Data
@Schema(description = "不符合ASIN数据分页结果")
public class InvalidAsinDataPageVo {
@Schema(description = "列表")
private List<InvalidAsinDataItemVo> items;
@Schema(description = "总数")
private Long total;
@Schema(description = "页码")
private Long page;
@Schema(description = "每页数量")
private Long pageSize;
}

View File

@@ -0,0 +1,130 @@
package com.nanri.aiimage.modules.invalidasin.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.invalidasin.mapper.InvalidAsinDataMapper;
import com.nanri.aiimage.modules.invalidasin.model.dto.InvalidAsinDataCreateRequest;
import com.nanri.aiimage.modules.invalidasin.model.dto.InvalidAsinDataUpdateRequest;
import com.nanri.aiimage.modules.invalidasin.model.entity.InvalidAsinDataEntity;
import com.nanri.aiimage.modules.invalidasin.model.vo.InvalidAsinDataItemVo;
import com.nanri.aiimage.modules.invalidasin.model.vo.InvalidAsinDataPageVo;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@RequiredArgsConstructor
public class InvalidAsinDataService {
private final InvalidAsinDataMapper invalidAsinDataMapper;
public InvalidAsinDataPageVo page(long page, long pageSize, String keyword) {
long safePage = Math.max(page, 1);
long safePageSize = Math.min(Math.max(pageSize, 1), 100);
String safeKeyword = keyword == null ? "" : keyword.trim();
LambdaQueryWrapper<InvalidAsinDataEntity> query = new LambdaQueryWrapper<InvalidAsinDataEntity>()
.and(!safeKeyword.isEmpty(), wrapper -> wrapper
.like(InvalidAsinDataEntity::getDataValue, safeKeyword)
.or()
.like(InvalidAsinDataEntity::getBrand, safeKeyword))
.orderByDesc(InvalidAsinDataEntity::getId);
Long total = invalidAsinDataMapper.selectCount(query);
List<InvalidAsinDataItemVo> items = invalidAsinDataMapper
.selectList(query.last("LIMIT " + ((safePage - 1) * safePageSize) + ", " + safePageSize))
.stream()
.map(this::toItemVo)
.toList();
InvalidAsinDataPageVo vo = new InvalidAsinDataPageVo();
vo.setItems(items);
vo.setTotal(total);
vo.setPage(safePage);
vo.setPageSize(safePageSize);
return vo;
}
@Transactional
public InvalidAsinDataItemVo create(InvalidAsinDataCreateRequest request) {
String dataValue = normalizeRequired(request.getDataValue(), "ASIN 不能为空");
String brand = normalizeOptional(request.getBrand());
ensureUnique(dataValue, null);
InvalidAsinDataEntity entity = new InvalidAsinDataEntity();
entity.setDataValue(dataValue);
entity.setBrand(brand);
invalidAsinDataMapper.insert(entity);
return toItemVo(getById(entity.getId()));
}
@Transactional
public InvalidAsinDataItemVo update(Long id, InvalidAsinDataUpdateRequest request) {
InvalidAsinDataEntity entity = getById(id);
String dataValue = normalizeRequired(request.getDataValue(), "ASIN 不能为空");
String brand = normalizeOptional(request.getBrand());
ensureUnique(dataValue, id);
entity.setDataValue(dataValue);
entity.setBrand(brand);
invalidAsinDataMapper.updateById(entity);
return toItemVo(getById(id));
}
@Transactional
public void delete(Long id) {
InvalidAsinDataEntity entity = getById(id);
invalidAsinDataMapper.deleteById(entity.getId());
}
private InvalidAsinDataEntity getById(Long id) {
InvalidAsinDataEntity entity = invalidAsinDataMapper.selectById(id);
if (entity == null) {
throw new BusinessException("数据不存在");
}
return entity;
}
private void ensureUnique(String dataValue, Long excludeId) {
LambdaQueryWrapper<InvalidAsinDataEntity> query = new LambdaQueryWrapper<InvalidAsinDataEntity>()
.eq(InvalidAsinDataEntity::getDataValue, dataValue)
.last("LIMIT 1");
InvalidAsinDataEntity exists = invalidAsinDataMapper.selectOne(query);
if (exists != null && (excludeId == null || !exists.getId().equals(excludeId))) {
throw new BusinessException("ASIN 已存在");
}
}
private String normalizeRequired(String value, String message) {
String normalized = normalizeText(value);
if (normalized.isEmpty()) {
throw new BusinessException(message);
}
return normalized;
}
private String normalizeOptional(String value) {
String normalized = normalizeText(value);
return normalized.isEmpty() ? null : normalized;
}
private String normalizeText(String value) {
if (value == null) {
return "";
}
return value.replace("", "")
.replace(" ", " ")
.replace("\r\n", " ")
.replace("\r", " ")
.replace("\n", " ")
.replace("\t", " ")
.trim()
.replaceAll("\\s+", " ");
}
private InvalidAsinDataItemVo toItemVo(InvalidAsinDataEntity entity) {
InvalidAsinDataItemVo vo = new InvalidAsinDataItemVo();
vo.setId(entity.getId());
vo.setDataValue(entity.getDataValue());
vo.setBrand(entity.getBrand());
vo.setCreatedAt(entity.getCreatedAt());
return vo;
}
}

View File

@@ -1044,7 +1044,7 @@ public class PatrolDeleteTaskService {
private TaskDistributedLockService.LockHandle acquireTaskLockOrThrow(Long taskId) {
TaskDistributedLockService.LockHandle lockHandle = acquireTaskLock(taskId);
if (lockHandle == null) {
throw new BusinessException(40901, "浠诲姟姝e湪澶勭悊涓紝璇风◢鍚庡啀璇?");
throw new BusinessException(40901, "任务正在处理中,请稍后再试");
}
return lockHandle;
}

View File

@@ -29,6 +29,7 @@ public class PermissionMenuSchemaInitializer {
new DefaultAdminMenu("用户管理", "admin_users", "users", 10),
new DefaultAdminMenu("栏目权限配置", "admin_columns", "columns", 20),
new DefaultAdminMenu("数据去重总数据", "admin_dedupe_total_data", "dedupe-total-data", 30),
new DefaultAdminMenu("不符合ASIN数据", "admin_invalid_asin_data", "invalid-asin-data", 35),
new DefaultAdminMenu("店铺密钥管理", "admin_shop_keys", "shop-keys", 40),
new DefaultAdminMenu("店铺管理", "admin_shop_manage", "shop-manage", 50),
new DefaultAdminMenu("跳过跟价ASIN", "admin_skip_price_asin", "skip-price-asin", 60),

View File

@@ -1391,7 +1391,7 @@ public class PriceTrackTaskService {
private TaskDistributedLockService.LockHandle acquireTaskLockOrThrow(Long taskId) {
TaskDistributedLockService.LockHandle lockHandle = acquireTaskLock(taskId);
if (lockHandle == null) {
throw new BusinessException(40901, "浠诲姟姝e湪澶勭悊涓紝璇风◢鍚庡啀璇?");
throw new BusinessException(40901, "任务正在处理中,请稍后再试");
}
return lockHandle;
}

View File

@@ -970,7 +970,7 @@ public class ProductRiskTaskService {
private TaskDistributedLockService.LockHandle acquireTaskLockOrThrow(Long taskId) {
TaskDistributedLockService.LockHandle lockHandle = acquireTaskLock(taskId);
if (lockHandle == null) {
throw new BusinessException(40901, "浠诲姟姝e湪澶勭悊涓紝璇风◢鍚庡啀璇?");
throw new BusinessException(40901, "任务正在处理中,请稍后再试");
}
return lockHandle;
}

View File

@@ -1003,7 +1003,7 @@ public class QueryAsinTaskService {
private TaskDistributedLockService.LockHandle acquireTaskLockOrThrow(Long taskId) {
TaskDistributedLockService.LockHandle lockHandle = acquireTaskLock(taskId);
if (lockHandle == null) {
throw new BusinessException(40901, "浠诲姟姝e湪澶勭悊涓紝璇风◢鍚庡啀璇?");
throw new BusinessException(40901, "任务正在处理中,请稍后再试");
}
return lockHandle;
}

View File

@@ -33,15 +33,8 @@ import java.time.format.DateTimeFormatter;
@RestController
@RequiredArgsConstructor
@RequestMapping({
"/admin/skip-price-asins",
"/admin/skip-price-asin",
"/api/admin/skip-price-asins",
"/api/admin/skip-price-asin",
"/skip-price-asins",
"/skip-price-asin"
})
@Tag(name = "Skip Price ASIN", description = "Manage skip price asin data")
@RequestMapping("/api/admin/skip-price-asins")
@Tag(name = "跳过跟价 ASIN", description = "管理跳过跟价 ASIN 数据,包括分页、导出、导入、按国家更新和删除。")
public class SkipPriceAsinController {
private static final DateTimeFormatter EXPORT_FILENAME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss");
@@ -49,15 +42,15 @@ public class SkipPriceAsinController {
private final SkipPriceAsinService skipPriceAsinService;
@GetMapping
@Operation(summary = "Page query skip price asins")
@Operation(summary = "分页查询跳过跟价 ASIN", description = "支持按分组、店铺名、ASIN 和当前操作人可见范围筛选。")
public ApiResponse<SkipPriceAsinPageVo> page(
@Parameter(description = "page") @RequestParam(name = "page", defaultValue = "1") Long page,
@Parameter(description = "page size") @RequestParam(name = "page_size", defaultValue = "15") Long pageSize,
@Parameter(description = "group id") @RequestParam(name = "group_id", required = false) Long groupId,
@Parameter(description = "shop name") @RequestParam(name = "shop_name", required = false) String shopName,
@Parameter(description = "asin") @RequestParam(name = "asin", required = false) String asin,
@Parameter(description = "operator user id") @RequestParam(name = "operator_id", required = false) Long operatorId,
@Parameter(description = "is super admin") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) {
@Parameter(description = "页码") @RequestParam(name = "page", defaultValue = "1") Long page,
@Parameter(description = "每页数量") @RequestParam(name = "page_size", defaultValue = "15") Long pageSize,
@Parameter(description = "分组 ID") @RequestParam(name = "group_id", required = false) Long groupId,
@Parameter(description = "店铺名称") @RequestParam(name = "shop_name", required = false) String shopName,
@Parameter(description = "ASIN") @RequestParam(name = "asin", required = false) String asin,
@Parameter(description = "当前操作人用户 ID") @RequestParam(name = "operator_id", required = false) Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) {
return ApiResponse.success(skipPriceAsinService.page(
page,
pageSize,
@@ -69,13 +62,13 @@ public class SkipPriceAsinController {
}
@GetMapping("/export")
@Operation(summary = "Export skip price asins")
@Operation(summary = "导出跳过跟价 ASIN", description = "按当前查询条件导出 Excel。")
public ResponseEntity<byte[]> export(
@Parameter(description = "group id") @RequestParam(name = "group_id", required = false) Long groupId,
@Parameter(description = "shop name") @RequestParam(name = "shop_name", required = false) String shopName,
@Parameter(description = "asin") @RequestParam(name = "asin", required = false) String asin,
@Parameter(description = "operator user id") @RequestParam(name = "operator_id", required = false) Long operatorId,
@Parameter(description = "is super admin") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) {
@Parameter(description = "分组 ID") @RequestParam(name = "group_id", required = false) Long groupId,
@Parameter(description = "店铺名称") @RequestParam(name = "shop_name", required = false) String shopName,
@Parameter(description = "ASIN") @RequestParam(name = "asin", required = false) String asin,
@Parameter(description = "当前操作人用户 ID") @RequestParam(name = "operator_id", required = false) Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) {
byte[] bytes = skipPriceAsinService.export(groupId, shopName, asin, operatorId, Boolean.TRUE.equals(superAdmin));
String filename = "skip-price-asin-" + LocalDateTime.now().format(EXPORT_FILENAME_FORMATTER) + ".xlsx";
return ResponseEntity.ok()
@@ -86,58 +79,60 @@ public class SkipPriceAsinController {
}
@PostMapping
@Operation(summary = "Create or update skip price asin")
@Operation(summary = "新增或更新跳过跟价 ASIN", description = "按店铺和国家维度新增或更新跳过跟价 ASIN。")
public ApiResponse<SkipPriceAsinItemVo> create(
@Parameter(description = "operator user id") @RequestParam(name = "operator_id") Long operatorId,
@Parameter(description = "is super admin") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin,
@Parameter(description = "当前操作人用户 ID") @RequestParam(name = "operator_id") Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin,
@Valid @RequestBody SkipPriceAsinCreateRequest request) {
return ApiResponse.success("\u4fdd\u5b58\u6210\u529f",
return ApiResponse.success("保存成功",
skipPriceAsinService.createOrUpdate(request, operatorId, Boolean.TRUE.equals(superAdmin)));
}
@PostMapping("/import")
@Operation(summary = "Import skip price asin")
@Operation(summary = "导入跳过跟价 ASIN", description = "上传 xlsx 或 xls 文件,异步导入新增数据。")
public ApiResponse<QueryAsinImportStartVo> importExcel(
@Parameter(description = "xlsx or xls file", required = true) @RequestParam("file") MultipartFile file,
@Parameter(description = "group id", required = true) @RequestParam(name = "group_id") Long groupId,
@Parameter(description = "operator user id") @RequestParam(name = "operator_id") Long operatorId,
@Parameter(description = "is super admin") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) {
return ApiResponse.success("\u5f00\u59cb\u5bfc\u5165",
@Parameter(description = "xlsx xls 文件", required = true) @RequestParam("file") MultipartFile file,
@Parameter(description = "分组 ID", required = true) @RequestParam(name = "group_id") Long groupId,
@Parameter(description = "当前操作人用户 ID") @RequestParam(name = "operator_id") Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) {
return ApiResponse.success("开始导入",
skipPriceAsinService.startImport(file, groupId, operatorId, Boolean.TRUE.equals(superAdmin)));
}
@GetMapping("/import/{importId}")
@Operation(summary = "Query import progress")
public ApiResponse<QueryAsinImportProgressVo> importProgress(@PathVariable String importId) {
@Operation(summary = "查询导入进度")
public ApiResponse<QueryAsinImportProgressVo> importProgress(
@Parameter(description = "导入任务 ID", required = true) @PathVariable String importId) {
return ApiResponse.success(skipPriceAsinService.getImportProgress(importId));
}
@PostMapping("/delete-import")
@Operation(summary = "Import delete skip price asin")
@Operation(summary = "导入删除跳过跟价 ASIN", description = "上传 xlsx 或 xls 文件,异步按文件内容删除数据。")
public ApiResponse<QueryAsinImportStartVo> deleteImportExcel(
@Parameter(description = "xlsx or xls file", required = true) @RequestParam("file") MultipartFile file,
@Parameter(description = "group id", required = true) @RequestParam(name = "group_id") Long groupId,
@Parameter(description = "operator user id") @RequestParam(name = "operator_id") Long operatorId,
@Parameter(description = "is super admin") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) {
return ApiResponse.success("\u5f00\u59cb\u5220\u9664",
@Parameter(description = "xlsx xls 文件", required = true) @RequestParam("file") MultipartFile file,
@Parameter(description = "分组 ID", required = true) @RequestParam(name = "group_id") Long groupId,
@Parameter(description = "当前操作人用户 ID") @RequestParam(name = "operator_id") Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) {
return ApiResponse.success("开始删除",
skipPriceAsinService.startDeleteImport(file, groupId, operatorId, Boolean.TRUE.equals(superAdmin)));
}
@GetMapping("/delete-import/{importId}")
@Operation(summary = "Query delete import progress")
public ApiResponse<QueryAsinImportProgressVo> deleteImportProgress(@PathVariable String importId) {
@Operation(summary = "查询导入删除进度")
public ApiResponse<QueryAsinImportProgressVo> deleteImportProgress(
@Parameter(description = "导入删除任务 ID", required = true) @PathVariable String importId) {
return ApiResponse.success(skipPriceAsinService.getDeleteImportProgress(importId));
}
@PutMapping({"/{id}/countries/{country}", "/{id}/country/{country}"})
@Operation(summary = "Update one country skip price asin")
@PutMapping("/{id}/countries/{country}")
@Operation(summary = "更新指定国家的跳过跟价 ASIN")
public ApiResponse<SkipPriceAsinItemVo> updateCountry(
@Parameter(description = "id", required = true) @PathVariable Long id,
@Parameter(description = "country code", required = true) @PathVariable String country,
@Parameter(description = "operator user id") @RequestParam(name = "operator_id") Long operatorId,
@Parameter(description = "is super admin") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin,
@Parameter(description = "主键 ID", required = true) @PathVariable Long id,
@Parameter(description = "国家代码", required = true) @PathVariable String country,
@Parameter(description = "当前操作人用户 ID") @RequestParam(name = "operator_id") Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin,
@Valid @RequestBody SkipPriceAsinCountryUpdateRequest request) {
return ApiResponse.success("\u4fdd\u5b58\u6210\u529f", skipPriceAsinService.updateCountry(
return ApiResponse.success("保存成功", skipPriceAsinService.updateCountry(
id,
country,
request.getAsin(),
@@ -146,14 +141,14 @@ public class SkipPriceAsinController {
Boolean.TRUE.equals(superAdmin)));
}
@DeleteMapping({"/{id}/countries/{country}", "/{id}/country/{country}"})
@Operation(summary = "Delete one country skip price asin")
@DeleteMapping("/{id}/countries/{country}")
@Operation(summary = "删除指定国家的跳过跟价 ASIN")
public ApiResponse<Void> deleteCountry(
@Parameter(description = "id", required = true) @PathVariable Long id,
@Parameter(description = "country code", required = true) @PathVariable String country,
@Parameter(description = "operator user id") @RequestParam(name = "operator_id") Long operatorId,
@Parameter(description = "is super admin") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) {
@Parameter(description = "主键 ID", required = true) @PathVariable Long id,
@Parameter(description = "国家代码", required = true) @PathVariable String country,
@Parameter(description = "当前操作人用户 ID") @RequestParam(name = "operator_id") Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) {
skipPriceAsinService.deleteCountry(id, country, operatorId, Boolean.TRUE.equals(superAdmin));
return ApiResponse.success("\u5220\u9664\u6210\u529f", null);
return ApiResponse.success("删除成功", null);
}
}

View File

@@ -487,7 +487,7 @@ public class SkipPriceAsinService {
case "FR" -> query.eq(SkipPriceAsinEntity::getAsinFr, asin);
case "IT" -> query.eq(SkipPriceAsinEntity::getAsinIt, asin);
case "ES" -> query.eq(SkipPriceAsinEntity::getAsinEs, asin);
default -> throw new BusinessException("鍥藉鍙傛暟涓嶆敮鎸? " + country);
default -> throw new BusinessException("国家参数不支持: " + country);
}
return skipPriceAsinMapper.selectOne(query.last("LIMIT 1"));
}

View File

@@ -877,7 +877,7 @@ public class ShopMatchTaskService {
private TaskDistributedLockService.LockHandle acquireTaskLockOrThrow(Long taskId) {
TaskDistributedLockService.LockHandle lockHandle = acquireTaskLock(taskId);
if (lockHandle == null) {
throw new BusinessException(40901, "浠诲姟姝e湪澶勭悊涓紝璇风◢鍚庡啀璇?");
throw new BusinessException(40901, "任务正在处理中,请稍后再试");
}
return lockHandle;
}

View File

@@ -4811,7 +4811,7 @@ public class SimilarAsinTaskService {
vo.setTaskId(row.getTaskId());
vo.setSourceFilename(row.getSourceFilename());
vo.setResultFilename(row.getResultFilename());
vo.setDownloadUrl(null);
vo.setDownloadUrl(buildFreshDownloadUrl(row));
attachFileJobState(vo, row, job);
vo.setTaskStatus(task == null ? null : task.getStatus());
vo.setSuccess(row.getSuccess() != null && row.getSuccess() == 1);
@@ -4824,6 +4824,20 @@ public class SimilarAsinTaskService {
return vo;
}
private String buildFreshDownloadUrl(FileResultEntity row) {
String resultFileUrl = row == null ? null : row.getResultFileUrl();
if (resultFileUrl == null || resultFileUrl.isBlank()) {
return null;
}
try {
return ossStorageService.generateFreshDownloadUrl(resultFileUrl);
} catch (Exception ex) {
log.warn("[similar-asin] generate fresh download url failed resultId={} err={}",
row.getId(), ex.getMessage());
return null;
}
}
private void attachFileJobState(SimilarAsinHistoryItemVo vo, FileResultEntity row, TaskFileJobEntity job) {
vo.setFileReady(row.getResultFileUrl() != null && !row.getResultFileUrl().isBlank());
if (job == null) {

View File

@@ -0,0 +1,23 @@
CREATE TABLE IF NOT EXISTS biz_invalid_asin_data (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
data_value VARCHAR(128) NOT NULL COMMENT '不符合 ASIN 值',
brand VARCHAR(128) DEFAULT NULL COMMENT '品牌名称',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
UNIQUE KEY uk_data_value (data_value),
KEY idx_brand (brand),
KEY idx_created_at (created_at)
) COMMENT='不符合ASIN数据表';
INSERT INTO `columns` (`name`, `column_key`, `menu_type`, `route_path`, `sort_order`)
SELECT '不符合ASIN数据', 'admin_invalid_asin_data', 'admin', 'invalid-asin-data', 35
WHERE NOT EXISTS (
SELECT 1 FROM `columns` WHERE `column_key` = 'admin_invalid_asin_data'
);
UPDATE `columns`
SET `name` = '不符合ASIN数据',
`menu_type` = 'admin',
`route_path` = 'invalid-asin-data',
`sort_order` = 35
WHERE `column_key` = 'admin_invalid_asin_data';

View File

@@ -0,0 +1,30 @@
-- 采集数据模块:复用通用任务表 biz_file_task / biz_file_resultmodule_type = COLLECT_DATA
-- 解析后的明细行入库到 biz_collect_data_item便于 Python 端按页50/页)拉取。
-- 筛选条件金额、排名、FBA、FBM、国家列表以 JSON 形式存放在 biz_file_task.request_json 中。
CREATE TABLE IF NOT EXISTS biz_collect_data_item (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
task_id BIGINT NOT NULL COMMENT '所属任务 IDbiz_file_task.id',
row_index INT NOT NULL COMMENT '行号,从 1 开始,按解析顺序递增',
source_file_key VARCHAR(255) DEFAULT NULL COMMENT '来源文件 key',
source_filename VARCHAR(255) DEFAULT NULL COMMENT '来源文件名',
keyword VARCHAR(512) DEFAULT NULL COMMENT '关键词列内容',
status_value VARCHAR(128) DEFAULT NULL COMMENT '状态列内容',
extra_json TEXT DEFAULT NULL COMMENT '其它列内容 JSON',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
UNIQUE KEY uk_task_row (task_id, row_index),
KEY idx_task_id (task_id)
) COMMENT='采集数据解析明细';
-- 前端栏目权限兜底:采集数据属于前端工具下的页面。
INSERT INTO `columns` (`name`, `column_key`, `menu_type`, `route_path`, `sort_order`)
SELECT '采集数据', 'collect_data', 'app', 'collect-data', 105
WHERE NOT EXISTS (
SELECT 1 FROM `columns` WHERE `column_key` = 'collect_data'
);
UPDATE `columns`
SET `name` = '采集数据',
`menu_type` = 'app',
`route_path` = 'collect-data',
`sort_order` = 105
WHERE `column_key` = 'collect_data';

View File

@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS biz_collect_data_country_pref (
user_id BIGINT NOT NULL PRIMARY KEY COMMENT '用户 ID',
country_codes_json VARCHAR(256) NOT NULL COMMENT 'JSON 数组:五国代码按处理顺序保存,如 ["DE","UK","FR"]',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
) COMMENT='采集数据用户五国处理顺序与勾选配置';

View File

@@ -78,6 +78,11 @@ ADMIN_MENU_ACCESS_CONFIG = {
'route_path': 'product-categories',
'error': '无权访问商品类目模块',
},
'invalid-asin-data': {
'column_key': 'admin_invalid_asin_data',
'route_path': 'invalid-asin-data',
'error': '无权访问不符合ASIN数据模块',
},
}
ADMIN_MENU_ACCESS_CONFIG.update({
@@ -1760,6 +1765,115 @@ def delete_dedupe_total_data(item_id):
})
# ---------- 不符合 ASIN 数据 ----------
def _format_invalid_asin_data_item(item):
return {
'id': item.get('id'),
'data_value': item.get('dataValue') or '',
'brand': item.get('brand') or '',
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
}
@admin_api.route('/invalid-asin-data')
@admin_required
def list_invalid_asin_data():
_, _, denied = _ensure_admin_menu_access('invalid-asin-data')
if denied:
return denied
page = max(1, int(request.args.get('page', 1)))
page_size = min(100, max(1, int(request.args.get('page_size', 15))))
keyword = (request.args.get('keyword') or '').strip()
data, error_response, status = _proxy_backend_java(
'GET',
'/api/admin/invalid-asin-data',
params={'page': page, 'pageSize': page_size, 'keyword': keyword},
)
if error_response is not None:
return error_response, status
payload = data.get('data') or {}
items = [_format_invalid_asin_data_item(item) for item in (payload.get('items') or [])]
return jsonify({
'success': True,
'items': items,
'total': payload.get('total') or 0,
'page': payload.get('page') or page,
'page_size': payload.get('pageSize') or page_size,
})
@admin_api.route('/invalid-asin-data', methods=['POST'])
@admin_required
def create_invalid_asin_data():
_, _, denied = _ensure_admin_menu_access('invalid-asin-data')
if denied:
return denied
data = request.get_json() or {}
payload = {
'dataValue': (data.get('data_value') or '').strip(),
'brand': (data.get('brand') or '').strip(),
}
result, error_response, status = _proxy_backend_java(
'POST',
'/api/admin/invalid-asin-data',
json_data=payload,
)
if error_response is not None:
return error_response, status
item = result.get('data') or {}
return jsonify({
'success': True,
'msg': result.get('message') or '创建成功',
'item': _format_invalid_asin_data_item(item),
})
@admin_api.route('/invalid-asin-data/<int:item_id>', methods=['PUT'])
@admin_required
def update_invalid_asin_data(item_id):
_, _, denied = _ensure_admin_menu_access('invalid-asin-data')
if denied:
return denied
data = request.get_json() or {}
payload = {
'dataValue': (data.get('data_value') or '').strip(),
'brand': (data.get('brand') or '').strip(),
}
result, error_response, status = _proxy_backend_java(
'PUT',
f'/api/admin/invalid-asin-data/{item_id}',
json_data=payload,
)
if error_response is not None:
return error_response, status
item = result.get('data') or {}
return jsonify({
'success': True,
'msg': result.get('message') or '更新成功',
'item': _format_invalid_asin_data_item(item),
})
@admin_api.route('/invalid-asin-data/<int:item_id>', methods=['DELETE'])
@admin_required
def delete_invalid_asin_data(item_id):
_, _, denied = _ensure_admin_menu_access('invalid-asin-data')
if denied:
return denied
result, error_response, status = _proxy_backend_java(
'DELETE',
f'/api/admin/invalid-asin-data/{item_id}',
)
if error_response is not None:
return error_response, status
return jsonify({
'success': True,
'msg': result.get('message') or '删除成功',
})
# ---------- 店铺管理 ----------
def _format_shop_manage_item(item):

View File

@@ -101,6 +101,7 @@
'users': 'panel-users',
'columns': 'panel-columns',
'dedupe-total-data': 'panel-dedupe-total-data',
'invalid-asin-data': 'panel-invalid-asin-data',
'shop-keys': 'panel-shop-keys',
'shop-manage': 'panel-shop-manage',
'skip-price-asin': 'panel-skip-price-asin',
@@ -113,6 +114,7 @@
if (tabName === 'users') { loadUsers(1); loadColumnsForPermission(); }
else if (tabName === 'columns') loadColumns();
else if (tabName === 'dedupe-total-data') loadDedupeTotalData(1);
else if (tabName === 'invalid-asin-data') loadInvalidAsinData(1);
else if (tabName === 'shop-keys') loadShopKeys(1);
else if (tabName === 'shop-manage') loadShopManage(1);
else if (tabName === 'skip-price-asin') loadSkipPriceAsin(1);
@@ -1165,6 +1167,135 @@
document.getElementById('editDedupeTotalDataModal').classList.remove('show');
};
// ========== 不符合ASIN数据 ==========
var invalidAsinDataPage = 1, invalidAsinDataPageSize = 15;
function buildInvalidAsinDataQuery(page) {
var q = 'page=' + (page || 1) + '&page_size=' + invalidAsinDataPageSize;
var keyword = (document.getElementById('searchInvalidAsinData').value || '').trim();
if (keyword) q += '&keyword=' + encodeURIComponent(keyword);
return q;
}
function loadInvalidAsinData(page) {
invalidAsinDataPage = page || 1;
fetch('/api/admin/invalid-asin-data?' + buildInvalidAsinDataQuery(invalidAsinDataPage))
.then(function (r) { return r.json(); })
.then(function (res) {
var tbody = document.getElementById('invalidAsinDataListBody');
if (!res.success) {
tbody.innerHTML = '<tr><td colspan="5" class="empty-tip">加载失败: ' + (res.error || '') + '</td></tr>';
return;
}
var items = res.items || [];
if (items.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="empty-tip">暂无数据</td></tr>';
} else {
tbody.innerHTML = items.map(function (item) {
var dataValueAttr = (item.data_value || '').replace(/"/g, '&quot;');
var brandAttr = (item.brand || '').replace(/"/g, '&quot;');
return '<tr><td>' + item.id + '</td><td>' + (item.data_value || '') + '</td><td>' + (item.brand || '') + '</td><td>' + (item.created_at || '') + '</td><td>' +
'<button class="btn btn-sm" data-invalid-asin-edit="' + item.id + '" data-value="' + dataValueAttr + '" data-brand="' + brandAttr + '">编辑</button> ' +
'<button class="btn btn-sm btn-danger" data-invalid-asin-delete="' + item.id + '" data-value="' + dataValueAttr + '">删除</button>' +
'</td></tr>';
}).join('');
}
renderPagination('invalidAsinDataPagination', res.total, res.page, res.page_size, loadInvalidAsinData);
bindInvalidAsinDataActions();
})
.catch(function () {
document.getElementById('invalidAsinDataListBody').innerHTML = '<tr><td colspan="5" class="empty-tip">请求失败</td></tr>';
});
}
function bindInvalidAsinDataActions() {
document.querySelectorAll('[data-invalid-asin-edit]').forEach(function (btn) {
btn.onclick = function () {
document.getElementById('editInvalidAsinDataId').value = btn.dataset.invalidAsinEdit || '';
document.getElementById('editInvalidAsinDataValue').value = (btn.dataset.value || '').replace(/&quot;/g, '"');
document.getElementById('editInvalidAsinDataBrand').value = (btn.dataset.brand || '').replace(/&quot;/g, '"');
document.getElementById('msgEditInvalidAsinData').textContent = '';
document.getElementById('msgEditInvalidAsinData').className = 'msg';
document.getElementById('editInvalidAsinDataModal').classList.add('show');
};
});
document.querySelectorAll('[data-invalid-asin-delete]').forEach(function (btn) {
btn.onclick = function () {
var value = (btn.dataset.value || '').replace(/&quot;/g, '"');
if (!confirm('确定删除“' + value + '”吗?')) return;
fetch('/api/admin/invalid-asin-data/' + btn.dataset.invalidAsinDelete, { method: 'DELETE' })
.then(function (r) { return r.json(); })
.then(function (res) {
if (res.success) { loadInvalidAsinData(invalidAsinDataPage); }
else { alert(res.error || '删除失败'); }
});
};
});
}
document.getElementById('btnSearchInvalidAsinData').onclick = function () { loadInvalidAsinData(1); };
document.getElementById('btnAddInvalidAsinData').onclick = function () {
var dataValue = (document.getElementById('invalidAsinDataValue').value || '').trim();
var brand = (document.getElementById('invalidAsinDataBrand').value || '').trim();
var msgEl = document.getElementById('msgInvalidAsinData');
msgEl.textContent = '';
msgEl.className = 'msg';
if (!dataValue) {
msgEl.textContent = '请填写 ASIN';
msgEl.classList.add('err');
return;
}
fetch('/api/admin/invalid-asin-data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data_value: dataValue, brand: brand })
})
.then(function (r) { return r.json(); })
.then(function (res) {
if (res.success) {
msgEl.textContent = res.msg || '创建成功';
msgEl.classList.add('ok');
document.getElementById('invalidAsinDataValue').value = '';
document.getElementById('invalidAsinDataBrand').value = '';
loadInvalidAsinData(1);
} else {
msgEl.textContent = res.error || '创建失败';
msgEl.classList.add('err');
}
})
.catch(function () {
msgEl.textContent = '请求失败';
msgEl.classList.add('err');
});
};
document.getElementById('btnSaveInvalidAsinData').onclick = function () {
var itemId = document.getElementById('editInvalidAsinDataId').value;
var dataValue = (document.getElementById('editInvalidAsinDataValue').value || '').trim();
var brand = (document.getElementById('editInvalidAsinDataBrand').value || '').trim();
var msgEl = document.getElementById('msgEditInvalidAsinData');
msgEl.textContent = '';
msgEl.className = 'msg';
if (!dataValue) {
msgEl.textContent = '请填写 ASIN';
msgEl.classList.add('err');
return;
}
fetch('/api/admin/invalid-asin-data/' + itemId, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data_value: dataValue, brand: brand })
})
.then(function (r) { return r.json(); })
.then(function (res) {
if (res.success) {
document.getElementById('editInvalidAsinDataModal').classList.remove('show');
loadInvalidAsinData(invalidAsinDataPage);
} else {
msgEl.textContent = res.error || '保存失败';
msgEl.classList.add('err');
}
});
};
document.getElementById('btnCloseEditInvalidAsinData').onclick = function () {
document.getElementById('editInvalidAsinDataModal').classList.remove('show');
};
// ========== 店铺密钥管理 ==========
var shopKeyPage = 1, shopKeyPageSize = 15;
function buildShopKeyQuery(page) {

View File

@@ -786,6 +786,7 @@
<div class="tab active" data-tab="users">用户管理</div>
<div class="tab" data-tab="columns">栏目权限配置</div>
<div class="tab" data-tab="dedupe-total-data">数据去重总数据</div>
<div class="tab" data-tab="invalid-asin-data">不符合ASIN数据</div>
<div class="tab" data-tab="shop-keys">店铺密钥管理</div>
<div class="tab" data-tab="shop-manage">店铺管理</div>
<div class="tab" data-tab="skip-price-asin">跳过跟价 ASIN</div>
@@ -1010,6 +1011,48 @@
</div>
</div>
<!-- 不符合ASIN数据 -->
<div id="panel-invalid-asin-data" class="tab-panel">
<div class="form-box">
<h3 style="margin-bottom:16px;font-size:15px;">新增不符合ASIN数据</h3>
<div class="form-row">
<div class="form-group" style="min-width:220px;">
<label>ASIN</label>
<input type="text" id="invalidAsinDataValue" placeholder="请输入 ASIN">
</div>
<div class="form-group" style="min-width:220px;">
<label>品牌</label>
<input type="text" id="invalidAsinDataBrand" placeholder="请输入品牌(可选)">
</div>
<button class="btn" id="btnAddInvalidAsinData">新增</button>
</div>
<p class="msg" id="msgInvalidAsinData"></p>
</div>
<div class="panel-box">
<h3 style="margin-bottom:16px;font-size:15px;">不符合ASIN数据列表</h3>
<div class="form-row" style="margin-bottom:16px;">
<div class="form-group" style="min-width:220px;">
<label>ASIN / 品牌(模糊搜索)</label>
<input type="text" id="searchInvalidAsinData" placeholder="输入关键字">
</div>
<button class=" btn" id="btnSearchInvalidAsinData">查询</button>
</div>
<table>
<thead>
<tr>
<th>ID</th>
<th>ASIN</th>
<th>品牌</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="invalidAsinDataListBody"></tbody>
</table>
<div class="pagination" id="invalidAsinDataPagination"></div>
</div>
</div>
<!-- 店铺密钥管理 -->
<div id="panel-shop-keys" class="tab-panel">
<div class="form-box">
@@ -1494,6 +1537,27 @@
</div>
</div>
<!-- 编辑不符合ASIN数据弹窗 -->
<div class="modal-mask" id="editInvalidAsinDataModal">
<div class="modal">
<h3>编辑不符合ASIN数据</h3>
<input type="hidden" id="editInvalidAsinDataId">
<div class="form-group">
<label>ASIN</label>
<input type="text" id="editInvalidAsinDataValue" placeholder="请输入 ASIN">
</div>
<div class="form-group">
<label>品牌</label>
<input type="text" id="editInvalidAsinDataBrand" placeholder="请输入品牌(可选)">
</div>
<p class=" msg" id="msgEditInvalidAsinData"></p>
<div style="margin-top:16px;display:flex;gap:8px;">
<button class="btn" id="btnSaveInvalidAsinData">保存</button>
<button class="btn btn-secondary" id="btnCloseEditInvalidAsinData">取消</button>
</div>
</div>
</div>
<!-- 编辑栏目弹窗 -->
<div class="modal-mask" id="editColumnModal">
<div class="modal">

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>采集数据</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/collect-data-main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,7 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import '@/styles/main.css'
import BrandCollectDataTab from '@/pages/brand/components/BrandCollectDataTab.vue'
createApp(BrandCollectDataTab).use(ElementPlus).mount('#app')

View File

@@ -5,33 +5,33 @@
<span>密钥设置</span>
</button>
<el-dialog v-model="dialogVisible" width="560px" class="secret-settings-dialog" :append-to-body="true">
<el-dialog v-model="dialogVisible" width="620px" class="secret-settings-dialog" :append-to-body="true">
<template #header>
<div class="dialog-header">
<div class="dialog-title">密钥设置</div>
<div class="dialog-subtitle">按当前登录用户保存在本机外观专利和货源查询共用同一个 Coze 接口密钥</div>
<div class="dialog-subtitle">按当前登录用户保存在本机外观专利和货源查询分别使用独立的 Coze 接口密钥</div>
</div>
</template>
<div class="secret-settings-body">
<section class="secret-card">
<section v-for="config in secretConfigs" :key="config.key" class="secret-card">
<div class="secret-card-head">
<div>
<div class="secret-card-title">通用 Coze 密钥</div>
<div class="secret-card-desc">用于外观专利检测和货源查询</div>
<div class="secret-card-title">{{ config.title }}</div>
<div class="secret-card-desc">{{ config.description }}</div>
</div>
<button
v-if="secretState.exists"
v-if="secretStates[config.key].exists"
type="button"
class="link-danger"
@click="clearSecret"
@click="clearSecret(config.key)"
>
清空
</button>
</div>
<input
v-model="secretState.value"
v-model="secretStates[config.key].value"
class="secret-input"
type="password"
placeholder="请输入 Coze 接口密钥"
@@ -43,14 +43,16 @@
<div class="retention-label">保留时长</div>
<div class="retention-options">
<label v-for="option in retentionOptions" :key="option.value" class="retention-option">
<input v-model="secretState.retention" type="radio" :value="option.value" />
<input v-model="secretStates[config.key].retention" type="radio" :value="option.value" />
<span>{{ option.label }}</span>
</label>
</div>
</div>
<div class="secret-meta">
<span v-if="secretState.exists">{{ formatRetentionText(secretState.retention, secretState.expiresAt) }}</span>
<span v-if="secretStates[config.key].exists">
{{ formatRetentionText(secretStates[config.key].retention, secretStates[config.key].expiresAt) }}
</span>
<span v-else>当前未保存</span>
</div>
</section>
@@ -73,6 +75,7 @@ import {
clearStoredApiSecret,
getStoredApiSecretSnapshot,
saveStoredApiSecret,
type ApiSecretModuleKey,
type ApiSecretRetention,
} from '@/shared/utils/api-secret-store'
@@ -93,16 +96,36 @@ const retentionOptions: Array<{ value: ApiSecretRetention; label: string }> = [
{ value: 'forever', label: '长期保留' },
]
const secretState = ref<SecretState>(emptySecretState())
const secretConfigs: Array<{ key: ApiSecretModuleKey; title: string; description: string }> = [
{
key: 'appearance-patent',
title: '外观专利密钥',
description: '仅用于外观专利检测。',
},
{
key: 'similar-asin',
title: '货源查询密钥',
description: '仅用于货源查询。',
},
]
const secretStates = ref<Record<ApiSecretModuleKey, SecretState>>({
'appearance-patent': emptySecretState(),
'similar-asin': emptySecretState(),
})
function loadStates() {
const snapshot = getStoredApiSecretSnapshot()
secretState.value = {
value: snapshot.value,
retention: snapshot.retention,
expiresAt: snapshot.expiresAt,
exists: snapshot.exists,
const nextStates = { ...secretStates.value }
for (const config of secretConfigs) {
const snapshot = getStoredApiSecretSnapshot(config.key)
nextStates[config.key] = {
value: snapshot.value,
retention: snapshot.retention,
expiresAt: snapshot.expiresAt,
exists: snapshot.exists,
}
}
secretStates.value = nextStates
}
function emptySecretState(): SecretState {
@@ -127,14 +150,17 @@ function formatRetentionText(retention: ApiSecretRetention, expiresAt: number |
return `有效期至 ${year}-${month}-${day} ${hour}:${minute}`
}
function clearSecret() {
clearStoredApiSecret()
function clearSecret(moduleKey: ApiSecretModuleKey) {
clearStoredApiSecret(moduleKey)
loadStates()
ElMessage.success('已清空密钥')
}
function saveAll() {
saveStoredApiSecret(secretState.value.value, secretState.value.retention)
for (const config of secretConfigs) {
const state = secretStates.value[config.key]
saveStoredApiSecret(config.key, state.value, state.retention)
}
loadStates()
dialogVisible.value = false
ElMessage.success('密钥设置已保存')
@@ -242,6 +268,9 @@ loadStates()
display: flex;
flex-direction: column;
gap: 14px;
max-height: 62vh;
overflow-y: auto;
padding-right: 4px;
}
.secret-card {

View File

@@ -161,6 +161,7 @@ import { getPywebviewApi } from '@/shared/bridges/pywebview'
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
import { getStoredApiSecret } from '@/shared/utils/api-secret-store'
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
const selectedFileNames = ref<string[]>([])
const uploadedFiles = ref<UploadFileVo[]>([])
@@ -257,7 +258,7 @@ function queueAiPrompt() {
}
function effectiveCozeApiKey() {
return getStoredApiSecret().trim()
return getStoredApiSecret('appearance-patent').trim()
}
function maskSecret(secret: string) {
@@ -462,6 +463,7 @@ async function pushToPythonQueue() {
id: row.displayId,
asin: row.asin,
country: row.country,
sku: row.sku || '',
url: row.url || '',
title: row.title || '',
})),
@@ -880,14 +882,9 @@ function shouldShowFallbackJobProgress(item: AppearancePatentHistoryItem) {
async function downloadResult(item: AppearancePatentHistoryItem) {
if (!item.resultId) return
const api = getPywebviewApi()
if (!api?.save_file_from_url_new) {
ElMessage.error('当前客户端未提供下载能力')
return
}
const url = item.downloadUrl || getAppearancePatentResultDownloadUrl(item.resultId)
const filename = item.resultFilename || `${item.sourceFilename || 'appearance-patent'}.xlsx`
const result = await api.save_file_from_url_new(url, filename)
const result = await saveUrlWithProgress(url, filename, `appearance-patent:${item.resultId}`)
if (result.success) {
ElMessage.success(`已保存: ${result.path || filename}`)
} else if (result.error && result.error !== '用户取消') {

Some files were not shown because too many files have changed in this diff Show More