增加公共下载进度、增加接收SKU、密钥分别存放
This commit is contained in:
4
app/.env
4
app/.env
@@ -12,8 +12,8 @@ zn_username=%E8%87%AA%E5%8A%A8%E5%8C%96_Robot
|
|||||||
client_name=ShuFuAI
|
client_name=ShuFuAI
|
||||||
|
|
||||||
# java_api_base=http://47.111.163.154:18080
|
# java_api_base=http://47.111.163.154:18080
|
||||||
# java_api_base=http://127.0.0.1:18080
|
java_api_base=http://127.0.0.1:18080
|
||||||
java_api_base=http://121.196.149.225:18080
|
# java_api_base=http://121.196.149.225:18080
|
||||||
|
|
||||||
# 与 Java 后端共享的 JWT 签名密钥,必须与 backend-java 的 AIIMAGE_JWT_SECRET 完全一致
|
# 与 Java 后端共享的 JWT 签名密钥,必须与 backend-java 的 AIIMAGE_JWT_SECRET 完全一致
|
||||||
AIIMAGE_JWT_SECRET=please-change-this-secret-please-rotate-at-least-32-bytes
|
AIIMAGE_JWT_SECRET=please-change-this-secret-please-rotate-at-least-32-bytes
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
1
app/assets/appearance-patent-efxpNAXi.css
Normal file
1
app/assets/appearance-patent-efxpNAXi.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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};
|
|
||||||
1
app/assets/brand-jr47vQUd.js
Normal file
1
app/assets/brand-jr47vQUd.js
Normal 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};
|
||||||
1
app/assets/collect-data-6h7Mh27f.css
Normal file
1
app/assets/collect-data-6h7Mh27f.css
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/collect-data.js
Normal file
1
app/assets/collect-data.js
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/convert-6oxZNMye.css
Normal file
1
app/assets/convert-6oxZNMye.css
Normal file
File diff suppressed because one or more lines are too long
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
1
app/assets/dedupe-DpfPYQDv.css
Normal file
1
app/assets/dedupe-DpfPYQDv.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
app/assets/delete-brand-DZ3x5hOX.css
Normal file
1
app/assets/delete-brand-DZ3x5hOX.css
Normal file
File diff suppressed because one or more lines are too long
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
1
app/assets/patrol-delete-Cqhg2qXm.css
Normal file
1
app/assets/patrol-delete-Cqhg2qXm.css
Normal file
File diff suppressed because one or more lines are too long
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
1
app/assets/product-risk-BQIMXTON.css
Normal file
1
app/assets/product-risk-BQIMXTON.css
Normal file
File diff suppressed because one or more lines are too long
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
1
app/assets/query-asin-CECO5lUB.css
Normal file
1
app/assets/query-asin-CECO5lUB.css
Normal file
File diff suppressed because one or more lines are too long
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
@@ -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))}}
|
|
||||||
1
app/assets/similar-asin-CVYG_iIM.css
Normal file
1
app/assets/similar-asin-CVYG_iIM.css
Normal 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
1
app/assets/split-CR1PtDQE.css
Normal file
1
app/assets/split-CR1PtDQE.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
app/assets/zh-cn-z8KAT-CE.js
Normal file
1
app/assets/zh-cn-z8KAT-CE.js
Normal file
File diff suppressed because one or more lines are too long
128
app/main.py
128
app/main.py
@@ -4,6 +4,8 @@ import json
|
|||||||
import sys
|
import sys
|
||||||
import shutil
|
import shutil
|
||||||
import os
|
import os
|
||||||
|
import uuid
|
||||||
|
import ctypes
|
||||||
os.makedirs(cache_path,exist_ok=True)
|
os.makedirs(cache_path,exist_ok=True)
|
||||||
if not debug:
|
if not debug:
|
||||||
today = datetime.datetime.now().strftime("%Y_%m_%d")
|
today = datetime.datetime.now().strftime("%Y_%m_%d")
|
||||||
@@ -186,17 +188,7 @@ class WindowAPI:
|
|||||||
if not result:
|
if not result:
|
||||||
return {'success': False, 'error': '用户取消'}
|
return {'success': False, 'error': '用户取消'}
|
||||||
path = result[0] if isinstance(result, (list, tuple)) else result
|
path = result[0] if isinstance(result, (list, tuple)) else result
|
||||||
try:
|
return self._download_url_to_path(url, path)
|
||||||
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)}
|
|
||||||
|
|
||||||
def save_file_from_url_new(self, url, default_filename='download.bin'):
|
def save_file_from_url_new(self, url, default_filename='download.bin'):
|
||||||
"""弹窗选择保存位置,从 url 下载文件并保存。根据文件后缀动态设置保存类型。"""
|
"""弹窗选择保存位置,从 url 下载文件并保存。根据文件后缀动态设置保存类型。"""
|
||||||
@@ -230,18 +222,124 @@ class WindowAPI:
|
|||||||
return {'success': False, 'error': '用户取消'}
|
return {'success': False, 'error': '用户取消'}
|
||||||
|
|
||||||
path = result[0] if isinstance(result, (list, tuple)) else result
|
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:
|
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 = requests.get(url.strip(), timeout=120, stream=True)
|
||||||
resp.raise_for_status()
|
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):
|
for chunk in resp.iter_content(chunk_size=65536):
|
||||||
if chunk:
|
if chunk:
|
||||||
f.write(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:
|
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)}
|
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):
|
def save_template_xlsx(self):
|
||||||
"""弹窗选择保存位置,将品牌文档格式模板 xlsx 保存到用户选择的位置。"""
|
"""弹窗选择保存位置,将品牌文档格式模板 xlsx 保存到用户选择的位置。"""
|
||||||
template_name = '品牌文档格式_模板.xlsx'
|
template_name = '品牌文档格式_模板.xlsx'
|
||||||
@@ -440,7 +538,7 @@ def main():
|
|||||||
|
|
||||||
api = WindowAPI(window)
|
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,
|
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(
|
webview.start(
|
||||||
debug=True,
|
debug=True,
|
||||||
storage_path=cache_path,
|
storage_path=cache_path,
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>格式转换 - 数富AI</title>
|
<title>格式转换 - 数富AI</title>
|
||||||
<script type="module" crossorigin src="/assets/convert.js"></script>
|
<script type="module" crossorigin src="/assets/convert.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-3bFwh9Cg.js">
|
<link rel="modulepreload" crossorigin href="/assets/java-modules-DzYrjxib.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/brand-D__LhhPM.js">
|
<link rel="modulepreload" crossorigin href="/assets/brand-jr47vQUd.js">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-B7UKcPVJ.css">
|
<link rel="stylesheet" crossorigin href="/assets/java-modules-l5anrOZ2.css">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/convert-BKSNvX8i.css">
|
<link rel="stylesheet" crossorigin href="/assets/convert-6oxZNMye.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>数据去重 - 数富AI</title>
|
<title>数据去重 - 数富AI</title>
|
||||||
<script type="module" crossorigin src="/assets/dedupe.js"></script>
|
<script type="module" crossorigin src="/assets/dedupe.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-3bFwh9Cg.js">
|
<link rel="modulepreload" crossorigin href="/assets/java-modules-DzYrjxib.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/brand-D__LhhPM.js">
|
<link rel="modulepreload" crossorigin href="/assets/brand-jr47vQUd.js">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-B7UKcPVJ.css">
|
<link rel="stylesheet" crossorigin href="/assets/java-modules-l5anrOZ2.css">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/dedupe-DNlVfFj-.css">
|
<link rel="stylesheet" crossorigin href="/assets/dedupe-DpfPYQDv.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -5,11 +5,11 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>删除品牌 - 数富AI</title>
|
<title>删除品牌 - 数富AI</title>
|
||||||
<script type="module" crossorigin src="/assets/delete-brand.js"></script>
|
<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/java-modules-DzYrjxib.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/brand-D__LhhPM.js">
|
<link rel="modulepreload" crossorigin href="/assets/brand-jr47vQUd.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/categorized-timers-JPA-olTr.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/java-modules-l5anrOZ2.css">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/delete-brand-BJod9K65.css">
|
<link rel="stylesheet" crossorigin href="/assets/delete-brand-DZ3x5hOX.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>数据拆分 - 数富AI</title>
|
<title>数据拆分 - 数富AI</title>
|
||||||
<script type="module" crossorigin src="/assets/split.js"></script>
|
<script type="module" crossorigin src="/assets/split.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-3bFwh9Cg.js">
|
<link rel="modulepreload" crossorigin href="/assets/java-modules-DzYrjxib.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/brand-D__LhhPM.js">
|
<link rel="modulepreload" crossorigin href="/assets/brand-jr47vQUd.js">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-B7UKcPVJ.css">
|
<link rel="stylesheet" crossorigin href="/assets/java-modules-l5anrOZ2.css">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/split-BoBVrLdC.css">
|
<link rel="stylesheet" crossorigin href="/assets/split-CR1PtDQE.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ import java.util.function.Predicate;
|
|||||||
|
|
||||||
public final class FailedStatusRowFilter {
|
public final class FailedStatusRowFilter {
|
||||||
|
|
||||||
private static final String STATUS_HEADER_CN = "\u72b6\u6001";
|
private static final String STATUS_HEADER_CN = "状态";
|
||||||
private static final String FAILED_STATUS_ERROR = "\u9519\u8bef";
|
private static final String FAILED_STATUS_ERROR = "错误";
|
||||||
private static final String FAILED_STATUS_FAILED_CN = "\u5931\u8d25";
|
private static final String FAILED_STATUS_FAILED_CN = "失败";
|
||||||
private static final String FAILED_STATUS_FAILED_EN = "failed";
|
private static final String FAILED_STATUS_FAILED_EN = "failed";
|
||||||
private static final String NO_MATCHED_ROWS_MESSAGE =
|
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(
|
private static final Set<String> STATUS_HEADER_ALIASES = Set.of(
|
||||||
normalizeHeader(STATUS_HEADER_CN),
|
normalizeHeader(STATUS_HEADER_CN),
|
||||||
@@ -56,8 +56,8 @@ public final class FailedStatusRowFilter {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 暴露表头归一化逻辑给其它模块复用,避免每个模块各自维护一份规则。
|
* 暴露表头归一化逻辑给其它模块复用,避免每个模块各自维护一份规则。
|
||||||
* 处理:去 BOM、全角空格转半角、首尾 trim、连续空白合一、转小写,
|
* 处理:去 BOM、全角空格转半角、首尾 trim、连续空白合并、转小写,
|
||||||
* 并剥除常见分隔符(含中文全角括号、方括号、冒号)。
|
* 并剔除常见分隔符(含中文全角括号、方括号、冒号)。
|
||||||
*/
|
*/
|
||||||
public static String canonicalizeHeader(String value) {
|
public static String canonicalizeHeader(String value) {
|
||||||
return normalizeHeader(value);
|
return normalizeHeader(value);
|
||||||
@@ -92,8 +92,8 @@ public final class FailedStatusRowFilter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static String normalizeHeader(String value) {
|
private static String normalizeHeader(String value) {
|
||||||
// 扩展:兼容中文全角括号 ()、中文方括号 【】、中文冒号 :,避免
|
// 兼容中文全角括号()、中文方括号【】、中文冒号:,
|
||||||
// "状态(结果)"、"标题维度(商标)" 等全角括号表头被识别失败。
|
// 避免“状态(结果)”“标题维度(商标)”等表头识别失败。
|
||||||
return normalizeValue(value).replaceAll("[\\s_\\-()\\[\\]{}::()【】]+", "");
|
return normalizeValue(value).replaceAll("[\\s_\\-()\\[\\]{}::()【】]+", "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -355,13 +355,14 @@ public class AppearancePatentCozeClient {
|
|||||||
List<String> rowIds = rows.stream().map(row -> nonBlank(row.getId(), "")).toList();
|
List<String> rowIds = rows.stream().map(row -> nonBlank(row.getId(), "")).toList();
|
||||||
List<String> asins = rows.stream().map(row -> nonBlank(row.getAsin(), "")).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> 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> titles = rows.stream().map(row -> nonBlank(row.getTitle(), row.getAsin())).toList();
|
||||||
List<String> urls = rows.stream().map(row -> nonBlank(row.getUrl(), "")).toList();
|
List<String> urls = rows.stream().map(row -> nonBlank(row.getUrl(), "")).toList();
|
||||||
|
|
||||||
Map<String, Object> parameters = new LinkedHashMap<>();
|
Map<String, Object> parameters = new LinkedHashMap<>();
|
||||||
parameters.put("title_list", titles);
|
parameters.put("title_list", titles);
|
||||||
parameters.put("url_list", urls);
|
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 透传该字段,避免无关默认提示词污染工作流。
|
// 前端没填 prompt 就一律不向 Coze 透传该字段,避免无关默认提示词污染工作流。
|
||||||
if (prompt != null && !prompt.isBlank()) {
|
if (prompt != null && !prompt.isBlank()) {
|
||||||
parameters.put("prompt", prompt);
|
parameters.put("prompt", prompt);
|
||||||
@@ -403,6 +404,7 @@ public class AppearancePatentCozeClient {
|
|||||||
List<String> rowIds,
|
List<String> rowIds,
|
||||||
List<String> asins,
|
List<String> asins,
|
||||||
List<String> countries,
|
List<String> countries,
|
||||||
|
List<String> skus,
|
||||||
List<String> titles,
|
List<String> titles,
|
||||||
List<String> urls) {
|
List<String> urls) {
|
||||||
List<Map<String, Object>> items = new ArrayList<>(rows.size());
|
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("row_id", rowIds.get(i));
|
||||||
item.put("asin", asins.get(i));
|
item.put("asin", asins.get(i));
|
||||||
item.put("country", countries.get(i));
|
item.put("country", countries.get(i));
|
||||||
|
item.put("sku", skus.get(i));
|
||||||
item.put("title", titles.get(i));
|
item.put("title", titles.get(i));
|
||||||
item.put("url", urls.get(i));
|
item.put("url", urls.get(i));
|
||||||
items.add(item);
|
items.add(item);
|
||||||
@@ -591,6 +594,7 @@ public class AppearancePatentCozeClient {
|
|||||||
row.setId(source.getId());
|
row.setId(source.getId());
|
||||||
row.setAsin(source.getAsin());
|
row.setAsin(source.getAsin());
|
||||||
row.setCountry(source.getCountry());
|
row.setCountry(source.getCountry());
|
||||||
|
row.setSku(source.getSku());
|
||||||
row.setUrl(source.getUrl());
|
row.setUrl(source.getUrl());
|
||||||
row.setTitle(source.getTitle());
|
row.setTitle(source.getTitle());
|
||||||
row.setError(source.getError());
|
row.setError(source.getError());
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ public class AppearancePatentResultRowDto {
|
|||||||
@Schema(description = "站点或国家。来自 Excel 的国家列,例如英国、德国、法国。", example = "英国")
|
@Schema(description = "站点或国家。来自 Excel 的国家列,例如英国、德国、法国。", example = "英国")
|
||||||
private String country;
|
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")
|
@Schema(description = "商品主图或待检测图片 URL。Java 调用 Coze 时会放入 url_list。", example = "https://webstatic.aiproxy.vip/output/20260425/103322/demo.jpg")
|
||||||
@JsonAlias({
|
@JsonAlias({
|
||||||
"imageUrl", "image_url", "imgUrl", "img_url", "pictureUrl", "picture_url",
|
"imageUrl", "image_url", "imgUrl", "img_url", "pictureUrl", "picture_url",
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ public class AppearancePatentParsedRowVo {
|
|||||||
@Schema(description = "价格。", example = "12.99")
|
@Schema(description = "价格。", example = "12.99")
|
||||||
private String price;
|
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")
|
@Schema(description = "商品图片 URL 或商品 URL,供 Coze 检测使用。", example = "https://webstatic.aiproxy.vip/output/demo.jpg")
|
||||||
private String url;
|
private String url;
|
||||||
|
|
||||||
|
|||||||
@@ -1255,6 +1255,7 @@ public class AppearancePatentTaskService {
|
|||||||
row.setId(sibling.getDisplayId());
|
row.setId(sibling.getDisplayId());
|
||||||
row.setAsin(sibling.getAsin());
|
row.setAsin(sibling.getAsin());
|
||||||
row.setCountry(sibling.getCountry());
|
row.setCountry(sibling.getCountry());
|
||||||
|
row.setSku(firstNonBlank(representative.getSku(), sibling.getSku()));
|
||||||
row.setUrl(firstNonBlank(representative.getUrl(), sibling.getUrl()));
|
row.setUrl(firstNonBlank(representative.getUrl(), sibling.getUrl()));
|
||||||
row.setTitle(firstNonBlank(representative.getTitle(), sibling.getTitle()));
|
row.setTitle(firstNonBlank(representative.getTitle(), sibling.getTitle()));
|
||||||
row.setError(representative.getError());
|
row.setError(representative.getError());
|
||||||
@@ -2178,8 +2179,8 @@ public class AppearancePatentTaskService {
|
|||||||
|| normalized.contains("execution limit")
|
|| normalized.contains("execution limit")
|
||||||
|| normalized.contains("720712008")
|
|| normalized.contains("720712008")
|
||||||
|| normalized.contains("720701002")
|
|| normalized.contains("720701002")
|
||||||
|| normalized.contains("\u5de5\u4f5c\u6d41\u8282\u70b9\u6267\u884c\u8d85\u9650")
|
|| normalized.contains("工作流节点执行超限")
|
||||||
|| normalized.contains("\u8c03\u7528\u8d85\u65f6");
|
|| normalized.contains("调用超时");
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isRetryableCozeFailure(String failureMessage) {
|
private boolean isRetryableCozeFailure(String failureMessage) {
|
||||||
@@ -2197,10 +2198,10 @@ public class AppearancePatentTaskService {
|
|||||||
|| normalized.contains("720712008")
|
|| normalized.contains("720712008")
|
||||||
|| normalized.contains("720701002")
|
|| normalized.contains("720701002")
|
||||||
|| normalized.contains("plugin limit")
|
|| normalized.contains("plugin limit")
|
||||||
|| normalized.contains("\u9650\u6d41")
|
|| normalized.contains("限流")
|
||||||
|| normalized.contains("\u7a0d\u540e\u91cd\u8bd5")
|
|| normalized.contains("稍后重试")
|
||||||
|| normalized.contains("\u5de5\u4f5c\u6d41\u8282\u70b9\u6267\u884c\u8d85\u9650")
|
|| normalized.contains("工作流节点执行超限")
|
||||||
|| normalized.contains("\u8c03\u7528\u8d85\u65f6");
|
|| normalized.contains("调用超时");
|
||||||
}
|
}
|
||||||
|
|
||||||
private void updateCozeStateRunning(TaskScopeStateEntity state, String error) {
|
private void updateCozeStateRunning(TaskScopeStateEntity state, String error) {
|
||||||
@@ -3379,6 +3380,8 @@ public class AppearancePatentTaskService {
|
|||||||
int asinCol = findRequiredHeader(headerMap, "asin");
|
int asinCol = findRequiredHeader(headerMap, "asin");
|
||||||
int countryCol = findRequiredHeader(headerMap, "国家", "country");
|
int countryCol = findRequiredHeader(headerMap, "国家", "country");
|
||||||
int priceCol = findOptionalHeaderExact(headerMap, "价格", "price");
|
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,
|
int urlCol = findOptionalHeaderExact(headerMap,
|
||||||
"url", "rul", "link", "image", "img", "pic", "picture",
|
"url", "rul", "link", "image", "img", "pic", "picture",
|
||||||
"链接", "商品链接", "图片", "商品图片", "主图", "商品主图", "图片链接", "主图链接");
|
"链接", "商品链接", "图片", "商品图片", "主图", "商品主图", "图片链接", "主图链接");
|
||||||
@@ -3426,6 +3429,7 @@ public class AppearancePatentTaskService {
|
|||||||
vo.setAsin(asin);
|
vo.setAsin(asin);
|
||||||
vo.setCountry(country);
|
vo.setCountry(country);
|
||||||
vo.setPrice(priceCol >= 0 ? cell(row, priceCol, formatter) : "");
|
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.setUrl(urlCol >= 0 ? cell(row, urlCol, formatter) : "");
|
||||||
vo.setTitle(titleCol >= 0 ? cell(row, titleCol, formatter) : "");
|
vo.setTitle(titleCol >= 0 ? cell(row, titleCol, formatter) : "");
|
||||||
parsedRows.add(new ParsedAppearanceRow(vo, statusCol >= 0 ? cell(row, statusCol, 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()) {
|
for (List<AppearancePatentParsedRowVo> siblings : rowsByBaseId.values()) {
|
||||||
String title = "";
|
String title = "";
|
||||||
String url = "";
|
String url = "";
|
||||||
|
String sku = "";
|
||||||
for (AppearancePatentParsedRowVo sibling : siblings) {
|
for (AppearancePatentParsedRowVo sibling : siblings) {
|
||||||
title = firstNonBlank(title, sibling.getTitle());
|
title = firstNonBlank(title, sibling.getTitle());
|
||||||
url = firstNonBlank(url, sibling.getUrl());
|
url = firstNonBlank(url, sibling.getUrl());
|
||||||
|
sku = firstNonBlank(sku, sibling.getSku());
|
||||||
}
|
}
|
||||||
for (AppearancePatentParsedRowVo sibling : siblings) {
|
for (AppearancePatentParsedRowVo sibling : siblings) {
|
||||||
if (normalize(sibling.getTitle()).isBlank()) {
|
if (normalize(sibling.getTitle()).isBlank()) {
|
||||||
@@ -3481,6 +3487,9 @@ public class AppearancePatentTaskService {
|
|||||||
if (normalize(sibling.getUrl()).isBlank()) {
|
if (normalize(sibling.getUrl()).isBlank()) {
|
||||||
sibling.setUrl(url);
|
sibling.setUrl(url);
|
||||||
}
|
}
|
||||||
|
if (normalize(sibling.getSku()).isBlank()) {
|
||||||
|
sibling.setSku(sku);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package com.nanri.aiimage.modules.collectdata.controller;
|
||||||
|
|
||||||
|
import com.nanri.aiimage.common.api.ApiResponse;
|
||||||
|
import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataCountryPreferenceSaveRequest;
|
||||||
|
import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataParseRequest;
|
||||||
|
import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataTaskBatchRequest;
|
||||||
|
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataCountryPreferenceVo;
|
||||||
|
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataDashboardVo;
|
||||||
|
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataHistoryVo;
|
||||||
|
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataItemsPageVo;
|
||||||
|
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataParseVo;
|
||||||
|
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataTaskBatchVo;
|
||||||
|
import com.nanri.aiimage.modules.collectdata.service.CollectDataService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@RequestMapping("/api/collect-data")
|
||||||
|
@Tag(name = "采集数据", description = "采集数据任务接口。前端上传 Excel 后由 Java 解析并落库;Python 端按页拉取明细数据进行采集。")
|
||||||
|
public class CollectDataController {
|
||||||
|
|
||||||
|
private final CollectDataService service;
|
||||||
|
|
||||||
|
@PostMapping("/parse")
|
||||||
|
@Operation(summary = "解析 Excel 并创建任务", description = "解析上传后的 Excel 文件,按行入库到 biz_collect_data_item,并保存任务筛选条件。任务初始状态为 PENDING。")
|
||||||
|
public ApiResponse<CollectDataParseVo> parse(@Valid @RequestBody CollectDataParseRequest request) {
|
||||||
|
return ApiResponse.success(service.parseAndCreateTask(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/tasks/{taskId}/activate")
|
||||||
|
@Operation(summary = "激活任务", description = "前端推送 Python 队列前调用,将任务状态从 PENDING 修改为 RUNNING,表示已交给 Python 处理。")
|
||||||
|
public ApiResponse<Void> activate(
|
||||||
|
@Parameter(description = "采集任务 ID", required = true, example = "9001")
|
||||||
|
@PathVariable Long taskId,
|
||||||
|
@Parameter(description = "当前用户 ID", required = true, example = "1")
|
||||||
|
@RequestParam("user_id") Long userId) {
|
||||||
|
service.activateTask(taskId, userId);
|
||||||
|
return ApiResponse.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/tasks/{taskId}/items")
|
||||||
|
@Operation(summary = "分页获取任务明细数据", description = "供 Python 端拉取,默认每页 50 条;返回任务关联的筛选条件,便于 Python 端按筛选条件采集。")
|
||||||
|
public ApiResponse<CollectDataItemsPageVo> items(
|
||||||
|
@Parameter(description = "采集任务 ID", required = true, example = "9001")
|
||||||
|
@PathVariable Long taskId,
|
||||||
|
@Parameter(description = "用户 ID,可选;传入时会校验任务归属")
|
||||||
|
@RequestParam(value = "user_id", required = false) Long userId,
|
||||||
|
@Parameter(description = "页码,从 1 开始", example = "1")
|
||||||
|
@RequestParam(value = "page", required = false, defaultValue = "1") Integer page,
|
||||||
|
@Parameter(description = "每页条数,默认 50,最大 200", example = "50")
|
||||||
|
@RequestParam(value = "page_size", required = false, defaultValue = "50") Integer pageSize) {
|
||||||
|
return ApiResponse.success(service.getItemsPage(taskId, userId, page, pageSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/dashboard")
|
||||||
|
@Operation(summary = "查询采集任务总览")
|
||||||
|
public ApiResponse<CollectDataDashboardVo> dashboard(
|
||||||
|
@Parameter(description = "当前用户 ID", required = true, example = "1")
|
||||||
|
@RequestParam("user_id") Long userId) {
|
||||||
|
return ApiResponse.success(service.dashboard(userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/history")
|
||||||
|
@Operation(summary = "查询采集任务历史")
|
||||||
|
public ApiResponse<CollectDataHistoryVo> history(
|
||||||
|
@Parameter(description = "当前用户 ID", required = true, example = "1")
|
||||||
|
@RequestParam("user_id") Long userId,
|
||||||
|
@Parameter(description = "返回数量上限,默认 50,最大 100", example = "50")
|
||||||
|
@RequestParam(value = "limit", required = false, defaultValue = "50") Integer limit) {
|
||||||
|
return ApiResponse.success(service.history(userId, limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/tasks/progress/batch")
|
||||||
|
@Operation(summary = "批量查询任务进度")
|
||||||
|
public ApiResponse<CollectDataTaskBatchVo> progressBatch(@Valid @RequestBody CollectDataTaskBatchRequest request) {
|
||||||
|
return ApiResponse.success(service.progressBatch(request.getTaskIds()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/tasks/{taskId}")
|
||||||
|
@Operation(summary = "删除任务", description = "删除任务及其明细行、关联结果记录。")
|
||||||
|
public ApiResponse<Void> deleteTask(
|
||||||
|
@Parameter(description = "采集任务 ID", required = true, example = "9001")
|
||||||
|
@PathVariable Long taskId,
|
||||||
|
@Parameter(description = "当前用户 ID", required = true, example = "1")
|
||||||
|
@RequestParam("user_id") Long userId) {
|
||||||
|
service.deleteTask(taskId, userId);
|
||||||
|
return ApiResponse.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/history/{resultId}")
|
||||||
|
@Operation(summary = "删除历史记录")
|
||||||
|
public ApiResponse<Void> deleteHistory(
|
||||||
|
@Parameter(description = "结果记录 ID", required = true, example = "1001")
|
||||||
|
@PathVariable Long resultId,
|
||||||
|
@Parameter(description = "当前用户 ID", required = true, example = "1")
|
||||||
|
@RequestParam("user_id") Long userId) {
|
||||||
|
service.deleteHistory(resultId, userId);
|
||||||
|
return ApiResponse.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/country-preference")
|
||||||
|
@Operation(summary = "查询国家处理顺序", description = "返回当前用户保存的国家代码列表,顺序即处理顺序。")
|
||||||
|
public ApiResponse<CollectDataCountryPreferenceVo> getCountryPreference(
|
||||||
|
@Parameter(description = "当前用户 ID", required = true, example = "1")
|
||||||
|
@RequestParam("user_id") Long userId) {
|
||||||
|
return ApiResponse.success(service.getCountryPreference(userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/country-preference")
|
||||||
|
@Operation(summary = "保存国家处理顺序", description = "保存当前用户的国家勾选与处理顺序。")
|
||||||
|
public ApiResponse<CollectDataCountryPreferenceVo> saveCountryPreference(
|
||||||
|
@Valid @RequestBody CollectDataCountryPreferenceSaveRequest request) {
|
||||||
|
return ApiResponse.success(service.saveCountryPreference(request));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.nanri.aiimage.modules.collectdata.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.nanri.aiimage.modules.collectdata.model.entity.CollectDataCountryPrefEntity;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface CollectDataCountryPrefMapper extends BaseMapper<CollectDataCountryPrefEntity> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.nanri.aiimage.modules.collectdata.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.nanri.aiimage.modules.collectdata.model.entity.CollectDataItemEntity;
|
||||||
|
import org.apache.ibatis.annotations.Insert;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface CollectDataItemMapper extends BaseMapper<CollectDataItemEntity> {
|
||||||
|
|
||||||
|
@Insert("""
|
||||||
|
<script>
|
||||||
|
INSERT INTO biz_collect_data_item
|
||||||
|
(task_id, row_index, source_file_key, source_filename, keyword, status_value, extra_json, created_at)
|
||||||
|
VALUES
|
||||||
|
<foreach collection="rows" item="row" separator=",">
|
||||||
|
(#{row.taskId}, #{row.rowIndex}, #{row.sourceFileKey}, #{row.sourceFilename},
|
||||||
|
#{row.keyword}, #{row.statusValue}, #{row.extraJson}, #{row.createdAt})
|
||||||
|
</foreach>
|
||||||
|
</script>
|
||||||
|
""")
|
||||||
|
int insertBatch(@Param("rows") List<CollectDataItemEntity> rows);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.nanri.aiimage.modules.collectdata.model.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.constraints.NotEmpty;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "保存采集数据五国处理顺序:至少 1 个、最多 5 个,代码须为 DE、UK、FR、IT、ES,且不能重复")
|
||||||
|
public class CollectDataCountryPreferenceSaveRequest {
|
||||||
|
|
||||||
|
@NotNull(message = "user_id 不能为空")
|
||||||
|
@JsonProperty("user_id")
|
||||||
|
@Schema(description = "当前用户 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
@NotEmpty(message = "country_codes 不能为空")
|
||||||
|
@Size(max = 5, message = "country_codes 最多 5 项")
|
||||||
|
@JsonProperty("country_codes")
|
||||||
|
@Schema(description = "国家代码列表(DE、UK、FR、IT、ES),顺序即处理顺序", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private List<String> countryCodes = new ArrayList<>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,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<>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.nanri.aiimage.modules.collectdata.model.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.NotEmpty;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "采集数据解析请求:提交筛选条件和上传后的 Excel 文件,由后端解析并落库。")
|
||||||
|
public class CollectDataParseRequest {
|
||||||
|
|
||||||
|
@JsonProperty("user_id")
|
||||||
|
@NotNull
|
||||||
|
@Schema(description = "当前用户 ID", example = "1", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
@NotEmpty
|
||||||
|
@Schema(description = "已上传的 Excel 文件列表", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private List<CollectDataSourceFileDto> files;
|
||||||
|
|
||||||
|
@JsonProperty("task_type")
|
||||||
|
@Schema(description = "任务类型,前端可自定义传入;为空时默认 collect-data", example = "collect-data")
|
||||||
|
private String taskType;
|
||||||
|
|
||||||
|
@Valid
|
||||||
|
@Schema(description = "筛选条件")
|
||||||
|
private CollectDataFiltersDto filters;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.nanri.aiimage.modules.collectdata.model.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "采集数据源文件信息")
|
||||||
|
public class CollectDataSourceFileDto {
|
||||||
|
@Schema(description = "上传接口返回的临时文件 key", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private String fileKey;
|
||||||
|
|
||||||
|
@Schema(description = "原始文件名")
|
||||||
|
private String originalFilename;
|
||||||
|
|
||||||
|
@Schema(description = "相对目录路径")
|
||||||
|
private String relativePath;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.nanri.aiimage.modules.collectdata.model.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.constraints.NotEmpty;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "批量查询采集数据任务进度的请求")
|
||||||
|
public class CollectDataTaskBatchRequest {
|
||||||
|
@NotEmpty
|
||||||
|
@Schema(description = "任务 ID 列表", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private List<Long> taskIds;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<>();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<>();
|
||||||
|
}
|
||||||
@@ -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<>();
|
||||||
|
}
|
||||||
@@ -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<>();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<>();
|
||||||
|
}
|
||||||
@@ -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<>();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -152,13 +152,13 @@ public class DeleteBrandRunController {
|
|||||||
@Operation(summary = "Download one delete-brand result")
|
@Operation(summary = "Download one delete-brand result")
|
||||||
public void downloadResult(
|
public void downloadResult(
|
||||||
@PathVariable Long resultId,
|
@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,
|
@RequestParam("user_id") Long userId,
|
||||||
jakarta.servlet.http.HttpServletResponse response) {
|
jakarta.servlet.http.HttpServletResponse response) {
|
||||||
String url = deleteBrandRunService.resolveResultDownloadUrl(resultId, userId);
|
String url = deleteBrandRunService.resolveResultDownloadUrl(resultId, userId);
|
||||||
String filename = deleteBrandRunService.resolveResultDownloadFilename(resultId, userId);
|
String filename = deleteBrandRunService.resolveResultDownloadFilename(resultId, userId);
|
||||||
if (url == null || url.isBlank()) {
|
if (url == null || url.isBlank()) {
|
||||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "缁撴灉鏃犲彲涓嬭浇");
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "结果无可下载");
|
||||||
}
|
}
|
||||||
if (filename == null || filename.isBlank()) {
|
if (filename == null || filename.isBlank()) {
|
||||||
String rawPath = URI.create(url).getPath();
|
String rawPath = URI.create(url).getPath();
|
||||||
@@ -177,7 +177,7 @@ public class DeleteBrandRunController {
|
|||||||
response.getOutputStream().flush();
|
response.getOutputStream().flush();
|
||||||
}
|
}
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "涓嬭浇澶辫触");
|
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "下载失败");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -628,7 +628,7 @@ public class DeleteBrandStaleTaskService {
|
|||||||
.eq(FileTaskEntity::getModuleType, MODULE_TYPE_QUERY_ASIN)
|
.eq(FileTaskEntity::getModuleType, MODULE_TYPE_QUERY_ASIN)
|
||||||
.eq(FileTaskEntity::getStatus, "RUNNING")
|
.eq(FileTaskEntity::getStatus, "RUNNING")
|
||||||
.set(FileTaskEntity::getStatus, "FAILED")
|
.set(FileTaskEntity::getStatus, "FAILED")
|
||||||
.set(FileTaskEntity::getErrorMessage, "闀挎椂闂存湭鏀跺埌 Python 缁撴灉鍥炰紶锛屼换鍔″凡鑷姩澶辫触")
|
.set(FileTaskEntity::getErrorMessage, "长时间未收到 Python 结果回传,任务已自动失败")
|
||||||
.set(FileTaskEntity::getUpdatedAt, LocalDateTime.now())
|
.set(FileTaskEntity::getUpdatedAt, LocalDateTime.now())
|
||||||
.set(FileTaskEntity::getFinishedAt, LocalDateTime.now()));
|
.set(FileTaskEntity::getFinishedAt, LocalDateTime.now()));
|
||||||
if (updated > 0) {
|
if (updated > 0) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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> {
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1044,7 +1044,7 @@ public class PatrolDeleteTaskService {
|
|||||||
private TaskDistributedLockService.LockHandle acquireTaskLockOrThrow(Long taskId) {
|
private TaskDistributedLockService.LockHandle acquireTaskLockOrThrow(Long taskId) {
|
||||||
TaskDistributedLockService.LockHandle lockHandle = acquireTaskLock(taskId);
|
TaskDistributedLockService.LockHandle lockHandle = acquireTaskLock(taskId);
|
||||||
if (lockHandle == null) {
|
if (lockHandle == null) {
|
||||||
throw new BusinessException(40901, "浠诲姟姝e湪澶勭悊涓紝璇风◢鍚庡啀璇?");
|
throw new BusinessException(40901, "任务正在处理中,请稍后再试");
|
||||||
}
|
}
|
||||||
return lockHandle;
|
return lockHandle;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ public class PermissionMenuSchemaInitializer {
|
|||||||
new DefaultAdminMenu("用户管理", "admin_users", "users", 10),
|
new DefaultAdminMenu("用户管理", "admin_users", "users", 10),
|
||||||
new DefaultAdminMenu("栏目权限配置", "admin_columns", "columns", 20),
|
new DefaultAdminMenu("栏目权限配置", "admin_columns", "columns", 20),
|
||||||
new DefaultAdminMenu("数据去重总数据", "admin_dedupe_total_data", "dedupe-total-data", 30),
|
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_keys", "shop-keys", 40),
|
||||||
new DefaultAdminMenu("店铺管理", "admin_shop_manage", "shop-manage", 50),
|
new DefaultAdminMenu("店铺管理", "admin_shop_manage", "shop-manage", 50),
|
||||||
new DefaultAdminMenu("跳过跟价ASIN", "admin_skip_price_asin", "skip-price-asin", 60),
|
new DefaultAdminMenu("跳过跟价ASIN", "admin_skip_price_asin", "skip-price-asin", 60),
|
||||||
|
|||||||
@@ -1391,7 +1391,7 @@ public class PriceTrackTaskService {
|
|||||||
private TaskDistributedLockService.LockHandle acquireTaskLockOrThrow(Long taskId) {
|
private TaskDistributedLockService.LockHandle acquireTaskLockOrThrow(Long taskId) {
|
||||||
TaskDistributedLockService.LockHandle lockHandle = acquireTaskLock(taskId);
|
TaskDistributedLockService.LockHandle lockHandle = acquireTaskLock(taskId);
|
||||||
if (lockHandle == null) {
|
if (lockHandle == null) {
|
||||||
throw new BusinessException(40901, "浠诲姟姝e湪澶勭悊涓紝璇风◢鍚庡啀璇?");
|
throw new BusinessException(40901, "任务正在处理中,请稍后再试");
|
||||||
}
|
}
|
||||||
return lockHandle;
|
return lockHandle;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -970,7 +970,7 @@ public class ProductRiskTaskService {
|
|||||||
private TaskDistributedLockService.LockHandle acquireTaskLockOrThrow(Long taskId) {
|
private TaskDistributedLockService.LockHandle acquireTaskLockOrThrow(Long taskId) {
|
||||||
TaskDistributedLockService.LockHandle lockHandle = acquireTaskLock(taskId);
|
TaskDistributedLockService.LockHandle lockHandle = acquireTaskLock(taskId);
|
||||||
if (lockHandle == null) {
|
if (lockHandle == null) {
|
||||||
throw new BusinessException(40901, "浠诲姟姝e湪澶勭悊涓紝璇风◢鍚庡啀璇?");
|
throw new BusinessException(40901, "任务正在处理中,请稍后再试");
|
||||||
}
|
}
|
||||||
return lockHandle;
|
return lockHandle;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1003,7 +1003,7 @@ public class QueryAsinTaskService {
|
|||||||
private TaskDistributedLockService.LockHandle acquireTaskLockOrThrow(Long taskId) {
|
private TaskDistributedLockService.LockHandle acquireTaskLockOrThrow(Long taskId) {
|
||||||
TaskDistributedLockService.LockHandle lockHandle = acquireTaskLock(taskId);
|
TaskDistributedLockService.LockHandle lockHandle = acquireTaskLock(taskId);
|
||||||
if (lockHandle == null) {
|
if (lockHandle == null) {
|
||||||
throw new BusinessException(40901, "浠诲姟姝e湪澶勭悊涓紝璇风◢鍚庡啀璇?");
|
throw new BusinessException(40901, "任务正在处理中,请稍后再试");
|
||||||
}
|
}
|
||||||
return lockHandle;
|
return lockHandle;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,15 +33,8 @@ import java.time.format.DateTimeFormatter;
|
|||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@RequestMapping({
|
@RequestMapping("/api/admin/skip-price-asins")
|
||||||
"/admin/skip-price-asins",
|
@Tag(name = "跳过跟价 ASIN", description = "管理跳过跟价 ASIN 数据,包括分页、导出、导入、按国家更新和删除。")
|
||||||
"/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")
|
|
||||||
public class SkipPriceAsinController {
|
public class SkipPriceAsinController {
|
||||||
|
|
||||||
private static final DateTimeFormatter EXPORT_FILENAME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss");
|
private static final DateTimeFormatter EXPORT_FILENAME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss");
|
||||||
@@ -49,15 +42,15 @@ public class SkipPriceAsinController {
|
|||||||
private final SkipPriceAsinService skipPriceAsinService;
|
private final SkipPriceAsinService skipPriceAsinService;
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@Operation(summary = "Page query skip price asins")
|
@Operation(summary = "分页查询跳过跟价 ASIN", description = "支持按分组、店铺名、ASIN 和当前操作人可见范围筛选。")
|
||||||
public ApiResponse<SkipPriceAsinPageVo> page(
|
public ApiResponse<SkipPriceAsinPageVo> page(
|
||||||
@Parameter(description = "page") @RequestParam(name = "page", defaultValue = "1") Long page,
|
@Parameter(description = "页码") @RequestParam(name = "page", defaultValue = "1") Long page,
|
||||||
@Parameter(description = "page size") @RequestParam(name = "page_size", defaultValue = "15") Long pageSize,
|
@Parameter(description = "每页数量") @RequestParam(name = "page_size", defaultValue = "15") Long pageSize,
|
||||||
@Parameter(description = "group id") @RequestParam(name = "group_id", required = false) Long groupId,
|
@Parameter(description = "分组 ID") @RequestParam(name = "group_id", required = false) Long groupId,
|
||||||
@Parameter(description = "shop name") @RequestParam(name = "shop_name", required = false) String shopName,
|
@Parameter(description = "店铺名称") @RequestParam(name = "shop_name", required = false) String shopName,
|
||||||
@Parameter(description = "asin") @RequestParam(name = "asin", required = false) String asin,
|
@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 = "当前操作人用户 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 = "super_admin", defaultValue = "false") Boolean superAdmin) {
|
||||||
return ApiResponse.success(skipPriceAsinService.page(
|
return ApiResponse.success(skipPriceAsinService.page(
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -69,13 +62,13 @@ public class SkipPriceAsinController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/export")
|
@GetMapping("/export")
|
||||||
@Operation(summary = "Export skip price asins")
|
@Operation(summary = "导出跳过跟价 ASIN", description = "按当前查询条件导出 Excel。")
|
||||||
public ResponseEntity<byte[]> export(
|
public ResponseEntity<byte[]> export(
|
||||||
@Parameter(description = "group id") @RequestParam(name = "group_id", required = false) Long groupId,
|
@Parameter(description = "分组 ID") @RequestParam(name = "group_id", required = false) Long groupId,
|
||||||
@Parameter(description = "shop name") @RequestParam(name = "shop_name", required = false) String shopName,
|
@Parameter(description = "店铺名称") @RequestParam(name = "shop_name", required = false) String shopName,
|
||||||
@Parameter(description = "asin") @RequestParam(name = "asin", required = false) String asin,
|
@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 = "当前操作人用户 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 = "super_admin", defaultValue = "false") Boolean superAdmin) {
|
||||||
byte[] bytes = skipPriceAsinService.export(groupId, shopName, asin, operatorId, Boolean.TRUE.equals(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";
|
String filename = "skip-price-asin-" + LocalDateTime.now().format(EXPORT_FILENAME_FORMATTER) + ".xlsx";
|
||||||
return ResponseEntity.ok()
|
return ResponseEntity.ok()
|
||||||
@@ -86,58 +79,60 @@ public class SkipPriceAsinController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@Operation(summary = "Create or update skip price asin")
|
@Operation(summary = "新增或更新跳过跟价 ASIN", description = "按店铺和国家维度新增或更新跳过跟价 ASIN。")
|
||||||
public ApiResponse<SkipPriceAsinItemVo> create(
|
public ApiResponse<SkipPriceAsinItemVo> create(
|
||||||
@Parameter(description = "operator user id") @RequestParam(name = "operator_id") Long operatorId,
|
@Parameter(description = "当前操作人用户 ID") @RequestParam(name = "operator_id") Long operatorId,
|
||||||
@Parameter(description = "is super admin") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin,
|
@Parameter(description = "是否超级管理员") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin,
|
||||||
@Valid @RequestBody SkipPriceAsinCreateRequest request) {
|
@Valid @RequestBody SkipPriceAsinCreateRequest request) {
|
||||||
return ApiResponse.success("\u4fdd\u5b58\u6210\u529f",
|
return ApiResponse.success("保存成功",
|
||||||
skipPriceAsinService.createOrUpdate(request, operatorId, Boolean.TRUE.equals(superAdmin)));
|
skipPriceAsinService.createOrUpdate(request, operatorId, Boolean.TRUE.equals(superAdmin)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/import")
|
@PostMapping("/import")
|
||||||
@Operation(summary = "Import skip price asin")
|
@Operation(summary = "导入跳过跟价 ASIN", description = "上传 xlsx 或 xls 文件,异步导入新增数据。")
|
||||||
public ApiResponse<QueryAsinImportStartVo> importExcel(
|
public ApiResponse<QueryAsinImportStartVo> importExcel(
|
||||||
@Parameter(description = "xlsx or xls file", required = true) @RequestParam("file") MultipartFile file,
|
@Parameter(description = "xlsx 或 xls 文件", required = true) @RequestParam("file") MultipartFile file,
|
||||||
@Parameter(description = "group id", required = true) @RequestParam(name = "group_id") Long groupId,
|
@Parameter(description = "分组 ID", required = true) @RequestParam(name = "group_id") Long groupId,
|
||||||
@Parameter(description = "operator user id") @RequestParam(name = "operator_id") Long operatorId,
|
@Parameter(description = "当前操作人用户 ID") @RequestParam(name = "operator_id") Long operatorId,
|
||||||
@Parameter(description = "is super admin") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) {
|
@Parameter(description = "是否超级管理员") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) {
|
||||||
return ApiResponse.success("\u5f00\u59cb\u5bfc\u5165",
|
return ApiResponse.success("开始导入",
|
||||||
skipPriceAsinService.startImport(file, groupId, operatorId, Boolean.TRUE.equals(superAdmin)));
|
skipPriceAsinService.startImport(file, groupId, operatorId, Boolean.TRUE.equals(superAdmin)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/import/{importId}")
|
@GetMapping("/import/{importId}")
|
||||||
@Operation(summary = "Query import progress")
|
@Operation(summary = "查询导入进度")
|
||||||
public ApiResponse<QueryAsinImportProgressVo> importProgress(@PathVariable String importId) {
|
public ApiResponse<QueryAsinImportProgressVo> importProgress(
|
||||||
|
@Parameter(description = "导入任务 ID", required = true) @PathVariable String importId) {
|
||||||
return ApiResponse.success(skipPriceAsinService.getImportProgress(importId));
|
return ApiResponse.success(skipPriceAsinService.getImportProgress(importId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/delete-import")
|
@PostMapping("/delete-import")
|
||||||
@Operation(summary = "Import delete skip price asin")
|
@Operation(summary = "导入删除跳过跟价 ASIN", description = "上传 xlsx 或 xls 文件,异步按文件内容删除数据。")
|
||||||
public ApiResponse<QueryAsinImportStartVo> deleteImportExcel(
|
public ApiResponse<QueryAsinImportStartVo> deleteImportExcel(
|
||||||
@Parameter(description = "xlsx or xls file", required = true) @RequestParam("file") MultipartFile file,
|
@Parameter(description = "xlsx 或 xls 文件", required = true) @RequestParam("file") MultipartFile file,
|
||||||
@Parameter(description = "group id", required = true) @RequestParam(name = "group_id") Long groupId,
|
@Parameter(description = "分组 ID", required = true) @RequestParam(name = "group_id") Long groupId,
|
||||||
@Parameter(description = "operator user id") @RequestParam(name = "operator_id") Long operatorId,
|
@Parameter(description = "当前操作人用户 ID") @RequestParam(name = "operator_id") Long operatorId,
|
||||||
@Parameter(description = "is super admin") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) {
|
@Parameter(description = "是否超级管理员") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) {
|
||||||
return ApiResponse.success("\u5f00\u59cb\u5220\u9664",
|
return ApiResponse.success("开始删除",
|
||||||
skipPriceAsinService.startDeleteImport(file, groupId, operatorId, Boolean.TRUE.equals(superAdmin)));
|
skipPriceAsinService.startDeleteImport(file, groupId, operatorId, Boolean.TRUE.equals(superAdmin)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/delete-import/{importId}")
|
@GetMapping("/delete-import/{importId}")
|
||||||
@Operation(summary = "Query delete import progress")
|
@Operation(summary = "查询导入删除进度")
|
||||||
public ApiResponse<QueryAsinImportProgressVo> deleteImportProgress(@PathVariable String importId) {
|
public ApiResponse<QueryAsinImportProgressVo> deleteImportProgress(
|
||||||
|
@Parameter(description = "导入删除任务 ID", required = true) @PathVariable String importId) {
|
||||||
return ApiResponse.success(skipPriceAsinService.getDeleteImportProgress(importId));
|
return ApiResponse.success(skipPriceAsinService.getDeleteImportProgress(importId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping({"/{id}/countries/{country}", "/{id}/country/{country}"})
|
@PutMapping("/{id}/countries/{country}")
|
||||||
@Operation(summary = "Update one country skip price asin")
|
@Operation(summary = "更新指定国家的跳过跟价 ASIN")
|
||||||
public ApiResponse<SkipPriceAsinItemVo> updateCountry(
|
public ApiResponse<SkipPriceAsinItemVo> updateCountry(
|
||||||
@Parameter(description = "id", required = true) @PathVariable Long id,
|
@Parameter(description = "主键 ID", required = true) @PathVariable Long id,
|
||||||
@Parameter(description = "country code", required = true) @PathVariable String country,
|
@Parameter(description = "国家代码", required = true) @PathVariable String country,
|
||||||
@Parameter(description = "operator user id") @RequestParam(name = "operator_id") Long operatorId,
|
@Parameter(description = "当前操作人用户 ID") @RequestParam(name = "operator_id") Long operatorId,
|
||||||
@Parameter(description = "is super admin") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin,
|
@Parameter(description = "是否超级管理员") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin,
|
||||||
@Valid @RequestBody SkipPriceAsinCountryUpdateRequest request) {
|
@Valid @RequestBody SkipPriceAsinCountryUpdateRequest request) {
|
||||||
return ApiResponse.success("\u4fdd\u5b58\u6210\u529f", skipPriceAsinService.updateCountry(
|
return ApiResponse.success("保存成功", skipPriceAsinService.updateCountry(
|
||||||
id,
|
id,
|
||||||
country,
|
country,
|
||||||
request.getAsin(),
|
request.getAsin(),
|
||||||
@@ -146,14 +141,14 @@ public class SkipPriceAsinController {
|
|||||||
Boolean.TRUE.equals(superAdmin)));
|
Boolean.TRUE.equals(superAdmin)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping({"/{id}/countries/{country}", "/{id}/country/{country}"})
|
@DeleteMapping("/{id}/countries/{country}")
|
||||||
@Operation(summary = "Delete one country skip price asin")
|
@Operation(summary = "删除指定国家的跳过跟价 ASIN")
|
||||||
public ApiResponse<Void> deleteCountry(
|
public ApiResponse<Void> deleteCountry(
|
||||||
@Parameter(description = "id", required = true) @PathVariable Long id,
|
@Parameter(description = "主键 ID", required = true) @PathVariable Long id,
|
||||||
@Parameter(description = "country code", required = true) @PathVariable String country,
|
@Parameter(description = "国家代码", required = true) @PathVariable String country,
|
||||||
@Parameter(description = "operator user id") @RequestParam(name = "operator_id") Long operatorId,
|
@Parameter(description = "当前操作人用户 ID") @RequestParam(name = "operator_id") Long operatorId,
|
||||||
@Parameter(description = "is super admin") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) {
|
@Parameter(description = "是否超级管理员") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) {
|
||||||
skipPriceAsinService.deleteCountry(id, country, operatorId, Boolean.TRUE.equals(superAdmin));
|
skipPriceAsinService.deleteCountry(id, country, operatorId, Boolean.TRUE.equals(superAdmin));
|
||||||
return ApiResponse.success("\u5220\u9664\u6210\u529f", null);
|
return ApiResponse.success("删除成功", null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -487,7 +487,7 @@ public class SkipPriceAsinService {
|
|||||||
case "FR" -> query.eq(SkipPriceAsinEntity::getAsinFr, asin);
|
case "FR" -> query.eq(SkipPriceAsinEntity::getAsinFr, asin);
|
||||||
case "IT" -> query.eq(SkipPriceAsinEntity::getAsinIt, asin);
|
case "IT" -> query.eq(SkipPriceAsinEntity::getAsinIt, asin);
|
||||||
case "ES" -> query.eq(SkipPriceAsinEntity::getAsinEs, 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"));
|
return skipPriceAsinMapper.selectOne(query.last("LIMIT 1"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -877,7 +877,7 @@ public class ShopMatchTaskService {
|
|||||||
private TaskDistributedLockService.LockHandle acquireTaskLockOrThrow(Long taskId) {
|
private TaskDistributedLockService.LockHandle acquireTaskLockOrThrow(Long taskId) {
|
||||||
TaskDistributedLockService.LockHandle lockHandle = acquireTaskLock(taskId);
|
TaskDistributedLockService.LockHandle lockHandle = acquireTaskLock(taskId);
|
||||||
if (lockHandle == null) {
|
if (lockHandle == null) {
|
||||||
throw new BusinessException(40901, "浠诲姟姝e湪澶勭悊涓紝璇风◢鍚庡啀璇?");
|
throw new BusinessException(40901, "任务正在处理中,请稍后再试");
|
||||||
}
|
}
|
||||||
return lockHandle;
|
return lockHandle;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4811,7 +4811,7 @@ public class SimilarAsinTaskService {
|
|||||||
vo.setTaskId(row.getTaskId());
|
vo.setTaskId(row.getTaskId());
|
||||||
vo.setSourceFilename(row.getSourceFilename());
|
vo.setSourceFilename(row.getSourceFilename());
|
||||||
vo.setResultFilename(row.getResultFilename());
|
vo.setResultFilename(row.getResultFilename());
|
||||||
vo.setDownloadUrl(null);
|
vo.setDownloadUrl(buildFreshDownloadUrl(row));
|
||||||
attachFileJobState(vo, row, job);
|
attachFileJobState(vo, row, job);
|
||||||
vo.setTaskStatus(task == null ? null : task.getStatus());
|
vo.setTaskStatus(task == null ? null : task.getStatus());
|
||||||
vo.setSuccess(row.getSuccess() != null && row.getSuccess() == 1);
|
vo.setSuccess(row.getSuccess() != null && row.getSuccess() == 1);
|
||||||
@@ -4824,6 +4824,20 @@ public class SimilarAsinTaskService {
|
|||||||
return vo;
|
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) {
|
private void attachFileJobState(SimilarAsinHistoryItemVo vo, FileResultEntity row, TaskFileJobEntity job) {
|
||||||
vo.setFileReady(row.getResultFileUrl() != null && !row.getResultFileUrl().isBlank());
|
vo.setFileReady(row.getResultFileUrl() != null && !row.getResultFileUrl().isBlank());
|
||||||
if (job == null) {
|
if (job == null) {
|
||||||
|
|||||||
@@ -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';
|
||||||
30
backend-java/src/main/resources/db/V55__collect_data.sql
Normal file
30
backend-java/src/main/resources/db/V55__collect_data.sql
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
-- 采集数据模块:复用通用任务表 biz_file_task / biz_file_result(module_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 '所属任务 ID(biz_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';
|
||||||
@@ -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='采集数据用户五国处理顺序与勾选配置';
|
||||||
@@ -78,6 +78,11 @@ ADMIN_MENU_ACCESS_CONFIG = {
|
|||||||
'route_path': 'product-categories',
|
'route_path': 'product-categories',
|
||||||
'error': '无权访问商品类目模块',
|
'error': '无权访问商品类目模块',
|
||||||
},
|
},
|
||||||
|
'invalid-asin-data': {
|
||||||
|
'column_key': 'admin_invalid_asin_data',
|
||||||
|
'route_path': 'invalid-asin-data',
|
||||||
|
'error': '无权访问不符合ASIN数据模块',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
ADMIN_MENU_ACCESS_CONFIG.update({
|
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):
|
def _format_shop_manage_item(item):
|
||||||
|
|||||||
@@ -101,6 +101,7 @@
|
|||||||
'users': 'panel-users',
|
'users': 'panel-users',
|
||||||
'columns': 'panel-columns',
|
'columns': 'panel-columns',
|
||||||
'dedupe-total-data': 'panel-dedupe-total-data',
|
'dedupe-total-data': 'panel-dedupe-total-data',
|
||||||
|
'invalid-asin-data': 'panel-invalid-asin-data',
|
||||||
'shop-keys': 'panel-shop-keys',
|
'shop-keys': 'panel-shop-keys',
|
||||||
'shop-manage': 'panel-shop-manage',
|
'shop-manage': 'panel-shop-manage',
|
||||||
'skip-price-asin': 'panel-skip-price-asin',
|
'skip-price-asin': 'panel-skip-price-asin',
|
||||||
@@ -113,6 +114,7 @@
|
|||||||
if (tabName === 'users') { loadUsers(1); loadColumnsForPermission(); }
|
if (tabName === 'users') { loadUsers(1); loadColumnsForPermission(); }
|
||||||
else if (tabName === 'columns') loadColumns();
|
else if (tabName === 'columns') loadColumns();
|
||||||
else if (tabName === 'dedupe-total-data') loadDedupeTotalData(1);
|
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-keys') loadShopKeys(1);
|
||||||
else if (tabName === 'shop-manage') loadShopManage(1);
|
else if (tabName === 'shop-manage') loadShopManage(1);
|
||||||
else if (tabName === 'skip-price-asin') loadSkipPriceAsin(1);
|
else if (tabName === 'skip-price-asin') loadSkipPriceAsin(1);
|
||||||
@@ -1165,6 +1167,135 @@
|
|||||||
document.getElementById('editDedupeTotalDataModal').classList.remove('show');
|
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, '"');
|
||||||
|
var brandAttr = (item.brand || '').replace(/"/g, '"');
|
||||||
|
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(/"/g, '"');
|
||||||
|
document.getElementById('editInvalidAsinDataBrand').value = (btn.dataset.brand || '').replace(/"/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(/"/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;
|
var shopKeyPage = 1, shopKeyPageSize = 15;
|
||||||
function buildShopKeyQuery(page) {
|
function buildShopKeyQuery(page) {
|
||||||
|
|||||||
@@ -786,6 +786,7 @@
|
|||||||
<div class="tab active" data-tab="users">用户管理</div>
|
<div class="tab active" data-tab="users">用户管理</div>
|
||||||
<div class="tab" data-tab="columns">栏目权限配置</div>
|
<div class="tab" data-tab="columns">栏目权限配置</div>
|
||||||
<div class="tab" data-tab="dedupe-total-data">数据去重总数据</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-keys">店铺密钥管理</div>
|
||||||
<div class="tab" data-tab="shop-manage">店铺管理</div>
|
<div class="tab" data-tab="shop-manage">店铺管理</div>
|
||||||
<div class="tab" data-tab="skip-price-asin">跳过跟价 ASIN</div>
|
<div class="tab" data-tab="skip-price-asin">跳过跟价 ASIN</div>
|
||||||
@@ -1010,6 +1011,48 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 id="panel-shop-keys" class="tab-panel">
|
||||||
<div class="form-box">
|
<div class="form-box">
|
||||||
@@ -1494,6 +1537,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</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-mask" id="editColumnModal">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
|
|||||||
12
frontend-vue/collect-data.html
Normal file
12
frontend-vue/collect-data.html
Normal 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>
|
||||||
7
frontend-vue/src/collect-data-main.ts
Normal file
7
frontend-vue/src/collect-data-main.ts
Normal 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')
|
||||||
@@ -5,33 +5,33 @@
|
|||||||
<span>密钥设置</span>
|
<span>密钥设置</span>
|
||||||
</button>
|
</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>
|
<template #header>
|
||||||
<div class="dialog-header">
|
<div class="dialog-header">
|
||||||
<div class="dialog-title">密钥设置</div>
|
<div class="dialog-title">密钥设置</div>
|
||||||
<div class="dialog-subtitle">按当前登录用户保存在本机,外观专利和货源查询共用同一个 Coze 接口密钥。</div>
|
<div class="dialog-subtitle">按当前登录用户保存在本机,外观专利和货源查询分别使用独立的 Coze 接口密钥。</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="secret-settings-body">
|
<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 class="secret-card-head">
|
||||||
<div>
|
<div>
|
||||||
<div class="secret-card-title">通用 Coze 密钥</div>
|
<div class="secret-card-title">{{ config.title }}</div>
|
||||||
<div class="secret-card-desc">用于外观专利检测和货源查询。</div>
|
<div class="secret-card-desc">{{ config.description }}</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
v-if="secretState.exists"
|
v-if="secretStates[config.key].exists"
|
||||||
type="button"
|
type="button"
|
||||||
class="link-danger"
|
class="link-danger"
|
||||||
@click="clearSecret"
|
@click="clearSecret(config.key)"
|
||||||
>
|
>
|
||||||
清空
|
清空
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
v-model="secretState.value"
|
v-model="secretStates[config.key].value"
|
||||||
class="secret-input"
|
class="secret-input"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="请输入 Coze 接口密钥"
|
placeholder="请输入 Coze 接口密钥"
|
||||||
@@ -43,14 +43,16 @@
|
|||||||
<div class="retention-label">保留时长</div>
|
<div class="retention-label">保留时长</div>
|
||||||
<div class="retention-options">
|
<div class="retention-options">
|
||||||
<label v-for="option in retentionOptions" :key="option.value" class="retention-option">
|
<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>
|
<span>{{ option.label }}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="secret-meta">
|
<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>
|
<span v-else>当前未保存</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -73,6 +75,7 @@ import {
|
|||||||
clearStoredApiSecret,
|
clearStoredApiSecret,
|
||||||
getStoredApiSecretSnapshot,
|
getStoredApiSecretSnapshot,
|
||||||
saveStoredApiSecret,
|
saveStoredApiSecret,
|
||||||
|
type ApiSecretModuleKey,
|
||||||
type ApiSecretRetention,
|
type ApiSecretRetention,
|
||||||
} from '@/shared/utils/api-secret-store'
|
} from '@/shared/utils/api-secret-store'
|
||||||
|
|
||||||
@@ -93,16 +96,36 @@ const retentionOptions: Array<{ value: ApiSecretRetention; label: string }> = [
|
|||||||
{ value: 'forever', label: '长期保留' },
|
{ 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() {
|
function loadStates() {
|
||||||
const snapshot = getStoredApiSecretSnapshot()
|
const nextStates = { ...secretStates.value }
|
||||||
secretState.value = {
|
for (const config of secretConfigs) {
|
||||||
value: snapshot.value,
|
const snapshot = getStoredApiSecretSnapshot(config.key)
|
||||||
retention: snapshot.retention,
|
nextStates[config.key] = {
|
||||||
expiresAt: snapshot.expiresAt,
|
value: snapshot.value,
|
||||||
exists: snapshot.exists,
|
retention: snapshot.retention,
|
||||||
|
expiresAt: snapshot.expiresAt,
|
||||||
|
exists: snapshot.exists,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
secretStates.value = nextStates
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptySecretState(): SecretState {
|
function emptySecretState(): SecretState {
|
||||||
@@ -127,14 +150,17 @@ function formatRetentionText(retention: ApiSecretRetention, expiresAt: number |
|
|||||||
return `有效期至 ${year}-${month}-${day} ${hour}:${minute}`
|
return `有效期至 ${year}-${month}-${day} ${hour}:${minute}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearSecret() {
|
function clearSecret(moduleKey: ApiSecretModuleKey) {
|
||||||
clearStoredApiSecret()
|
clearStoredApiSecret(moduleKey)
|
||||||
loadStates()
|
loadStates()
|
||||||
ElMessage.success('已清空密钥')
|
ElMessage.success('已清空密钥')
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveAll() {
|
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()
|
loadStates()
|
||||||
dialogVisible.value = false
|
dialogVisible.value = false
|
||||||
ElMessage.success('密钥设置已保存')
|
ElMessage.success('密钥设置已保存')
|
||||||
@@ -242,6 +268,9 @@ loadStates()
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
|
max-height: 62vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.secret-card {
|
.secret-card {
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
|||||||
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
|
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
|
||||||
import { getStoredApiSecret } from '@/shared/utils/api-secret-store'
|
import { getStoredApiSecret } from '@/shared/utils/api-secret-store'
|
||||||
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
|
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
|
||||||
|
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
|
||||||
|
|
||||||
const selectedFileNames = ref<string[]>([])
|
const selectedFileNames = ref<string[]>([])
|
||||||
const uploadedFiles = ref<UploadFileVo[]>([])
|
const uploadedFiles = ref<UploadFileVo[]>([])
|
||||||
@@ -257,7 +258,7 @@ function queueAiPrompt() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function effectiveCozeApiKey() {
|
function effectiveCozeApiKey() {
|
||||||
return getStoredApiSecret().trim()
|
return getStoredApiSecret('appearance-patent').trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
function maskSecret(secret: string) {
|
function maskSecret(secret: string) {
|
||||||
@@ -462,6 +463,7 @@ async function pushToPythonQueue() {
|
|||||||
id: row.displayId,
|
id: row.displayId,
|
||||||
asin: row.asin,
|
asin: row.asin,
|
||||||
country: row.country,
|
country: row.country,
|
||||||
|
sku: row.sku || '',
|
||||||
url: row.url || '',
|
url: row.url || '',
|
||||||
title: row.title || '',
|
title: row.title || '',
|
||||||
})),
|
})),
|
||||||
@@ -880,14 +882,9 @@ function shouldShowFallbackJobProgress(item: AppearancePatentHistoryItem) {
|
|||||||
|
|
||||||
async function downloadResult(item: AppearancePatentHistoryItem) {
|
async function downloadResult(item: AppearancePatentHistoryItem) {
|
||||||
if (!item.resultId) return
|
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 url = item.downloadUrl || getAppearancePatentResultDownloadUrl(item.resultId)
|
||||||
const filename = item.resultFilename || `${item.sourceFilename || 'appearance-patent'}.xlsx`
|
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) {
|
if (result.success) {
|
||||||
ElMessage.success(`已保存: ${result.path || filename}`)
|
ElMessage.success(`已保存: ${result.path || filename}`)
|
||||||
} else if (result.error && result.error !== '用户取消') {
|
} else if (result.error && result.error !== '用户取消') {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user