更新后端新增内容

This commit is contained in:
super
2026-03-26 16:42:25 +08:00
parent 21c6f41c69
commit 32a9494dd6
63 changed files with 1943 additions and 415 deletions

1
.gitignore vendored
View File

@@ -32,3 +32,4 @@ backend-java/*.iml
# desktop app
desktop/
ERP-Demo/

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

View File

@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>格式转换 - 数富AI</title>
<script type="module" crossorigin src="/assets/convert.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-BRrok0pS.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-IqgMfeBe.css">
<link rel="modulepreload" crossorigin href="/assets/pywebview-Bl6M97uH.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-8G_9KMXd.css">
<link rel="stylesheet" crossorigin href="/assets/convert-Cfr3mUjs.css">
</head>
<body>

View File

@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>数据去重 - 数富AI</title>
<script type="module" crossorigin src="/assets/dedupe.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-BRrok0pS.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-IqgMfeBe.css">
<link rel="modulepreload" crossorigin href="/assets/pywebview-Bl6M97uH.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-8G_9KMXd.css">
<link rel="stylesheet" crossorigin href="/assets/dedupe-CS18ls2z.css">
</head>
<body>

View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>删除品牌 - 数富AI</title>
<script type="module" crossorigin src="/assets/delete-brand.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-Bl6M97uH.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-8G_9KMXd.css">
<link rel="stylesheet" crossorigin href="/assets/delete-brand-D1WtGorm.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

View File

@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>数据拆分 - 数富AI</title>
<script type="module" crossorigin src="/assets/split.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-BRrok0pS.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-IqgMfeBe.css">
<link rel="modulepreload" crossorigin href="/assets/pywebview-Bl6M97uH.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-8G_9KMXd.css">
<link rel="stylesheet" crossorigin href="/assets/split-mwLUJT1K.css">
</head>
<body>

View File

@@ -1,9 +1,9 @@
<!DOCTYPE html>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>亚马逊 - 南日AI</title>
<title>浜氶┈閫?- 鍗楁棩AI</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
@@ -38,7 +38,7 @@
}
.btn-home:hover { color: #fff; background: #2a2a2a; }
/* 顶部栏目导航(容器化,便于后续增加栏目) */
/* 椤堕儴鏍忕洰瀵艰埅锛堝鍣ㄥ寲锛屼究浜庡悗缁鍔犳爮鐩級 */
.nav-tabs {
display: flex;
align-items: center;
@@ -87,7 +87,7 @@
display: flex;
height: calc(100vh - 56px);
}
/* 栏目内容容器:每个栏目一个 .tab-panel,通过 data-panel 与导航对应 */
/* 鏍忕洰鍐呭瀹瑰櫒锛氭瘡涓爮鐩竴涓?.tab-panel锛岄€氳繃 data-panel 涓庡鑸搴?*/
.tab-panel {
display: none;
flex: 1;
@@ -338,87 +338,87 @@
<body>
<header class="top-bar">
<div class="logo-area">
<span class="app-name">南日AI-亚马逊</span>
<a href="/home" class="btn-home">返回首页</a>
<span class="app-name">鍗楁棩AI-浜氶┈閫?/span>
<a href="/home" class="btn-home">杩斿洖棣栭〉</a>
</div>
<nav class="nav-tabs">
<span class="nav-tab-group">
<button type="button" class="nav-tab active" data-panel="brandCheck">品牌检测</button>
<button type="button" class="nav-tab" onclick="window.location='/new_web_source/dedupe.html'">总数据去重</button>
<button type="button" class="nav-tab" onclick="window.location='/new_web_source/convert.html'">格式转换</button>
<button type="button" class="nav-tab" onclick="window.location='/new_web_source/split.html'">表格拆分</button>
<!-- 后续增加栏目时在此添加,例如:<button type="button" class="nav-tab" data-panel="other">其他栏目</button> -->
<button type="button" class="nav-tab active" data-panel="brandCheck">鍝佺墝妫€娴?/button>
<button type="button" class="nav-tab" onclick="window.location='/new_web_source/dedupe.html'">鎬绘暟鎹幓閲?/button>
<button type="button" class="nav-tab" onclick="window.location='/new_web_source/convert.html'">鏍煎紡杞崲</button>
<button type="button" class="nav-tab" onclick="window.location='/new_web_source/split.html'">琛ㄦ牸鎷嗗垎</button>
<!-- 鍚庣画澧炲姞鏍忕洰鏃跺湪姝ゆ坊鍔狅紝渚嬪锛?button type="button" class="nav-tab" data-panel="other">鍏朵粬鏍忕洰</button> -->
</span>
<!-- 多组栏目时可用分隔符:<span class="nav-tab-sep" aria-hidden="true"></span> -->
<!-- 澶氱粍鏍忕洰鏃跺彲鐢ㄥ垎闅旂锛?span class="nav-tab-sep" aria-hidden="true"></span> -->
</nav>
<div class="top-right"></div>
</header>
<div class="main-content">
<!-- 品牌检测栏目容器;后续增加栏目时复制此结构,改 data-panel 与 id并去掉 .active -->
<!-- 鍝佺墝妫€娴嬫爮鐩鍣紱鍚庣画澧炲姞鏍忕洰鏃跺鍒舵缁撴瀯锛屾敼 data-panel 涓?id锛屽苟鍘绘帀 .active -->
<div class="tab-panel active" data-panel="brandCheck" id="panel-brandCheck">
<aside class="left-panel">
<div class="section-title">选择文件</div>
<div class="section-title">閫夋嫨鏂囦欢</div>
<div class="upload-zone" id="uploadZone">
<div class="hint">选择 .xlsx 文件或文件夹(将读取该文件夹下所有 xlsx</div>
<div class="hint">閫夋嫨 .xlsx 鏂囦欢鎴栨枃浠跺す锛堝皢璇诲彇璇ユ枃浠跺す涓嬫墍鏈?xlsx锛?/div>
<div class="btns">
<button type="button" class="opt-btn" id="btnSelectFiles">选择 Excel 文件</button>
<button type="button" class="opt-btn" id="btnSelectFolder">选择文件夹</button>
<button type="button" class="opt-btn" id="btnSelectFiles">閫夋嫨 Excel 鏂囦欢</button>
<button type="button" class="opt-btn" id="btnSelectFolder">閫夋嫨鏂囦欢澶?/button>
</div>
<div class="selected-files" id="selectedFiles"></div>
</div>
<div class="section-title">运行方式</div>
<div class="section-title">杩愯鏂瑰紡</div>
<div class="option-group">
<label class="radio-item">
<input type="radio" name="runMode" value="immediate" checked>
<div>
<span class="label">立即运行</span>
<div class="desc">立刻执行,执行完成后可下载结果</div>
<span class="label">绔嬪嵆杩愯</span>
<div class="desc">绔嬪埢鎵ц锛屾墽琛屽畬鎴愬悗鍙笅杞界粨鏋?/div>
</div>
</label>
<label class="radio-item">
<input type="radio" name="runMode" value="task">
<div>
<span class="label">添加任务</span>
<div class="desc">数量较多时建议添加任务,后台执行,可在右侧任务队列查看状态与下载结果</div>
<span class="label">娣诲姞浠诲姟</span>
<div class="desc">鏁伴噺杈冨鏃跺缓璁坊鍔犱换鍔★紝鍚庡彴鎵ц锛屽彲鍦ㄥ彸渚т换鍔¢槦鍒楁煡鐪嬬姸鎬佷笌涓嬭浇缁撴灉</div>
</div>
</label>
</div>
<div class="section-title">匹配方式</div>
<div class="section-title">鍖归厤鏂瑰紡</div>
<div class="option-group">
<label class="radio-item">
<input type="radio" name="matchStrategy" value="Terms" checked>
<div>
<span class="label">精确匹配表达式</span>
<div class="desc">使用精确匹配,适合严格匹配品牌名称</div>
<span class="label">绮剧‘鍖归厤琛ㄨ揪寮?/span>
<div class="desc">浣跨敤绮剧‘鍖归厤锛岄€傚悎涓ユ牸鍖归厤鍝佺墝鍚嶇О</div>
</div>
</label>
<label class="radio-item">
<input type="radio" name="matchStrategy" value="Simple">
<div>
<span class="label">嵌入(结果包含输入的词语)</span>
<div class="desc">使用嵌入策略,结果中包含输入的品牌词语</div>
<span class="label">宓屽叆锛堢粨鏋滃寘鍚緭鍏ョ殑璇嶈锛?/span>
<div class="desc">浣跨敤宓屽叆绛栫暐锛岀粨鏋滀腑鍖呭惈杈撳叆鐨勫搧鐗岃瘝璇?/div>
</div>
</label>
</div>
<div class="run-row" id="runRow">
<div style="display:flex;align-items:center;flex-wrap:wrap;gap:10px;">
<button type="button" class="btn-run" id="btnRun">立即运行</button>
<span class="loading-msg" id="loadingMsg" style="display:none;">生成中,请稍候…</span>
<button type="button" class="btn-run" id="btnRun">绔嬪嵆杩愯</button>
<span class="loading-msg" id="loadingMsg" style="display:none;">鐢熸垚涓紝璇风◢鍊欌€?/span>
<div class="progress-wrap" id="runProgressWrap" style="display:none;">
<div class="progress-bar-bg"><div class="progress-bar-fill" id="runProgressFill" style="width:0%;"></div></div>
<span class="hint" style="font-size:12px;color:#888;" id="runProgressText">0 / 0</span>
</div>
<!-- 当前文件行级进度(不经数据库中转,只读内存接口) -->
<!-- 褰撳墠鏂囦欢琛岀骇杩涘害锛堜笉缁忔暟鎹簱涓浆锛屽彧璇诲唴瀛樻帴鍙o級 -->
<div class="progress-wrap" id="runLineProgressWrap" style="display:none;">
<div class="progress-bar-bg"><div class="progress-bar-fill" id="runLineProgressFill" style="width:0%;"></div></div>
<span class="hint" style="font-size:12px;color:#888;" id="runLineProgressText">0 / 0</span>
</div>
<button type="button" class="btn-cancel-run" id="btnCancelRun" style="display:none;">取消生成</button>
<button type="button" class="btn-cancel-run" id="btnCancelRun" style="display:none;">鍙栨秷鐢熸垚</button>
</div>
</div>
<div class="template-download-row" id="templateDownloadRow" role="button" tabindex="0" title="下载品牌文档格式模板(选择保存位置)">
<div class="template-download-row" id="templateDownloadRow" role="button" tabindex="0" title="涓嬭浇鍝佺墝鏂囨。鏍煎紡妯℃澘锛堥€夋嫨淇濆瓨浣嶇疆锛?>
<span class="template-download-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z" stroke="rgba(255,255,255,0.9)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
@@ -427,11 +427,11 @@
</svg>
</span>
<span class="template-download-text">
<span class="title">模板下载</span>
<span class="hint">品牌文档格式_模板.xlsx · 点击选择保存位置</span>
<span class="title">妯℃澘涓嬭浇</span>
<span class="hint">鍝佺墝鏂囨。鏍煎紡_妯℃澘.xlsx 路 鐐瑰嚮閫夋嫨淇濆瓨浣嶇疆</span>
</span>
</div>
<div class="template-download-row" id="templateDownloadRow2" role="button" tabindex="0" title="下载模板2以文件夹方式上传">
<div class="template-download-row" id="templateDownloadRow2" role="button" tabindex="0" title="涓嬭浇妯℃澘2锛堜互鏂囦欢澶规柟寮忎笂浼狅級">
<span class="template-download-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z" stroke="rgba(255,255,255,0.9)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
@@ -440,20 +440,20 @@
</svg>
</span>
<span class="template-download-text">
<span class="title">模板2下载</span>
<span class="hint">以文件夹方式上传.zip · 点击下载</span>
<span class="title">妯℃澘2涓嬭浇</span>
<span class="hint">浠ユ枃浠跺す鏂瑰紡涓婁紶.zip 路 鐐瑰嚮涓嬭浇</span>
</span>
</div>
</aside>
<div class="right-panel">
<div class="panel-header">
<span class="panel-header-title">任务队列</span>
<button type="button" class="btn-refresh-tasks" id="btnRefreshTasks">刷新任务状态</button>
<span class="panel-header-title">浠诲姟闃熷垪</span>
<button type="button" class="btn-refresh-tasks" id="btnRefreshTasks">鍒锋柊浠诲姟鐘舵€?/button>
</div>
<div class="task-list-wrap">
<ul class="task-list" id="taskList"></ul>
<div class="empty-tasks" id="emptyTasks">暂无任务</div>
<div class="empty-tasks" id="emptyTasks">鏆傛棤浠诲姟</div>
</div>
</div>
</div>
@@ -467,7 +467,7 @@
var cachedTasks = [];
var api = window.pywebview && window.pywebview.api;
// 顶部栏目切换:点击 nav-tab 时显示对应 data-panel tab-panel
// 椤堕儴鏍忕洰鍒囨崲锛氱偣鍑?nav-tab 鏃舵樉绀哄搴?data-panel 鐨?tab-panel
document.querySelectorAll('.nav-tab').forEach(function(tab) {
tab.addEventListener('click', function() {
var panelId = this.getAttribute('data-panel');
@@ -493,18 +493,18 @@
el.innerHTML = '';
return;
}
el.innerHTML = '已选 <b>' + selectedPaths.length + '</b> 个文件:<br>' +
el.innerHTML = '宸查€?<b>' + selectedPaths.length + '</b> 涓枃浠讹細<br>' +
selectedPaths.slice(0, 20).map(function(p) {
var name = p.split(/[/\\]/).pop();
return '<span title="' + p + '">' + name + '</span>';
}).join('') +
(selectedPaths.length > 20 ? '<span>… 等 ' + selectedPaths.length + ' 个</span>' : '');
(selectedPaths.length > 20 ? '<span>鈥?绛?' + selectedPaths.length + ' 涓?/span>' : '');
}
document.getElementById('btnSelectFiles').onclick = async function(e) {
e.stopPropagation();
if (!api || !api.select_brand_xlsx_files) {
showToast('当前环境不支持文件选择,请在本机客户端中打开');
showToast('褰撳墠鐜涓嶆敮鎸佹枃浠堕€夋嫨锛岃鍦ㄦ湰鏈哄鎴风涓墦寮€');
return;
}
try {
@@ -512,10 +512,10 @@
if (paths && paths.length) {
selectedPaths = paths;
renderSelected();
showToast('已选择 ' + paths.length + ' 个文件');
showToast('宸查€夋嫨 ' + paths.length + ' 涓枃浠?);
}
} catch (err) {
showToast('选择失败:' + (err.message || err));
showToast('閫夋嫨澶辫触锛? + (err.message || err));
}
};
@@ -525,16 +525,16 @@
try {
var res = await api.save_template_xlsx();
if (res && res.success) {
showToast('模板已保存至:' + (res.path || ''));
showToast('妯℃澘宸蹭繚瀛樿嚦锛? + (res.path || ''));
} else {
showToast(res && res.error ? res.error : '保存失败');
showToast(res && res.error ? res.error : '淇濆瓨澶辫触');
}
} catch (err) {
showToast('下载失败:' + (err.message || err));
showToast('涓嬭浇澶辫触锛? + (err.message || err));
}
return;
}
window.location.href = '/static/品牌文档格式_模板.xlsx';
window.location.href = '/static/鍝佺墝鏂囨。鏍煎紡_妯℃澘.xlsx';
};
document.getElementById('templateDownloadRow').onkeydown = function(e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.click(); }
@@ -546,16 +546,16 @@
try {
var res = await api.save_template_zip();
if (res && res.success) {
showToast('模板已保存至:' + (res.path || ''));
showToast('妯℃澘宸蹭繚瀛樿嚦锛? + (res.path || ''));
} else {
showToast(res && res.error ? res.error : '保存失败');
showToast(res && res.error ? res.error : '淇濆瓨澶辫触');
}
} catch (err) {
showToast('下载失败:' + (err.message || err));
showToast('涓嬭浇澶辫触锛? + (err.message || err));
}
return;
}
window.location.href = '/static/模板2-以文件夹方式上传.zip';
window.location.href = '/static/妯℃澘2-浠ユ枃浠跺す鏂瑰紡涓婁紶.zip';
};
document.getElementById('templateDownloadRow2').onkeydown = function(e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.click(); }
@@ -564,7 +564,7 @@
document.getElementById('btnSelectFolder').onclick =async function(e) {
e.stopPropagation();
if (!api || !api.select_brand_folder) {
showToast('当前环境不支持文件夹选择,请在本机客户端中打开');
showToast('褰撳墠鐜涓嶆敮鎸佹枃浠跺す閫夋嫨锛岃鍦ㄦ湰鏈哄鎴风涓墦寮€');
return;
}
try {
@@ -581,14 +581,14 @@
if (res.success && res.paths && res.paths.length) {
selectedPaths = res.paths;
renderSelected();
showToast('已选择文件夹内 ' + res.paths.length + ' xlsx 文件');
showToast('宸查€夋嫨鏂囦欢澶瑰唴 ' + res.paths.length + ' 涓?xlsx 鏂囦欢');
} else {
showToast(res.error || '该文件夹下没有 xlsx 文件');
showToast(res.error || '璇ユ枃浠跺す涓嬫病鏈?xlsx 鏂囦欢');
}
})
.catch(function() { showToast('请求失败'); });
.catch(function() { showToast('璇锋眰澶辫触'); });
} catch (err) {
showToast('选择失败:' + (err.message || err));
showToast('閫夋嫨澶辫触锛? + (err.message || err));
}
};
@@ -628,9 +628,9 @@
function onRunTaskFinished(status) {
stopPollAndResetRunUI();
loadTasks();
if (status === 'success') showToast('生成完成,可下载结果');
else if (status === 'failed') showToast('生成失败');
else if (status === 'cancelled') showToast('已取消');
if (status === 'success') showToast('鐢熸垚瀹屾垚锛屽彲涓嬭浇缁撴灉');
else if (status === 'failed') showToast('鐢熸垚澶辫触');
else if (status === 'cancelled') showToast('宸插彇娑?);
}
function pollRunTask(taskId) {
@@ -649,11 +649,11 @@
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.success) onRunTaskFinished('cancelled');
else showToast(res.error || '取消失败');
else showToast(res.error || '鍙栨秷澶辫触');
});
};
// 通过 SSE 订阅任务完成事件,完成后由后端主动推送,前端收到后立即隐藏进度条和取消按钮
// 閫氳繃 SSE 璁㈤槄浠诲姟瀹屾垚浜嬩欢锛屽畬鎴愬悗鐢卞悗绔富鍔ㄦ帹閫侊紝鍓嶇鏀跺埌鍚庣珛鍗抽殣钘忚繘搴︽潯鍜屽彇娑堟寜閽?
var eventsUrl = '/api/brand/tasks/' + taskId + '/events';
runTaskEventSource = new EventSource(eventsUrl);
runTaskEventSource.onmessage = function(e) {
@@ -685,18 +685,18 @@
if (runProgressText) {
var st = (t.status || '').toLowerCase();
if (st === 'running') {
runProgressText.textContent = total ? (cur + ' / ' + total) : '生成中…';
runProgressText.textContent = total ? (cur + ' / ' + total) : '鐢熸垚涓€?;
} else if (st === 'success') {
runProgressText.textContent = '已完成';
runProgressText.textContent = '宸插畬鎴?;
} else if (st === 'failed') {
runProgressText.textContent = '失败';
runProgressText.textContent = '澶辫触';
} else if (st === 'cancelled') {
runProgressText.textContent = '已取消';
runProgressText.textContent = '宸插彇娑?;
} else {
runProgressText.textContent = '执行中…';
runProgressText.textContent = '鎵ц?;
}
}
// 行级进度:从内存接口 /api/brand/tasks/<id>/line-progress 拉取
// 琛岀骇杩涘害锛氫粠鍐呭瓨鎺ュ彛 /api/brand/tasks/<id>/line-progress 鎷夊彇
if (runLineProgressWrap && runLineProgressFill && runLineProgressText) {
var st2 = (t.status || '').toLowerCase();
if (st2 === 'running') {
@@ -715,14 +715,17 @@
var curLine = info.current_line || 0;
runLineProgressWrap.style.display = 'block';
runLineProgressFill.style.width = totalLines ? (100 * curLine / totalLines) + '%' : '0%';
runLineProgressText.textContent = totalLines ? (curLine + ' / ' + totalLines) : '当前文件处理中…';
runLineProgressText.textContent = totalLines ? (curLine + ' / ' + totalLines) : '褰撳墠鏂囦欢澶勭悊涓€?;
})
.catch(function() {});
.catch(function(err) {
console.error('loadTasks failed:', err);
showToast('浠诲姟鍒楄鍒锋柊澶辫触');
});
} else {
runLineProgressWrap.style.display = 'none';
}
}
// 终态也通过轮询兜底,确保 UI 一定被重置SSE 可能因网络未收到)
// 缁堟€佷篃閫氳繃杞鍏滃簳锛岀‘淇?UI 涓€瀹氳閲嶇疆锛圫SE 鍙兘鍥犵綉缁滄湭鏀跺埌锛?
var st = (t.status || '').toLowerCase();
if (st === 'success' || st === 'failed' || st === 'cancelled') {
onRunTaskFinished(st);
@@ -735,7 +738,7 @@
document.getElementById('btnRun').onclick = function() {
if (selectedPaths.length === 0) {
showToast('请先选择 Excel 文件或文件夹');
showToast('璇峰厛閫夋嫨 Excel 鏂囦欢鎴栨枃浠跺す');
return;
}
var mode = getRunMode();
@@ -755,16 +758,17 @@
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.success && res.task_id) {
loadTasks();
loadingMsg.style.display = 'none';
pollRunTask(res.task_id);
} else {
stopPollAndResetRunUI();
showToast(res.error || '运行失败');
showToast(res.error || '杩愯澶辫触');
}
})
.catch(function() {
stopPollAndResetRunUI();
showToast('请求失败或超时');
showToast('璇锋眰澶辫触鎴栬秴鏃?);
});
} else {
fetch('/api/brand/tasks', {
@@ -776,13 +780,13 @@
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.success) {
showToast('任务已添加,请在任务队列查看');
showToast('浠诲姟宸叉坊鍔狅紝璇峰湪浠诲姟闃熷垪鏌ョ湅');
loadTasks();
} else {
showToast(res.error || '添加失败');
showToast(res.error || '娣诲姞澶辫触');
}
})
.catch(function() { showToast('请求失败'); });
.catch(function() { showToast('璇锋眰澶辫触'); });
}
};
@@ -801,8 +805,8 @@
cachedTasks = res.items;
list.innerHTML = res.items.map(function(t) {
var statusClass = (t.status || 'pending').toLowerCase();
var statusText = { pending: '等待中', running: '执行中', success: '已完成', failed: '失败', cancelled: '已取消' }[t.status] || t.status;
var filesDesc = (t.file_paths && t.file_paths.length) ? t.file_paths.length + ' 个文件' : '';
var statusText = { pending: '绛夊緟涓?, running: '鎵ц?, success: '宸插畬鎴?, failed: '澶辫触', cancelled: '宸插彇娑? }[t.status] || t.status;
var filesDesc = (t.file_paths && t.file_paths.length) ? t.file_paths.length + ' 涓枃浠? : '';
var total = t.progress_total || 0;
var cur = t.progress_current || 0;
var pct = total ? Math.min(100, (100 * cur / total)) : 0;
@@ -811,13 +815,13 @@
: '';
var hasZipUrl = t.result_paths && t.result_paths.zip_url;
var downloadHtml = hasZipUrl
? '<a class="download js-save-zip" href="#" data-task-id="' + t.id + '">下载结果</a>'
? '<a class="download js-save-zip" href="#" data-task-id="' + t.id + '">涓嬭浇缁撴灉</a>'
: '<span class="download hide"></span>';
var cancelBtn = statusClass === 'running'
? '<button type="button" class="btn-cancel js-cancel-task" data-task-id="' + t.id + '">取消</button>'
? '<button type="button" class="btn-cancel js-cancel-task" data-task-id="' + t.id + '">鍙栨秷</button>'
: '';
var deleteBtn = '<button type="button" class="btn-delete js-delete-task" data-task-id="' + t.id + '">删除</button>';
var titleDesc = (t.desc && String(t.desc).trim()) ? String(t.desc).trim() : ('任务 #' + t.id);
var deleteBtn = '<button type="button" class="btn-delete js-delete-task" data-task-id="' + t.id + '">鍒犻櫎</button>';
var titleDesc = (t.desc && String(t.desc).trim()) ? String(t.desc).trim() : ('浠诲姟 #' + t.id);
var displayDesc = titleDesc.length > 20 ? titleDesc.substring(0, 20) + '....' : titleDesc;
return '<li class="task-item">' +
'<div class="left">' +
@@ -834,15 +838,18 @@
'</div></li>';
}).join('');
})
.catch(function() {});
.catch(function(err) {
console.error('loadTasks failed:', err);
showToast('浠诲姟鍒楄〃鍒锋柊澶辫触');
});
}
// 手动刷新任务状态按钮
// 鎵嬪姩鍒锋柊浠诲姟鐘舵€佹寜閽?
var btnRefreshTasks = document.getElementById('btnRefreshTasks');
if (btnRefreshTasks) {
btnRefreshTasks.onclick = function () {
loadTasks();
showToast('任务状态已刷新');
showToast('浠诲姟鐘舵€佸凡鍒锋柊');
};
}
@@ -857,7 +864,7 @@
fetch('/api/brand/tasks/' + taskId + '/cancel', { method: 'POST', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.success) { showToast('已取消'); loadTasks(); } else { showToast(res.error || '取消失败'); }
if (res.success) { showToast('宸插彇娑?); loadTasks(); } else { showToast(res.error || '鍙栨秷澶辫触'); }
});
return;
}
@@ -867,7 +874,7 @@
fetch('/api/brand/tasks/' + taskId, { method: 'DELETE', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.success) { showToast('已删除'); loadTasks(); } else { showToast(res.error || '删除失败'); }
if (res.success) { showToast('宸插垹闄?); loadTasks(); } else { showToast(res.error || '鍒犻櫎澶辫触'); }
});
return;
}
@@ -875,7 +882,7 @@
if (!a || !a.classList || !a.classList.contains('js-save-zip')) return;
var task = cachedTasks.filter(function(t) { return String(t.id) === String(taskId); })[0];
if (!task || !task.result_paths || !task.result_paths.zip_url) {
showToast('无法获取下载地址');
showToast('鏃犳硶鑾峰彇涓嬭浇鍦板潃');
return;
}
var zipUrl = task.result_paths.zip_url;
@@ -884,21 +891,21 @@
a.style.pointerEvents = 'none';
var done = function(ret) {
if (ret && ret.success) {
showToast('已保存:' + (ret.path || filename));
} else if (ret && ret.error && ret.error !== '用户取消') {
showToast('保存失败:' + ret.error);
showToast('宸蹭繚瀛橈細' + (ret.path || filename));
} else if (ret && ret.error && ret.error !== '鐢ㄦ埛鍙栨秷') {
showToast('淇濆瓨澶辫触锛? + ret.error);
}
a.style.pointerEvents = '';
};
try {
var ret = api.save_file_from_url(zipUrl, filename);
if (ret && typeof ret.then === 'function') {
ret.then(done).catch(function(err) { showToast('保存失败:' + (err && err.message || err)); a.style.pointerEvents = ''; });
ret.then(done).catch(function(err) { showToast('淇濆瓨澶辫触锛? + (err && err.message || err)); a.style.pointerEvents = ''; });
} else {
done(ret);
}
} catch (err) {
showToast('保存失败:' + (err.message || err));
showToast('淇濆瓨澶辫触锛? + (err.message || err));
a.style.pointerEvents = '';
}
} else {
@@ -907,7 +914,7 @@
});
loadTasks();
// setInterval(loadTasks, 10000);
setInterval(loadTasks, 5000);
if (window.addEventListener) {
window.addEventListener('pywebviewready', function() {
@@ -919,3 +926,4 @@
</script>
</body>
</html>

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>首页 - 南日AI</title>
<title>首页 - 数富AI</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
@@ -186,7 +186,7 @@
</head>
<body data-user-id="{{ user_id or '' }}">
<header class="header">
<span class="header-title">南日AI</span>
<span class="header-title">数富AI</span>
<div class="header-right">
{% if is_admin %}
<!-- <a href="/admin" class="admin-link">用户管理</a>-->
@@ -219,6 +219,7 @@
<script>
// 权限校验:根据 column-permissions 接口按 column_key 显示入口
(function() {
localStorage.setItem("uid",{{ user_id }})
var uid = document.body.getAttribute('data-user-id');
if (!uid) return;
var baseUrl = window.location.origin;

Binary file not shown.

View File

@@ -0,0 +1,15 @@
package com.nanri.aiimage.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.ArrayList;
import java.util.List;
@Data
@ConfigurationProperties(prefix = "aiimage.module-cleanup")
public class ModuleCleanupProperties {
private boolean enabled = true;
private String cron = "0 0 0 * * *";
private List<String> moduleTypes = new ArrayList<>(List.of("DEDUPE", "SPLIT", "CONVERT", "DELETE_BRAND"));
}

View File

@@ -4,6 +4,6 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableConfigurationProperties({OssProperties.class, StorageProperties.class, BrandProgressProperties.class, ZiniaoProperties.class})
@EnableConfigurationProperties({OssProperties.class, StorageProperties.class, BrandProgressProperties.class, ZiniaoProperties.class, ModuleCleanupProperties.class})
public class PropertiesConfig {
}

View File

@@ -9,9 +9,13 @@ public class ZiniaoProperties {
private boolean enabled;
private String baseUrl;
private String apiKey;
private String appId;
private String appSecret;
private Long companyId;
private String shopsPath;
private String switchShopPath;
private String appTokenPath;
private String staffListPath;
private String userStoresPath;
private String userLoginTokenPath;
private String openStoreScheme;
private String openStoreUserId;
private String openStoreLaunchUrl;

View File

@@ -124,8 +124,9 @@ public class BrandTaskController {
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "任务不存在")
})
public ApiResponse<LegacyBrandTaskDetailVo> getTask(
@Parameter(description = "品牌任务 ID", required = true) @PathVariable Long taskId) {
return ApiResponse.success(brandTaskService.getTaskDetailLegacy(taskId));
@Parameter(description = "品牌任务 ID", required = true) @PathVariable Long taskId,
@Parameter(description = "用户 ID", required = true) @RequestParam Long userId) {
return ApiResponse.success(brandTaskService.getTaskDetailLegacy(taskId, userId));
}
@PostMapping("/tasks/{taskId}/result")
@@ -153,8 +154,9 @@ public class BrandTaskController {
})
public ApiResponse<BrandSimpleVo> submitResult(
@Parameter(description = "品牌任务 ID", required = true) @PathVariable Long taskId,
@Parameter(description = "用户 ID", required = true) @RequestParam Long userId,
@Valid @RequestBody BrandCrawlResultRequest request) {
brandTaskService.submitCrawlResult(taskId, request);
brandTaskService.submitCrawlResult(taskId, userId, request);
return ApiResponse.success(new BrandSimpleVo(true));
}
@@ -168,8 +170,9 @@ public class BrandTaskController {
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "任务不存在或当前状态不可取消")
})
public ApiResponse<BrandSimpleVo> cancelTask(
@Parameter(description = "品牌任务 ID", required = true) @PathVariable Long taskId) {
brandTaskService.cancelTask(taskId);
@Parameter(description = "品牌任务 ID", required = true) @PathVariable Long taskId,
@Parameter(description = "用户 ID", required = true) @RequestParam Long userId) {
brandTaskService.cancelTask(taskId, userId);
return ApiResponse.success(new BrandSimpleVo(true));
}
@@ -183,8 +186,9 @@ public class BrandTaskController {
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "任务不存在或正在执行中不可删除")
})
public ApiResponse<BrandSimpleVo> deleteTask(
@Parameter(description = "品牌任务 ID", required = true) @PathVariable Long taskId) {
brandTaskService.deleteTask(taskId);
@Parameter(description = "品牌任务 ID", required = true) @PathVariable Long taskId,
@Parameter(description = "用户 ID", required = true) @RequestParam Long userId) {
brandTaskService.deleteTask(taskId, userId);
return ApiResponse.success(new BrandSimpleVo(true));
}
@@ -198,8 +202,9 @@ public class BrandTaskController {
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "任务不存在或无结果可下载")
})
public ResponseEntity<Void> download(
@Parameter(description = "品牌任务 ID", required = true) @PathVariable Long taskId) {
String url = brandTaskService.resolveDownloadUrl(taskId);
@Parameter(description = "品牌任务 ID", required = true) @PathVariable Long taskId,
@Parameter(description = "用户 ID", required = true) @RequestParam Long userId) {
String url = brandTaskService.resolveDownloadUrl(taskId, userId);
return ResponseEntity.status(302)
.header(HttpHeaders.LOCATION, url)
.build();

View File

@@ -133,8 +133,14 @@ public class BrandTaskService {
return vo;
}
public LegacyBrandTaskDetailVo getTaskDetailLegacy(Long taskId) {
BrandCrawlTaskEntity task = requireTask(taskId);
public BrandTaskDetailVo getTaskDetail(Long taskId, Long userId) {
BrandTaskDetailVo vo = new BrandTaskDetailVo();
vo.setTask(toTaskItem(requireTask(taskId, userId)));
return vo;
}
public LegacyBrandTaskDetailVo getTaskDetailLegacy(Long taskId, Long userId) {
BrandCrawlTaskEntity task = requireTask(taskId, userId);
LegacyBrandTaskDetailVo vo = new LegacyBrandTaskDetailVo();
vo.setTask(toLegacyTaskItem(task));
vo.setLine_progress(buildLineProgress(taskId));
@@ -198,8 +204,8 @@ public class BrandTaskService {
return vo;
}
public void submitCrawlResult(Long taskId, BrandCrawlResultRequest request) {
BrandCrawlTaskEntity task = requireTask(taskId);
public void submitCrawlResult(Long taskId, Long userId, BrandCrawlResultRequest request) {
BrandCrawlTaskEntity task = requireTask(taskId, userId);
if (STATUS_CANCELLED.equalsIgnoreCase(blankToDefault(task.getStatus(), STATUS_PENDING))) {
throw new BusinessException("任务已取消");
}
@@ -304,9 +310,11 @@ public class BrandTaskService {
}
}
public void cancelTask(Long taskId) {
public void cancelTask(Long taskId, Long userId) {
requireTask(taskId, userId);
int updated = brandCrawlTaskMapper.update(null, new LambdaUpdateWrapper<BrandCrawlTaskEntity>()
.eq(BrandCrawlTaskEntity::getId, taskId)
.eq(BrandCrawlTaskEntity::getUserId, userId)
.in(BrandCrawlTaskEntity::getStatus, STATUS_PENDING, STATUS_RUNNING)
.set(BrandCrawlTaskEntity::getStatus, STATUS_CANCELLED));
if (updated == 0) {
@@ -315,17 +323,19 @@ public class BrandTaskService {
brandTaskProgressCacheService.delete(taskId);
}
public void deleteTask(Long taskId) {
public void deleteTask(Long taskId, Long userId) {
requireTask(taskId, userId);
int deleted = brandCrawlTaskMapper.delete(new LambdaQueryWrapper<BrandCrawlTaskEntity>()
.eq(BrandCrawlTaskEntity::getId, taskId)
.eq(BrandCrawlTaskEntity::getUserId, userId)
.ne(BrandCrawlTaskEntity::getStatus, STATUS_RUNNING));
if (deleted == 0) {
throw new BusinessException("任务不存在或正在执行中无法删除");
}
}
public String resolveDownloadUrl(Long taskId) {
BrandCrawlTaskEntity task = requireTask(taskId);
public String resolveDownloadUrl(Long taskId, Long userId) {
BrandCrawlTaskEntity task = requireTask(taskId, userId);
Object raw = parseJsonValue(task.getResultPaths());
if (!(raw instanceof Map<?, ?> map)) {
throw new BusinessException("无结果可下载");
@@ -600,6 +610,17 @@ public class BrandTaskService {
return entity;
}
private BrandCrawlTaskEntity requireTask(Long taskId, Long userId) {
if (userId == null || userId <= 0) {
throw new BusinessException("userId 不合法");
}
BrandCrawlTaskEntity entity = brandCrawlTaskMapper.selectById(taskId);
if (entity == null || !userId.equals(entity.getUserId())) {
throw new BusinessException("任务不存在");
}
return entity;
}
private File resolveSourceFile(BrandSourceFileDto sourceFile) {
String fileUrl = sourceFile.getFileUrl();
if (fileUrl == null || fileUrl.isBlank()) {
@@ -661,11 +682,15 @@ public class BrandTaskService {
String value = index == null ? "" : normalizeCellText(formatter.formatCellValue(row.getCell(index)));
rowData.put(column, value);
}
rowData.put("__rowIndex", rowNum + 1);
rows.add(rowData);
String brand = normalizeCellText(Objects.toString(rowData.getOrDefault("品牌", ""), ""));
if (!brand.isBlank()) {
uniqueBrands.add(brand);
String brand = normalizeCellText(Objects.toString(rowData.getOrDefault("\u54c1\u724c", ""), ""));
if (brand.isBlank()) {
rowData.put("__rowIndex", rowNum + 1);
rows.add(rowData);
continue;
}
if (uniqueBrands.add(brand)) {
rowData.put("__rowIndex", rowNum + 1);
rows.add(rowData);
}
}
return new ParsedBrandFile(sheet.getSheetName(), columns, rows, new ArrayList<>(uniqueBrands));

View File

@@ -7,6 +7,7 @@ import com.nanri.aiimage.modules.convert.model.vo.ConvertRunVo;
import com.nanri.aiimage.modules.convert.service.ConvertRunService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
@@ -24,6 +25,7 @@ import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.io.File;
@@ -66,35 +68,43 @@ public class ConvertRunController {
}
@GetMapping("/history")
@Operation(summary = "查询格式转换历史", description = "查询最近的格式转换成功记录,用于页面右侧历史下载列表展示。")
@Operation(summary = "查询格式转换历史", description = "查询当前用户最近的格式转换成功记录,用于页面右侧历史下载列表展示。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功", content = @Content(schema = @Schema(implementation = ConvertHistoryVo.class)))
})
public ApiResponse<ConvertHistoryVo> history() {
public ApiResponse<ConvertHistoryVo> history(
@Parameter(name = "user_id", description = "当前登录用户 ID", required = true, in = ParameterIn.QUERY)
@RequestParam("user_id") Long userId) {
ConvertHistoryVo vo = new ConvertHistoryVo();
vo.setItems(convertRunService.listHistory());
vo.setItems(convertRunService.listHistory(userId));
return ApiResponse.success(vo);
}
@DeleteMapping("/history/{resultId}")
@Operation(summary = "删除格式转换历史", description = "删除一条格式转换历史记录。")
@Operation(summary = "删除格式转换历史", description = "删除当前用户的一条格式转换历史记录。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "删除成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "结果记录不存在")
})
public ApiResponse<Void> deleteHistory(@Parameter(description = "格式转换结果记录 ID", required = true) @PathVariable Long resultId) {
convertRunService.deleteHistory(resultId);
public ApiResponse<Void> deleteHistory(
@Parameter(description = "格式转换结果记录 ID", required = true) @PathVariable Long resultId,
@Parameter(name = "user_id", description = "当前登录用户 ID", required = true, in = ParameterIn.QUERY)
@RequestParam("user_id") Long userId) {
convertRunService.deleteHistory(resultId, userId);
return ApiResponse.success(null);
}
@GetMapping("/results/{resultId}/download")
@Operation(summary = "下载格式转换结果", description = "根据结果记录 ID 下载已生成的转换结果文件。桌面端前端可继续通过 save_file_from_url 选择保存位置。")
@Operation(summary = "下载格式转换结果", description = "根据结果记录 ID 下载当前用户的转换结果文件。桌面端前端可继续通过 save_file_from_url 选择保存位置。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "返回结果文件流"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "结果文件不存在")
})
public ResponseEntity<Resource> download(@Parameter(description = "格式转换结果记录 ID", required = true) @PathVariable Long resultId) {
File resultFile = convertRunService.getResultFile(resultId);
public ResponseEntity<Resource> download(
@Parameter(description = "格式转换结果记录 ID", required = true) @PathVariable Long resultId,
@Parameter(name = "user_id", description = "当前登录用户 ID", required = true, in = ParameterIn.QUERY)
@RequestParam("user_id") Long userId) {
File resultFile = convertRunService.getResultFile(resultId, userId);
String encodedFilename = URLEncoder.encode(resultFile.getName(), StandardCharsets.UTF_8).replaceAll("\\+", "%20");
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM)

View File

@@ -1,9 +1,11 @@
package com.nanri.aiimage.modules.convert.model.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.List;
@@ -23,4 +25,9 @@ public class ConvertRunRequest {
@Schema(description = "文件夹上传场景下的总压缩包名称(不含扩展名)")
private String archiveName;
@NotNull(message = "user_id 不能为空")
@JsonProperty("user_id")
@Schema(description = "当前登录用户 ID")
private Long userId;
}

View File

@@ -71,7 +71,8 @@ public class ConvertRunService {
task.setTaskMode("IMMEDIATE");
task.setStatus("SUCCESS");
task.setSourceFileCount(request.getFiles().size());
task.setCreatedBy("system");
task.setCreatedBy("user:" + request.getUserId());
task.setUserId(request.getUserId());
task.setRequestJson(JSONUtil.toJsonStr(request));
task.setCreatedAt(LocalDateTime.now());
task.setUpdatedAt(LocalDateTime.now());
@@ -130,6 +131,7 @@ public class ConvertRunService {
resultEntity.setResultFileSize(packagedResultFile.length());
resultEntity.setResultContentType(contentType);
resultEntity.setSuccess(1);
resultEntity.setUserId(request.getUserId());
resultEntity.setCreatedAt(LocalDateTime.now());
fileResultMapper.insert(resultEntity);
item.setResultId(resultEntity.getId());
@@ -150,6 +152,7 @@ public class ConvertRunService {
resultEntity.setSourceFilename(sourceFile.getOriginalFilename());
resultEntity.setSuccess(0);
resultEntity.setErrorMessage(ex.getMessage());
resultEntity.setUserId(request.getUserId());
resultEntity.setCreatedAt(LocalDateTime.now());
fileResultMapper.insert(resultEntity);
}
@@ -175,6 +178,7 @@ public class ConvertRunService {
resultEntity.setResultFileSize(zipFile.length());
resultEntity.setResultContentType("application/zip");
resultEntity.setSuccess(1);
resultEntity.setUserId(request.getUserId());
resultEntity.setCreatedAt(LocalDateTime.now());
fileResultMapper.insert(resultEntity);
item.setResultId(resultEntity.getId());
@@ -195,9 +199,10 @@ public class ConvertRunService {
return vo;
}
public List<ConvertResultItemVo> listHistory() {
public List<ConvertResultItemVo> listHistory(Long userId) {
return fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
.eq(FileResultEntity::getUserId, userId)
.orderByDesc(FileResultEntity::getCreatedAt)
.last("limit 200"))
.stream()
@@ -214,17 +219,17 @@ public class ConvertRunService {
.toList();
}
public void deleteHistory(Long resultId) {
public void deleteHistory(Long resultId, Long userId) {
FileResultEntity entity = fileResultMapper.selectById(resultId);
if (entity == null || !MODULE_TYPE.equals(entity.getModuleType())) {
if (entity == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
throw new BusinessException("Convert result record does not exist.");
}
fileResultMapper.deleteById(resultId);
}
public File getResultFile(Long resultId) {
public File getResultFile(Long resultId, Long userId) {
FileResultEntity entity = fileResultMapper.selectById(resultId);
if (entity == null || entity.getResultFileUrl() == null || !MODULE_TYPE.equals(entity.getModuleType())) {
if (entity == null || entity.getResultFileUrl() == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
throw new BusinessException("Convert result file does not exist.");
}

View File

@@ -7,6 +7,7 @@ import com.nanri.aiimage.modules.dedupe.model.vo.DedupeRunVo;
import com.nanri.aiimage.modules.dedupe.service.DedupeRunService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
@@ -23,6 +24,7 @@ import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
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
@@ -60,35 +62,43 @@ public class DedupeRunController {
}
@GetMapping("/history")
@Operation(summary = "查询去重历史", description = "查询最近的去重成功记录,用于页面右侧历史下载列表展示。")
@Operation(summary = "查询去重历史", description = "查询当前用户最近的去重成功记录,用于页面右侧历史下载列表展示。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功", content = @Content(schema = @Schema(implementation = DedupeHistoryVo.class)))
})
public ApiResponse<DedupeHistoryVo> history() {
public ApiResponse<DedupeHistoryVo> history(
@Parameter(name = "user_id", description = "当前登录用户 ID", required = true, in = ParameterIn.QUERY)
@RequestParam("user_id") Long userId) {
DedupeHistoryVo vo = new DedupeHistoryVo();
vo.setItems(dedupeRunService.listHistory());
vo.setItems(dedupeRunService.listHistory(userId));
return ApiResponse.success(vo);
}
@DeleteMapping("/history/{resultId}")
@Operation(summary = "删除去重历史", description = "删除一条去重历史记录。")
@Operation(summary = "删除去重历史", description = "删除当前用户的一条去重历史记录。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "删除成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "结果记录不存在")
})
public ApiResponse<Void> deleteHistory(@Parameter(description = "去重结果记录 ID", required = true) @PathVariable Long resultId) {
dedupeRunService.deleteHistory(resultId);
public ApiResponse<Void> deleteHistory(
@Parameter(description = "去重结果记录 ID", required = true) @PathVariable Long resultId,
@Parameter(name = "user_id", description = "当前登录用户 ID", required = true, in = ParameterIn.QUERY)
@RequestParam("user_id") Long userId) {
dedupeRunService.deleteHistory(resultId, userId);
return ApiResponse.success(null);
}
@GetMapping("/results/{resultId}/download")
@Operation(summary = "下载数据去重结果", description = "根据结果记录 ID 下载清洗后 Excel 文件。")
@Operation(summary = "下载数据去重结果", description = "根据结果记录 ID 下载当前用户的清洗后 Excel 文件。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "返回结果文件流"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "结果文件不存在")
})
public ResponseEntity<Resource> download(@Parameter(description = "去重结果记录 ID", required = true) @PathVariable Long resultId) {
Resource resource = dedupeRunService.getResultFile(resultId);
public ResponseEntity<Resource> download(
@Parameter(description = "去重结果记录 ID", required = true) @PathVariable Long resultId,
@Parameter(name = "user_id", description = "当前登录用户 ID", required = true, in = ParameterIn.QUERY)
@RequestParam("user_id") Long userId) {
Resource resource = dedupeRunService.getResultFile(resultId, userId);
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment")

View File

@@ -1,8 +1,10 @@
package com.nanri.aiimage.modules.dedupe.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;
@@ -31,4 +33,9 @@ public class DedupeRunRequest {
@Schema(description = "文件夹上传场景下的总压缩包名称(不含扩展名)")
private String archiveName;
@NotNull(message = "user_id 不能为空")
@JsonProperty("user_id")
@Schema(description = "当前登录用户 ID")
private Long userId;
}

View File

@@ -62,7 +62,8 @@ public class DedupeRunService {
task.setTaskMode("IMMEDIATE");
task.setStatus("SUCCESS");
task.setSourceFileCount(request.getFiles().size());
task.setCreatedBy("system");
task.setCreatedBy("user:" + request.getUserId());
task.setUserId(request.getUserId());
task.setRequestJson(JSONUtil.toJsonStr(request));
task.setCreatedAt(LocalDateTime.now());
task.setUpdatedAt(LocalDateTime.now());
@@ -123,6 +124,7 @@ public class DedupeRunService {
resultEntity.setResultFileSize(outputFile.length());
resultEntity.setResultContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
resultEntity.setSuccess(1);
resultEntity.setUserId(request.getUserId());
resultEntity.setCreatedAt(LocalDateTime.now());
fileResultMapper.insert(resultEntity);
} catch (Exception ex) {
@@ -136,6 +138,7 @@ public class DedupeRunService {
resultEntity.setSourceFilename(sourceFile.getOriginalFilename());
resultEntity.setSuccess(0);
resultEntity.setErrorMessage(ex.getMessage());
resultEntity.setUserId(request.getUserId());
resultEntity.setCreatedAt(LocalDateTime.now());
fileResultMapper.insert(resultEntity);
}
@@ -162,6 +165,7 @@ public class DedupeRunService {
resultEntity.setResultFileSize(zipFile.length());
resultEntity.setResultContentType("application/zip");
resultEntity.setSuccess(1);
resultEntity.setUserId(request.getUserId());
resultEntity.setCreatedAt(LocalDateTime.now());
fileResultMapper.insert(resultEntity);
item.setResultId(resultEntity.getId());
@@ -182,9 +186,10 @@ public class DedupeRunService {
return vo;
}
public List<DedupeResultItemVo> listHistory() {
public List<DedupeResultItemVo> listHistory(Long userId) {
return fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getModuleType, "DEDUPE")
.eq(FileResultEntity::getUserId, userId)
.orderByDesc(FileResultEntity::getCreatedAt)
.last("limit 50"))
.stream()
@@ -201,17 +206,17 @@ public class DedupeRunService {
.toList();
}
public void deleteHistory(Long resultId) {
public void deleteHistory(Long resultId, Long userId) {
FileResultEntity entity = fileResultMapper.selectById(resultId);
if (entity == null || !"DEDUPE".equals(entity.getModuleType())) {
if (entity == null || !"DEDUPE".equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
throw new BusinessException("记录不存在");
}
fileResultMapper.deleteById(resultId);
}
public Resource getResultFile(Long resultId) {
public Resource getResultFile(Long resultId, Long userId) {
FileResultEntity entity = fileResultMapper.selectById(resultId);
if (entity == null || entity.getResultFileUrl() == null || !"DEDUPE".equals(entity.getModuleType())) {
if (entity == null || entity.getResultFileUrl() == null || !"DEDUPE".equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
throw new BusinessException("结果文件不存在");
}
File file = new File(entity.getResultFileUrl());

View File

@@ -0,0 +1,60 @@
package com.nanri.aiimage.modules.deletebrand.controller;
import com.nanri.aiimage.common.api.ApiResponse;
import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandRunRequest;
import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandHistoryVo;
import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandRunVo;
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
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.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/delete-brand")
@Tag(name = "删除品牌", description = "按每个上传 Excel 的文件名匹配紫鸟店铺,并解析删除品牌表格预览。")
public class DeleteBrandRunController {
private final DeleteBrandRunService deleteBrandRunService;
@PostMapping("/run")
@Operation(summary = "执行删除品牌解析", description = "读取上传的删除品牌 Excel按每个 Excel 文件名匹配紫鸟店铺,并返回受限预览数据和紫鸟店铺打开链接。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "执行成功", content = @Content(schema = @Schema(implementation = DeleteBrandRunVo.class)))
})
public ApiResponse<DeleteBrandRunVo> run(@Valid @RequestBody DeleteBrandRunRequest request) {
return ApiResponse.success(deleteBrandRunService.run(request));
}
@GetMapping("/history")
@Operation(summary = "查询删除品牌历史", description = "按 user_id 查询删除品牌历史。")
public ApiResponse<DeleteBrandHistoryVo> history(
@Parameter(name = "user_id", description = "当前登录用户 ID", required = true, in = ParameterIn.QUERY)
@RequestParam("user_id") Long userId) {
return ApiResponse.success(deleteBrandRunService.listHistory(userId));
}
@DeleteMapping("/history/{resultId}")
@Operation(summary = "删除删除品牌历史", description = "按 user_id 删除一条删除品牌历史。")
public ApiResponse<Void> deleteHistory(
@PathVariable Long resultId,
@Parameter(name = "user_id", description = "当前登录用户 ID", required = true, in = ParameterIn.QUERY)
@RequestParam("user_id") Long userId) {
deleteBrandRunService.deleteHistory(resultId, userId);
return ApiResponse.success(null);
}
}

View File

@@ -0,0 +1,24 @@
package com.nanri.aiimage.modules.deletebrand.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 = "删除品牌执行请求")
public class DeleteBrandRunRequest {
@Valid
@NotEmpty(message = "请先上传待处理文件")
@Schema(description = "已上传源文件列表")
private List<DeleteBrandSourceFileDto> files;
@NotNull(message = "user_id 不能为空")
@JsonProperty("user_id")
@Schema(description = "当前登录用户 ID")
private Long userId;
}

View File

@@ -0,0 +1,17 @@
package com.nanri.aiimage.modules.deletebrand.model.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "删除品牌源文件")
public class DeleteBrandSourceFileDto {
@Schema(description = "上传文件 key")
private String fileKey;
@Schema(description = "原始文件名")
private String originalFilename;
@Schema(description = "相对路径,仅用于批量上传展示和定位,不参与店铺匹配")
private String relativePath;
}

View File

@@ -0,0 +1,14 @@
package com.nanri.aiimage.modules.deletebrand.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 DeleteBrandHistoryVo {
@Schema(description = "历史列表")
private List<DeleteBrandResultItemVo> items = new ArrayList<>();
}

View File

@@ -0,0 +1,20 @@
package com.nanri.aiimage.modules.deletebrand.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "删除品牌预览行")
public class DeleteBrandPreviewRowVo {
@Schema(description = "行号")
private Integer rowIndex;
@Schema(description = "国家")
private String country;
@Schema(description = "删除ASIN")
private String asin;
@Schema(description = "状态")
private String status;
}

View File

@@ -0,0 +1,47 @@
package com.nanri.aiimage.modules.deletebrand.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 DeleteBrandResultItemVo {
@Schema(description = "结果记录ID")
private Long resultId;
@Schema(description = "源文件名")
private String sourceFilename;
@Schema(description = "按 Excel 文件名解析出的店铺名")
private String shopName;
@Schema(description = "是否匹配到紫鸟店铺")
private boolean matched;
@Schema(description = "紫鸟店铺ID")
private String shopId;
@Schema(description = "紫鸟店铺平台")
private String platform;
@Schema(description = "紫鸟打开链接")
private String openStoreUrl;
@Schema(description = "总行数")
private Integer totalRows;
@Schema(description = "是否已截断")
private boolean truncated;
@Schema(description = "预览行")
private List<DeleteBrandPreviewRowVo> previewRows = new ArrayList<>();
@Schema(description = "是否成功")
private boolean success;
@Schema(description = "错误信息")
private String error;
}

View File

@@ -0,0 +1,23 @@
package com.nanri.aiimage.modules.deletebrand.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 DeleteBrandRunVo {
@Schema(description = "处理文件总数")
private Integer total;
@Schema(description = "成功文件数")
private Integer successCount;
@Schema(description = "失败文件数")
private Integer failedCount;
@Schema(description = "结果列表")
private List<DeleteBrandResultItemVo> items = new ArrayList<>();
}

View File

@@ -0,0 +1,291 @@
package com.nanri.aiimage.modules.deletebrand.service;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.IdUtil;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandRunRequest;
import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandSourceFileDto;
import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandHistoryVo;
import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandPreviewRowVo;
import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandResultItemVo;
import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandRunVo;
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 com.nanri.aiimage.modules.ziniao.model.cache.ZiniaoShopCacheDto;
import com.nanri.aiimage.modules.ziniao.service.ZiniaoAuthService;
import lombok.RequiredArgsConstructor;
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.springframework.stereotype.Service;
import java.io.File;
import java.io.FileInputStream;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
@Service
@RequiredArgsConstructor
public class DeleteBrandRunService {
private static final String MODULE_TYPE = "DELETE_BRAND";
private static final int PREVIEW_LIMIT = 200;
private final FileTaskMapper fileTaskMapper;
private final FileResultMapper fileResultMapper;
private final LocalFileStorageService localFileStorageService;
private final ZiniaoAuthService ziniaoAuthService;
public DeleteBrandRunVo run(DeleteBrandRunRequest request) {
if (request.getUserId() == null || request.getUserId() <= 0) {
throw new BusinessException("user_id 不合法");
}
FileTaskEntity task = new FileTaskEntity();
task.setTaskNo(MODULE_TYPE + "-" + IdUtil.getSnowflakeNextIdStr());
task.setModuleType(MODULE_TYPE);
task.setTaskMode("IMMEDIATE");
task.setStatus("SUCCESS");
task.setSourceFileCount(request.getFiles().size());
task.setCreatedBy("user:" + request.getUserId());
task.setUserId(request.getUserId());
task.setRequestJson(JSONUtil.toJsonStr(request));
task.setCreatedAt(LocalDateTime.now());
task.setUpdatedAt(LocalDateTime.now());
task.setFinishedAt(LocalDateTime.now());
fileTaskMapper.insert(task);
List<ZiniaoShopCacheDto> shops = ziniaoAuthService.listShopsForConfiguredUser();
List<DeleteBrandResultItemVo> items = new ArrayList<>();
int successCount = 0;
int failedCount = 0;
for (DeleteBrandSourceFileDto sourceFile : request.getFiles()) {
DeleteBrandResultItemVo item = new DeleteBrandResultItemVo();
item.setSourceFilename(sourceFile.getOriginalFilename());
item.setShopName(resolveShopName(sourceFile));
try {
File inputFile = localFileStorageService.findLocalSourceFile(sourceFile.getFileKey());
if (inputFile == null || !inputFile.exists()) {
throw new BusinessException("上传文件不存在,请重新上传");
}
ParsedDeleteBrandFile parsed = parseDeleteBrandFile(inputFile);
item.setTotalRows(parsed.totalRows());
item.setTruncated(parsed.truncated());
item.setPreviewRows(parsed.previewRows());
ZiniaoShopCacheDto matchedShop = matchShop(item.getShopName(), shops);
if (matchedShop != null) {
item.setMatched(true);
item.setShopId(matchedShop.getShopId());
item.setPlatform(matchedShop.getPlatform());
item.setOpenStoreUrl(ziniaoAuthService.buildOpenStoreUrlForShop(matchedShop));
} else {
item.setMatched(false);
}
item.setSuccess(true);
successCount++;
FileResultEntity resultEntity = new FileResultEntity();
resultEntity.setTaskId(task.getId());
resultEntity.setModuleType(MODULE_TYPE);
resultEntity.setSourceFilename(item.getSourceFilename());
resultEntity.setResultFilename(item.getShopName());
resultEntity.setResultFileUrl(null);
resultEntity.setResultFileSize(0L);
resultEntity.setResultContentType("application/json");
resultEntity.setSuccess(1);
resultEntity.setUserId(request.getUserId());
resultEntity.setCreatedAt(LocalDateTime.now());
fileResultMapper.insert(resultEntity);
item.setResultId(resultEntity.getId());
} catch (Exception ex) {
item.setSuccess(false);
item.setError(ex.getMessage());
failedCount++;
FileResultEntity resultEntity = new FileResultEntity();
resultEntity.setTaskId(task.getId());
resultEntity.setModuleType(MODULE_TYPE);
resultEntity.setSourceFilename(sourceFile.getOriginalFilename());
resultEntity.setSuccess(0);
resultEntity.setErrorMessage(ex.getMessage());
resultEntity.setUserId(request.getUserId());
resultEntity.setCreatedAt(LocalDateTime.now());
fileResultMapper.insert(resultEntity);
item.setResultId(resultEntity.getId());
}
items.add(item);
}
task.setSuccessFileCount(successCount);
task.setFailedFileCount(failedCount);
task.setResultJson(JSONUtil.toJsonStr(items));
task.setUpdatedAt(LocalDateTime.now());
fileTaskMapper.updateById(task);
DeleteBrandRunVo vo = new DeleteBrandRunVo();
vo.setTotal(request.getFiles().size());
vo.setSuccessCount(successCount);
vo.setFailedCount(failedCount);
vo.setItems(items);
return vo;
}
public DeleteBrandHistoryVo listHistory(Long userId) {
DeleteBrandHistoryVo vo = new DeleteBrandHistoryVo();
vo.setItems(fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
.eq(FileResultEntity::getUserId, userId)
.orderByDesc(FileResultEntity::getCreatedAt)
.last("limit 100"))
.stream()
.map(entity -> {
DeleteBrandResultItemVo item = new DeleteBrandResultItemVo();
item.setResultId(entity.getId());
item.setSourceFilename(entity.getSourceFilename());
item.setShopName(entity.getResultFilename());
item.setSuccess(entity.getSuccess() != null && entity.getSuccess() == 1);
item.setError(entity.getErrorMessage());
return item;
})
.toList());
return vo;
}
public void deleteHistory(Long resultId, Long userId) {
FileResultEntity entity = fileResultMapper.selectById(resultId);
if (entity == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
throw new BusinessException("记录不存在");
}
fileResultMapper.deleteById(resultId);
}
private ParsedDeleteBrandFile parseDeleteBrandFile(File inputFile) {
DataFormatter formatter = new DataFormatter();
try (FileInputStream fis = new FileInputStream(inputFile); Workbook workbook = org.apache.poi.ss.usermodel.WorkbookFactory.create(fis)) {
Sheet sheet = workbook.getSheetAt(0);
Row titleRow = sheet.getRow(0);
Row headerRow = sheet.getRow(1);
if (titleRow == null || headerRow == null) {
throw new BusinessException("Excel 表头不完整");
}
List<CountryColumnPair> pairs = resolveCountryPairs(titleRow, headerRow, formatter);
if (pairs.isEmpty()) {
throw new BusinessException("未识别到删除品牌表头");
}
List<DeleteBrandPreviewRowVo> previewRows = new ArrayList<>();
int totalRows = 0;
boolean truncated = false;
for (int rowNum = 2; rowNum <= sheet.getLastRowNum(); rowNum++) {
Row row = sheet.getRow(rowNum);
if (row == null) {
continue;
}
for (CountryColumnPair pair : pairs) {
String asin = normalizeCellText(formatter.formatCellValue(row.getCell(pair.asinColumnIndex())));
String status = normalizeCellText(formatter.formatCellValue(row.getCell(pair.statusColumnIndex())));
if (asin.isBlank() && status.isBlank()) {
continue;
}
totalRows++;
if (previewRows.size() < PREVIEW_LIMIT) {
DeleteBrandPreviewRowVo item = new DeleteBrandPreviewRowVo();
item.setRowIndex(rowNum + 1);
item.setCountry(pair.country());
item.setAsin(asin);
item.setStatus(status);
previewRows.add(item);
} else {
truncated = true;
}
}
}
return new ParsedDeleteBrandFile(totalRows, truncated, previewRows);
} catch (BusinessException ex) {
throw ex;
} catch (Exception ex) {
throw new BusinessException("读取删除品牌 Excel 失败");
}
}
private List<CountryColumnPair> resolveCountryPairs(Row titleRow, Row headerRow, DataFormatter formatter) {
List<CountryColumnPair> pairs = new ArrayList<>();
int lastCellNum = Math.max(titleRow.getLastCellNum(), headerRow.getLastCellNum());
for (int i = 0; i < lastCellNum; i++) {
String title = normalizeCellText(formatter.formatCellValue(titleRow.getCell(i)));
String firstHeader = normalizeCellText(formatter.formatCellValue(headerRow.getCell(i)));
String secondHeader = normalizeCellText(formatter.formatCellValue(headerRow.getCell(i + 1)));
if (title.isBlank()) {
continue;
}
if ("删除ASIN".equals(firstHeader) && "状态".equals(secondHeader)) {
pairs.add(new CountryColumnPair(title, i, i + 1));
i++;
}
}
return pairs;
}
private ZiniaoShopCacheDto matchShop(String shopName, List<ZiniaoShopCacheDto> shops) {
if (shopName == null || shopName.isBlank() || shops == null || shops.isEmpty()) {
return null;
}
String normalized = normalizeShopName(shopName);
for (ZiniaoShopCacheDto shop : shops) {
if (normalized.equals(normalizeShopName(shop.getShopName()))) {
return shop;
}
}
return null;
}
private String resolveShopName(DeleteBrandSourceFileDto sourceFile) {
String name = sourceFile.getOriginalFilename();
if (name == null || name.isBlank()) {
return "";
}
return FileUtil.mainName(name).trim();
}
private String normalizeShopName(String value) {
if (value == null) {
return "";
}
return value.replace("\u3000", " ")
.replace(" ", "")
.trim()
.toLowerCase(Locale.ROOT);
}
private String normalizeCellText(String value) {
if (value == null) {
return "";
}
return value.replace("\ufeff", "")
.replace("\u3000", " ")
.replace("\r\n", " ")
.replace("\r", " ")
.replace("\n", " ")
.replace("\t", " ")
.trim()
.replaceAll("\\s+", " ");
}
private record CountryColumnPair(String country, int asinColumnIndex, int statusColumnIndex) {
}
private record ParsedDeleteBrandFile(int totalRows, boolean truncated, List<DeleteBrandPreviewRowVo> previewRows) {
}
}

View File

@@ -7,6 +7,7 @@ import com.nanri.aiimage.modules.split.model.vo.SplitRunVo;
import com.nanri.aiimage.modules.split.service.SplitRunService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
@@ -23,6 +24,7 @@ import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
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
@@ -59,35 +61,43 @@ public class SplitRunController {
}
@GetMapping("/history")
@Operation(summary = "查询数据拆分历史", description = "查询最近的数据拆分成功记录,用于页面右侧历史下载列表展示。")
@Operation(summary = "查询数据拆分历史", description = "查询当前用户最近的数据拆分成功记录,用于页面右侧历史下载列表展示。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功", content = @Content(schema = @Schema(implementation = SplitHistoryVo.class)))
})
public ApiResponse<SplitHistoryVo> history() {
public ApiResponse<SplitHistoryVo> history(
@Parameter(name = "user_id", description = "当前登录用户 ID", required = true, in = ParameterIn.QUERY)
@RequestParam("user_id") Long userId) {
SplitHistoryVo vo = new SplitHistoryVo();
vo.setItems(splitRunService.listHistory());
vo.setItems(splitRunService.listHistory(userId));
return ApiResponse.success(vo);
}
@DeleteMapping("/history/{resultId}")
@Operation(summary = "删除数据拆分历史", description = "删除一条数据拆分历史记录。")
@Operation(summary = "删除数据拆分历史", description = "删除当前用户的一条数据拆分历史记录。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "删除成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "结果记录不存在")
})
public ApiResponse<Void> deleteHistory(@Parameter(description = "数据拆分结果记录 ID", required = true) @PathVariable Long resultId) {
splitRunService.deleteHistory(resultId);
public ApiResponse<Void> deleteHistory(
@Parameter(description = "数据拆分结果记录 ID", required = true) @PathVariable Long resultId,
@Parameter(name = "user_id", description = "当前登录用户 ID", required = true, in = ParameterIn.QUERY)
@RequestParam("user_id") Long userId) {
splitRunService.deleteHistory(resultId, userId);
return ApiResponse.success(null);
}
@GetMapping("/results/{resultId}/download")
@Operation(summary = "下载数据拆分结果", description = "根据结果记录 ID 下载拆分后的 Excel 文件。")
@Operation(summary = "下载数据拆分结果", description = "根据结果记录 ID 下载当前用户的拆分结果文件。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "返回结果文件流"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "结果文件不存在")
})
public ResponseEntity<Resource> download(@Parameter(description = "数据拆分结果记录 ID", required = true) @PathVariable Long resultId) {
Resource resource = splitRunService.getResultFile(resultId);
public ResponseEntity<Resource> download(
@Parameter(description = "数据拆分结果记录 ID", required = true) @PathVariable Long resultId,
@Parameter(name = "user_id", description = "当前登录用户 ID", required = true, in = ParameterIn.QUERY)
@RequestParam("user_id") Long userId) {
Resource resource = splitRunService.getResultFile(resultId, userId);
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment")

View File

@@ -1,9 +1,11 @@
package com.nanri.aiimage.modules.split.model.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.List;
@@ -33,4 +35,9 @@ public class SplitRunRequest {
@Schema(description = "文件夹上传场景下的总压缩包名称(不含扩展名)")
private String archiveName;
@NotNull(message = "user_id 不能为空")
@JsonProperty("user_id")
@Schema(description = "当前登录用户 ID")
private Long userId;
}

View File

@@ -67,7 +67,8 @@ public class SplitRunService {
task.setTaskMode("IMMEDIATE");
task.setStatus("SUCCESS");
task.setSourceFileCount(request.getFiles().size());
task.setCreatedBy("system");
task.setCreatedBy("user:" + request.getUserId());
task.setUserId(request.getUserId());
task.setRequestJson(JSONUtil.toJsonStr(request));
task.setCreatedAt(LocalDateTime.now());
task.setUpdatedAt(LocalDateTime.now());
@@ -113,6 +114,7 @@ public class SplitRunService {
resultEntity.setSourceFilename(sourceFile.getOriginalFilename());
resultEntity.setSuccess(0);
resultEntity.setErrorMessage(ex.getMessage());
resultEntity.setUserId(request.getUserId());
resultEntity.setCreatedAt(LocalDateTime.now());
fileResultMapper.insert(resultEntity);
}
@@ -144,6 +146,7 @@ public class SplitRunService {
resultEntity.setResultContentType(ZIP_CONTENT_TYPE);
resultEntity.setRowCount(item.getRowCount());
resultEntity.setSuccess(1);
resultEntity.setUserId(request.getUserId());
resultEntity.setCreatedAt(LocalDateTime.now());
fileResultMapper.insert(resultEntity);
item.setResultId(resultEntity.getId());
@@ -164,9 +167,10 @@ public class SplitRunService {
return vo;
}
public List<SplitResultItemVo> listHistory() {
public List<SplitResultItemVo> listHistory(Long userId) {
return fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
.eq(FileResultEntity::getUserId, userId)
.orderByDesc(FileResultEntity::getCreatedAt)
.last("limit 100"))
.stream()
@@ -186,17 +190,17 @@ public class SplitRunService {
.toList();
}
public void deleteHistory(Long resultId) {
public void deleteHistory(Long resultId, Long userId) {
FileResultEntity entity = fileResultMapper.selectById(resultId);
if (entity == null || !MODULE_TYPE.equals(entity.getModuleType())) {
if (entity == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
throw new BusinessException("Split result record does not exist.");
}
fileResultMapper.deleteById(resultId);
}
public Resource getResultFile(Long resultId) {
public Resource getResultFile(Long resultId, Long userId) {
FileResultEntity entity = fileResultMapper.selectById(resultId);
if (entity == null || entity.getResultFileUrl() == null || !MODULE_TYPE.equals(entity.getModuleType())) {
if (entity == null || entity.getResultFileUrl() == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
throw new BusinessException("Split result file does not exist.");
}

View File

@@ -24,5 +24,6 @@ public class FileResultEntity {
private Integer rowCount;
private Integer success;
private String errorMessage;
private Long userId;
private LocalDateTime createdAt;
}

View File

@@ -24,6 +24,7 @@ public class FileTaskEntity {
private String resultJson;
private String errorMessage;
private String createdBy;
private Long userId;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private LocalDateTime finishedAt;

View File

@@ -0,0 +1,52 @@
package com.nanri.aiimage.modules.task.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.nanri.aiimage.config.ModuleCleanupProperties;
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.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class ModuleHistoryCleanupService {
private final ModuleCleanupProperties moduleCleanupProperties;
private final FileTaskMapper fileTaskMapper;
private final FileResultMapper fileResultMapper;
@Transactional
@Scheduled(cron = "${aiimage.module-cleanup.cron:0 0 0 * * *}")
public void cleanupConfiguredModules() {
if (!moduleCleanupProperties.isEnabled()) {
return;
}
List<String> moduleTypes = moduleCleanupProperties.getModuleTypes();
if (moduleTypes == null || moduleTypes.isEmpty()) {
return;
}
int deletedResults = fileResultMapper.delete(new LambdaQueryWrapper<FileResultEntity>()
.in(FileResultEntity::getModuleType, moduleTypes));
int resetTasks = fileTaskMapper.update(null, new LambdaUpdateWrapper<FileTaskEntity>()
.in(FileTaskEntity::getModuleType, moduleTypes)
.set(FileTaskEntity::getResultJson, null)
.set(FileTaskEntity::getRequestJson, null)
.set(FileTaskEntity::getErrorMessage, null));
int deletedTasks = fileTaskMapper.delete(new LambdaQueryWrapper<FileTaskEntity>()
.in(FileTaskEntity::getModuleType, moduleTypes));
log.info("模块历史清理完成: moduleTypes={}, deletedResults={}, resetTasks={}, deletedTasks={}", moduleTypes, deletedResults, resetTasks, deletedTasks);
}
}

View File

@@ -1,9 +1,18 @@
package com.nanri.aiimage.modules.ziniao.client;
import com.nanri.aiimage.modules.ziniao.model.cache.ZiniaoShopCacheDto;
import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoStaffItemVo;
import java.util.List;
public interface ZiniaoClient {
List<ZiniaoShopCacheDto> listShops();
String getAppToken();
Long getCompanyIdByApiKey();
List<ZiniaoStaffItemVo> listStaff(Long companyId);
List<ZiniaoShopCacheDto> listUserStores(Long companyId, Long userId, String userToken);
String getUserLoginToken(Long companyId, Long userId);
}

View File

@@ -5,6 +5,8 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.config.ZiniaoProperties;
import com.nanri.aiimage.modules.ziniao.model.cache.ZiniaoShopCacheDto;
import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoStaffItemVo;
import com.nanri.aiimage.modules.ziniao.service.ZiniaoSessionCacheService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
@@ -13,6 +15,7 @@ import org.springframework.web.client.RestClient;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@Component
@@ -21,22 +24,222 @@ public class ZiniaoClientImpl implements ZiniaoClient {
private final ZiniaoProperties ziniaoProperties;
private final ObjectMapper objectMapper;
private final ZiniaoSessionCacheService ziniaoSessionCacheService;
@Override
public List<ZiniaoShopCacheDto> listShops() {
if (ziniaoProperties.getCompanyId() == null || ziniaoProperties.getCompanyId() <= 0) {
throw new BusinessException("紫鸟 companyId 未配置");
public String getAppToken() {
String cachedToken = ziniaoSessionCacheService.getAppToken();
if (cachedToken != null && !cachedToken.isBlank()) {
return cachedToken;
}
String raw = getRestClient().post()
.uri(joinUrl(ziniaoProperties.getBaseUrl(), ziniaoProperties.getShopsPath()))
.headers(headers -> {
headers.setBearerAuth(ziniaoProperties.getApiKey());
headers.setContentType(MediaType.APPLICATION_JSON);
})
.body(java.util.Map.of("companyId", ziniaoProperties.getCompanyId()))
String raw = postWithApiKeyEmptyJsonBody(ziniaoProperties.getAppTokenPath(), "获取 appToken");
try {
JsonNode root = objectMapper.readTree(raw);
JsonNode data = firstNonNull(root.get("data"), root.get("result"), root);
String token = text(firstNonNull(
data == null ? null : data.get("appAuthToken"),
data == null ? null : data.get("appToken"),
root.get("appAuthToken"),
root.get("appToken")
));
if (token == null || token.isBlank()) {
throw new BusinessException("紫鸟 appToken 响应缺少 token");
}
ziniaoSessionCacheService.saveAppToken(token);
return token;
} catch (BusinessException ex) {
throw ex;
} catch (Exception ex) {
throw new BusinessException("解析紫鸟 appToken 响应失败");
}
}
@Override
public Long getCompanyIdByApiKey() {
String raw = getWithApiKey("/app/builtin/company", "获取 companyId");
try {
JsonNode root = objectMapper.readTree(raw);
JsonNode data = firstNonNull(root.get("data"), root.get("result"), root);
Long companyId = longValue(firstNonNull(data == null ? null : data.get("companyId"), root.get("companyId")));
if (companyId == null || companyId <= 0) {
throw new BusinessException("紫鸟 companyId 响应缺少 companyId");
}
return companyId;
} catch (BusinessException ex) {
throw ex;
} catch (Exception ex) {
throw new BusinessException("解析紫鸟 companyId 响应失败");
}
}
@Override
public List<ZiniaoStaffItemVo> listStaff(Long companyId) {
String raw = postWithApiKey(ziniaoProperties.getStaffListPath(), Map.of(
"companyId", String.valueOf(companyId),
"level", "",
"isAccurate", "",
"limit", "100",
"page", "1",
"departmentIds", List.of(),
"delflag", "",
"name", "",
"username", ""
), "获取员工列表");
try {
JsonNode root = objectMapper.readTree(raw);
JsonNode dataNode = firstNonNull(root.get("data"), root.get("result"));
JsonNode itemsNode = dataNode == null ? null : firstNonNull(dataNode.get("data"), dataNode.get("items"), dataNode);
List<ZiniaoStaffItemVo> items = new ArrayList<>();
if (itemsNode != null && itemsNode.isArray()) {
for (JsonNode itemNode : itemsNode) {
addStaffItem(items, itemNode);
}
} else if (itemsNode != null && itemsNode.isObject()) {
addStaffItem(items, itemsNode);
}
return items;
} catch (Exception ex) {
throw new BusinessException("解析紫鸟员工列表失败");
}
}
@Override
public List<ZiniaoShopCacheDto> listUserStores(Long companyId, Long userId, String userToken) {
String raw = postWithAuthorization(ziniaoProperties.getUserStoresPath(), userToken, Map.of(
"companyId", companyId,
"userId", userId,
"storeName", "",
"isAccurate", 1,
"page", 1,
"limit", 10
), "获取员工店铺列表");
return parseUserStores(raw);
}
@Override
public String getUserLoginToken(Long companyId, Long userId) {
String raw = postWithApiKey(ziniaoProperties.getUserLoginTokenPath(), Map.of(
"companyId", String.valueOf(companyId),
"userId", String.valueOf(userId)
), "获取员工登录 token");
try {
JsonNode root = objectMapper.readTree(raw);
if (!success(root)) {
throw new BusinessException("获取紫鸟员工登录 token 失败: " + Objects.toString(text(root.get("msg")), "未知错误"));
}
JsonNode data = firstNonNull(root.get("data"), root.get("result"));
String token = text(firstNonNull(
data == null ? null : data.get("token"),
data == null ? null : data.get("loginToken"),
root.get("token"),
root.get("loginToken")
));
if (token == null || token.isBlank()) {
throw new BusinessException("紫鸟员工登录 token 响应缺少 token");
}
return token;
} catch (BusinessException ex) {
throw ex;
} catch (Exception ex) {
throw new BusinessException("解析紫鸟员工登录 token 响应失败");
}
}
private String getWithApiKey(String path, String action) {
String apiKey = requireText(ziniaoProperties.getApiKey(), "紫鸟 apiKey 未配置");
String raw = getRestClient().get()
.uri(joinUrl(ziniaoProperties.getBaseUrl(), path))
.headers(headers -> headers.setBearerAuth(apiKey))
.retrieve()
.body(String.class);
return parseShops(raw);
validateSuccess(raw, action, path);
return raw;
}
private String postWithApiKeyEmptyJsonBody(String path, String action) {
String apiKey = requireText(ziniaoProperties.getApiKey(), "紫鸟 apiKey 未配置");
String raw = getRestClient().post()
.uri(joinUrl(ziniaoProperties.getBaseUrl(), path))
.headers(headers -> {
headers.setBearerAuth(apiKey);
headers.setContentType(MediaType.APPLICATION_JSON);
})
.body("")
.retrieve()
.body(String.class);
validateSuccess(raw, action, path);
return raw;
}
private String postWithApiKey(String path, Map<String, Object> body, String action) {
String apiKey = requireText(ziniaoProperties.getApiKey(), "紫鸟 apiKey 未配置");
RestClient.RequestBodySpec request = getRestClient().post()
.uri(joinUrl(ziniaoProperties.getBaseUrl(), path))
.headers(headers -> {
headers.setBearerAuth(apiKey);
headers.setContentType(MediaType.APPLICATION_JSON);
});
if (body != null) {
request.body(body);
}
String raw = request
.retrieve()
.body(String.class);
validateSuccess(raw, action, path);
return raw;
}
private String postWithAuthorization(String path, String authorization, Map<String, Object> body, String action) {
String token = requireText(authorization, "紫鸟授权 token 未配置");
RestClient.RequestBodySpec request = getRestClient().post()
.uri(joinUrl(ziniaoProperties.getBaseUrl(), path))
.headers(headers -> {
headers.set("Authorization", token);
headers.setContentType(MediaType.APPLICATION_JSON);
});
if (body != null) {
request.body(body);
}
String raw = request
.retrieve()
.body(String.class);
validateSuccess(raw, action, path);
return raw;
}
private void validateSuccess(String raw, String action, String path) {
try {
JsonNode root = objectMapper.readTree(raw);
if (!success(root)) {
throw new BusinessException("紫鸟接口返回失败(" + action + ", " + path + "): " + Objects.toString(text(root.get("msg")), "未知错误"));
}
} catch (BusinessException ex) {
throw ex;
} catch (Exception ex) {
throw new BusinessException("解析紫鸟接口响应失败(" + action + ", " + path + ")");
}
}
private boolean success(JsonNode root) {
String code = text(root.get("code"));
if (code != null && !"0".equals(code)) {
return false;
}
String status = text(root.get("status"));
Long ret = longValue(root.get("ret"));
if ((ret != null && ret != 0L) || (status != null && !"success".equalsIgnoreCase(status))) {
return false;
}
JsonNode wrapper = root.get("data");
if (wrapper != null && wrapper.isObject()) {
String wrapperStatus = text(wrapper.get("status"));
Long wrapperRet = longValue(wrapper.get("ret"));
if ((wrapperRet != null && wrapperRet != 0L) || (wrapperStatus != null && !"success".equalsIgnoreCase(wrapperStatus))) {
return false;
}
}
return true;
}
private RestClient getRestClient() {
@@ -46,21 +249,20 @@ public class ZiniaoClientImpl implements ZiniaoClient {
return RestClient.builder().requestFactory(requestFactory).build();
}
private void addStaffItem(List<ZiniaoStaffItemVo> items, JsonNode itemNode) {
ZiniaoStaffItemVo item = new ZiniaoStaffItemVo();
item.setUserId(longValue(firstNonNull(itemNode.get("userId"), itemNode.get("user_id"))));
item.setUsername(text(firstNonNull(itemNode.get("username"), itemNode.get("userName"))));
item.setName(text(itemNode.get("name")));
if (item.getUserId() != null) {
items.add(item);
}
}
private List<ZiniaoShopCacheDto> parseShops(String raw) {
try {
JsonNode root = objectMapper.readTree(raw);
String code = text(root.get("code"));
if (code != null && !"0".equals(code)) {
throw new BusinessException("紫鸟店铺接口返回失败: " + Objects.toString(text(root.get("msg")), "未知错误"));
}
JsonNode wrapper = root.get("data");
if (wrapper != null && wrapper.isObject()) {
String status = text(wrapper.get("status"));
Long ret = longValue(wrapper.get("ret"));
if ((ret != null && ret != 0L) || (status != null && !"success".equalsIgnoreCase(status))) {
throw new BusinessException("紫鸟店铺接口业务失败: " + Objects.toString(status, "unknown"));
}
}
JsonNode itemsNode = wrapper == null ? null : wrapper.get("data");
List<ZiniaoShopCacheDto> items = new ArrayList<>();
if (itemsNode != null && itemsNode.isArray()) {
@@ -75,12 +277,33 @@ public class ZiniaoClientImpl implements ZiniaoClient {
}
}
return items;
} catch (BusinessException ex) {
throw ex;
} catch (Exception ex) {
throw new BusinessException("解析紫鸟店铺列表失败");
}
}
private List<ZiniaoShopCacheDto> parseUserStores(String raw) {
try {
JsonNode root = objectMapper.readTree(raw);
JsonNode itemsNode = firstNonNull(root.get("data"), root.get("result"));
List<ZiniaoShopCacheDto> items = new ArrayList<>();
if (itemsNode != null && itemsNode.isArray()) {
for (JsonNode itemNode : itemsNode) {
ZiniaoShopCacheDto item = new ZiniaoShopCacheDto();
item.setShopId(text(firstNonNull(itemNode.get("id"), itemNode.get("shopId"), itemNode.get("accountId"), itemNode.get("account_id"))));
item.setShopName(text(firstNonNull(itemNode.get("name"), itemNode.get("shopName"), itemNode.get("accountName"), itemNode.get("account_name"))));
item.setPlatform(text(firstNonNull(itemNode.get("platform"), itemNode.get("platformName"), itemNode.get("site"), itemNode.get("siteName"))));
if (item.getShopId() != null && !item.getShopId().isBlank()) {
items.add(item);
}
}
}
return items;
} catch (Exception ex) {
throw new BusinessException("解析紫鸟员工店铺列表失败");
}
}
private String joinUrl(String baseUrl, String path) {
String base = Objects.toString(baseUrl, "").trim();
@@ -121,4 +344,11 @@ public class ZiniaoClientImpl implements ZiniaoClient {
return null;
}
}
private String requireText(String value, String message) {
if (value == null || value.isBlank() || "change-me".equalsIgnoreCase(value.trim())) {
throw new BusinessException(message);
}
return value.trim();
}
}

View File

@@ -1,12 +1,11 @@
package com.nanri.aiimage.modules.ziniao.controller;
import com.nanri.aiimage.common.api.ApiResponse;
import com.nanri.aiimage.modules.ziniao.model.dto.ZiniaoSwitchShopRequest;
import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoCurrentShopVo;
import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoLoginUrlVo;
import com.nanri.aiimage.modules.ziniao.model.dto.ZiniaoOpenShopRequest;
import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoOpenShopVo;
import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoSessionVo;
import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoShopListVo;
import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoSwitchShopVo;
import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoStaffListVo;
import com.nanri.aiimage.modules.ziniao.service.ZiniaoAuthService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
@@ -15,8 +14,6 @@ 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.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@@ -27,71 +24,44 @@ import org.springframework.web.bind.annotation.RestController;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/ziniao")
@Tag(name = "紫鸟接入", description = "紫鸟 ApiKey 模式下的配置状态、店铺列表、当前店铺与切换店铺接口")
@Tag(name = "紫鸟接入", description = "基于 API Key 直连紫鸟接口,获取 appToken、员工、店铺并生成打开店铺链接")
public class ZiniaoAuthController {
private final ZiniaoAuthService ziniaoAuthService;
@GetMapping("/login-url")
@Operation(summary = "获取紫鸟接入信息", description = "兼容保留接口,返回当前 ApiKey 模式的接入信息和 sessionId。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "获取成功", content = @Content(schema = @Schema(implementation = ZiniaoLoginUrlVo.class)))
})
public ApiResponse<ZiniaoLoginUrlVo> getLoginUrl() {
return ApiResponse.success(ziniaoAuthService.getLoginUrl());
}
@GetMapping("/callback")
@Operation(summary = "兼容保留回调接口", description = "ApiKey 模式下无真实登录回调,该接口仅兼容旧调用并返回 302。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "302", description = "重定向到兼容地址")
})
public ResponseEntity<Void> callback(@RequestParam(required = false) String code, @RequestParam(required = false) String state) {
try {
String redirectUrl = ziniaoAuthService.handleCallback(code, state);
return ResponseEntity.status(302)
.header(HttpHeaders.LOCATION, redirectUrl)
.build();
} catch (Exception ex) {
return ResponseEntity.status(302)
.header(HttpHeaders.LOCATION, ziniaoAuthService.buildFailureRedirect(ex.getMessage()))
.build();
}
}
@GetMapping("/session")
@Operation(summary = "获取紫鸟接入状态", description = "根据 sessionId 获取当前 ApiKey 模式会话与当前店铺信息。")
@Operation(summary = "获取紫鸟会话状态", description = "自动获取或复用 appToken并返回 companyId、当前员工 userId、脱敏 token 和当前店铺信息。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "获取成功", content = @Content(schema = @Schema(implementation = ZiniaoSessionVo.class)))
})
public ApiResponse<ZiniaoSessionVo> getSession(@RequestParam String sessionId) {
return ApiResponse.success(ziniaoAuthService.getSession(sessionId));
public ApiResponse<ZiniaoSessionVo> getSession(@RequestParam(required = false) String sessionId, @RequestParam(required = false) Long userId) {
return ApiResponse.success(ziniaoAuthService.getSession(sessionId, userId));
}
@GetMapping("/staff")
@Operation(summary = "获取紫鸟员工列表", description = "使用 API Key 直连紫鸟接口,查询当前 companyId 下的员工列表,供前端绑定 employee userId。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "获取成功", content = @Content(schema = @Schema(implementation = ZiniaoStaffListVo.class)))
})
public ApiResponse<ZiniaoStaffListVo> listStaff() {
return ApiResponse.success(ziniaoAuthService.listStaff());
}
@GetMapping("/shops")
@Operation(summary = "获取紫鸟店铺列表", description = "根据 sessionId 使用配置的 ApiKey companyId 拉取最新店铺列表")
@Operation(summary = "获取员工可见店铺列表", description = "先通过 API Key 获取 companyId,再按员工 userId 查询该员工有权限的店铺列表,并缓存到当前 session")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "获取成功", content = @Content(schema = @Schema(implementation = ZiniaoShopListVo.class)))
})
public ApiResponse<ZiniaoShopListVo> listShops(@RequestParam String sessionId) {
return ApiResponse.success(ziniaoAuthService.listShops(sessionId));
public ApiResponse<ZiniaoShopListVo> listShops(@RequestParam String sessionId, @RequestParam Long userId) {
return ApiResponse.success(ziniaoAuthService.listShops(sessionId, userId));
}
@GetMapping("/shops/current")
@Operation(summary = "获取当前紫鸟店铺", description = "根据 sessionId 获取当前已选中的店铺")
@PostMapping("/shops/open")
@Operation(summary = "生成店铺打开链接", description = "按指定员工 userId 和 shopId 获取员工登录 token并返回可直接拉起紫鸟客户端的 openStoreUrl")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "获取成功", content = @Content(schema = @Schema(implementation = ZiniaoCurrentShopVo.class)))
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "打开链接生成成功", content = @Content(schema = @Schema(implementation = ZiniaoOpenShopVo.class)))
})
public ApiResponse<ZiniaoCurrentShopVo> getCurrentShop(@RequestParam String sessionId) {
return ApiResponse.success(ziniaoAuthService.getCurrentShop(sessionId));
}
@PostMapping("/shops/switch")
@Operation(summary = "切换当前紫鸟店铺", description = "根据 sessionId 和 shopId 切换当前选中的店铺。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "切换成功", content = @Content(schema = @Schema(implementation = ZiniaoSwitchShopVo.class)))
})
public ApiResponse<ZiniaoSwitchShopVo> switchShop(@Valid @RequestBody ZiniaoSwitchShopRequest request) {
return ApiResponse.success(ziniaoAuthService.switchShop(request));
public ApiResponse<ZiniaoOpenShopVo> openShop(@Valid @RequestBody ZiniaoOpenShopRequest request) {
return ApiResponse.success(ziniaoAuthService.openShop(request));
}
}

View File

@@ -11,5 +11,7 @@ public class ZiniaoSessionCacheDto {
private Long expireAt;
private String ziniaoUserId;
private String nickname;
private Long companyId;
private Long currentUserId;
private String defaultShopId;
}

View File

@@ -0,0 +1,17 @@
package com.nanri.aiimage.modules.ziniao.model.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
public class ZiniaoOpenShopRequest {
@NotBlank(message = "sessionId 不能为空")
private String sessionId;
@NotNull(message = "userId 不能为空")
private Long userId;
@NotBlank(message = "shopId 不能为空")
private String shopId;
}

View File

@@ -1,6 +1,7 @@
package com.nanri.aiimage.modules.ziniao.model.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
@@ -8,6 +9,9 @@ public class ZiniaoSwitchShopRequest {
@NotBlank(message = "sessionId 不能为空")
private String sessionId;
@NotNull(message = "userId 不能为空")
private Long userId;
@NotBlank(message = "shopId 不能为空")
private String shopId;
}

View File

@@ -0,0 +1,23 @@
package com.nanri.aiimage.modules.ziniao.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "紫鸟打开店铺结果")
public class ZiniaoOpenShopVo {
@Schema(description = "店铺ID")
private String shopId;
@Schema(description = "店铺名称")
private String shopName;
@Schema(description = "员工用户ID")
private Long userId;
@Schema(description = "通过 API Key 调用接口获取的员工登录 token")
private String loginToken;
@Schema(description = "打开店铺URL")
private String openStoreUrl;
}

View File

@@ -9,15 +9,30 @@ public class ZiniaoSessionVo {
@Schema(description = "会话 ID")
private String sessionId;
@Schema(description = "是否已认证")
@Schema(description = "是否已启用")
private Boolean enabled;
@Schema(description = "是否已获取应用 token")
private Boolean authenticated;
@Schema(description = "公司 ID")
private Long companyId;
@Schema(description = "当前员工 userId")
private Long currentUserId;
@Schema(description = "紫鸟用户 ID")
private String ziniaoUserId;
@Schema(description = "紫鸟昵称")
private String nickname;
@Schema(description = "脱敏后的 appToken")
private String appToken;
@Schema(description = "token 类型")
private String tokenType;
@Schema(description = "会话过期时间戳(毫秒)")
private Long expireAt;

View File

@@ -0,0 +1,17 @@
package com.nanri.aiimage.modules.ziniao.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "紫鸟员工")
public class ZiniaoStaffItemVo {
@Schema(description = "员工用户ID")
private Long userId;
@Schema(description = "员工账号")
private String username;
@Schema(description = "员工姓名")
private String name;
}

View File

@@ -0,0 +1,14 @@
package com.nanri.aiimage.modules.ziniao.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 ZiniaoStaffListVo {
@Schema(description = "员工列表")
private List<ZiniaoStaffItemVo> items = new ArrayList<>();
}

View File

@@ -5,22 +5,22 @@ import com.nanri.aiimage.config.ZiniaoProperties;
import com.nanri.aiimage.modules.ziniao.client.ZiniaoClient;
import com.nanri.aiimage.modules.ziniao.model.cache.ZiniaoSessionCacheDto;
import com.nanri.aiimage.modules.ziniao.model.cache.ZiniaoShopCacheDto;
import com.nanri.aiimage.modules.ziniao.model.dto.ZiniaoSwitchShopRequest;
import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoCurrentShopVo;
import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoLoginUrlVo;
import com.nanri.aiimage.modules.ziniao.model.dto.ZiniaoOpenShopRequest;
import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoOpenShopVo;
import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoSessionVo;
import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoShopItemVo;
import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoShopListVo;
import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoSwitchShopVo;
import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoStaffItemVo;
import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoStaffListVo;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.web.util.UriComponentsBuilder;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Base64;
import java.util.List;
import java.util.UUID;
import java.util.Base64;
@Service
@RequiredArgsConstructor
@@ -30,163 +30,150 @@ public class ZiniaoAuthService {
private final ZiniaoClient ziniaoClient;
private final ZiniaoSessionCacheService ziniaoSessionCacheService;
public ZiniaoLoginUrlVo getLoginUrl() {
public ZiniaoSessionVo getSession(String sessionId, Long userId) {
ensureEnabled();
String sessionId = ensureSession();
ZiniaoLoginUrlVo vo = new ZiniaoLoginUrlVo();
vo.setState(sessionId);
vo.setExpireAt(Instant.now().plusSeconds(ziniaoProperties.getSessionTtlHours() * 3600).toEpochMilli());
vo.setLoginUrl(ziniaoProperties.getBaseUrl());
return vo;
}
public String handleCallback(String code, String state) {
ensureEnabled();
String sessionId = ensureSession();
return ziniaoProperties.getBaseUrl() + "?sessionId=" + sessionId;
}
public ZiniaoSessionVo getSession(String sessionId) {
ZiniaoSessionCacheDto session = requireSession(sessionId);
List<ZiniaoShopCacheDto> shops = ziniaoSessionCacheService.getShops(sessionId);
ZiniaoShopCacheDto currentShop = getOrInitCurrentShop(sessionId, shops);
ZiniaoSessionCacheDto session = sessionId == null || sessionId.isBlank() ? initSession(userId) : requireOrInitSession(sessionId, userId);
ZiniaoSessionVo vo = new ZiniaoSessionVo();
vo.setSessionId(session.getSessionId());
vo.setAuthenticated(true);
vo.setEnabled(ziniaoProperties.isEnabled());
vo.setAuthenticated(session.getAccessToken() != null && !session.getAccessToken().isBlank());
vo.setCompanyId(session.getCompanyId());
vo.setCurrentUserId(session.getCurrentUserId());
vo.setZiniaoUserId(session.getZiniaoUserId());
vo.setNickname(session.getNickname());
vo.setAppToken(session.getAccessToken());
vo.setTokenType(session.getTokenType());
vo.setExpireAt(session.getExpireAt());
vo.setShopCount(shops.size());
if (currentShop != null) {
vo.setCurrentShopId(currentShop.getShopId());
vo.setCurrentShopName(currentShop.getShopName());
}
vo.setShopCount(ziniaoSessionCacheService.getShops(session.getSessionId()).size());
vo.setCurrentShopId(session.getDefaultShopId());
vo.setCurrentShopName(resolveCurrentShopName(session.getSessionId(), session.getDefaultShopId()));
return vo;
}
public ZiniaoShopListVo listShops(String sessionId) {
requireSession(sessionId);
List<ZiniaoShopCacheDto> shops = ziniaoClient.listShops();
ziniaoSessionCacheService.saveShops(sessionId, shops);
ZiniaoShopCacheDto currentShop = getOrInitCurrentShop(sessionId, shops);
public ZiniaoStaffListVo listStaff() {
ensureEnabled();
ZiniaoStaffListVo vo = new ZiniaoStaffListVo();
Long companyId = resolveCompanyIdForStaff();
vo.getItems().addAll(ziniaoClient.listStaff(companyId));
return vo;
}
public ZiniaoShopListVo listShops(String sessionId, Long userId) {
ZiniaoSessionCacheDto session = requireOrInitSession(sessionId, userId);
Long currentUserId = requireUserId(session.getCurrentUserId());
String userToken = ziniaoClient.getUserLoginToken(resolveCompanyId(), currentUserId);
List<ZiniaoShopCacheDto> shops = ziniaoClient.listUserStores(resolveCompanyId(), currentUserId, userToken);
ziniaoSessionCacheService.saveShops(session.getSessionId(), shops);
if (!shops.isEmpty() && (session.getDefaultShopId() == null || session.getDefaultShopId().isBlank())) {
session.setDefaultShopId(shops.get(0).getShopId());
ziniaoSessionCacheService.saveSession(session);
}
ZiniaoShopListVo vo = new ZiniaoShopListVo();
for (ZiniaoShopCacheDto shop : shops) {
ZiniaoShopItemVo item = new ZiniaoShopItemVo();
item.setShopId(shop.getShopId());
item.setShopName(shop.getShopName());
item.setPlatform(shop.getPlatform());
item.setSelected(currentShop != null && shop.getShopId().equals(currentShop.getShopId()));
item.setSelected(shop.getShopId() != null && shop.getShopId().equals(session.getDefaultShopId()));
vo.getItems().add(item);
}
return vo;
}
public ZiniaoCurrentShopVo getCurrentShop(String sessionId) {
requireSession(sessionId);
ZiniaoShopCacheDto currentShop = getOrInitCurrentShop(sessionId, ziniaoSessionCacheService.getShops(sessionId));
if (currentShop == null) {
throw new BusinessException("当前会话没有可用店铺");
public ZiniaoOpenShopVo openShop(ZiniaoOpenShopRequest request) {
ZiniaoSessionCacheDto session = requireOrInitSession(request.getSessionId(), request.getUserId());
Long currentUserId = requireUserId(request.getUserId() != null ? request.getUserId() : session.getCurrentUserId());
String userToken = ziniaoClient.getUserLoginToken(resolveCompanyId(), currentUserId);
List<ZiniaoShopCacheDto> shops = ziniaoSessionCacheService.getShops(session.getSessionId());
if (shops.isEmpty()) {
shops = ziniaoClient.listUserStores(resolveCompanyId(), currentUserId, userToken);
ziniaoSessionCacheService.saveShops(session.getSessionId(), shops);
}
ZiniaoCurrentShopVo vo = new ZiniaoCurrentShopVo();
vo.setShopId(currentShop.getShopId());
vo.setShopName(currentShop.getShopName());
vo.setPlatform(currentShop.getPlatform());
vo.setSelectedAt(currentShop.getSelectedAt());
return vo;
}
public ZiniaoSwitchShopVo switchShop(ZiniaoSwitchShopRequest request) {
requireSession(request.getSessionId());
List<ZiniaoShopCacheDto> shops = ziniaoSessionCacheService.getShops(request.getSessionId());
ZiniaoShopCacheDto targetShop = shops.stream()
.filter(item -> item.getShopId() != null && item.getShopId().equals(request.getShopId()))
.findFirst()
.orElseThrow(() -> new BusinessException("店铺不存在或不属于当前会话"));
targetShop.setSelectedAt(Instant.now().toEpochMilli());
ziniaoSessionCacheService.saveCurrentShop(request.getSessionId(), targetShop);
ZiniaoSwitchShopVo vo = new ZiniaoSwitchShopVo();
vo.setSuccess(true);
vo.setCurrentShopId(targetShop.getShopId());
vo.setCurrentShopName(targetShop.getShopName());
vo.setOpenStoreUrl(buildOpenStoreUrl(targetShop));
.orElseThrow(() -> new BusinessException("店铺不存在或不属于当前员工"));
session.setCurrentUserId(currentUserId);
session.setDefaultShopId(targetShop.getShopId());
ziniaoSessionCacheService.saveSession(session);
ziniaoSessionCacheService.saveCurrentShop(session.getSessionId(), targetShop);
ZiniaoOpenShopVo vo = new ZiniaoOpenShopVo();
vo.setUserId(currentUserId);
vo.setShopId(targetShop.getShopId());
vo.setShopName(targetShop.getShopName());
vo.setLoginToken(userToken);
vo.setOpenStoreUrl(buildOpenStoreUrl(targetShop.getShopId(), currentUserId, userToken));
return vo;
}
public String buildFailureRedirect(String message) {
return ziniaoProperties.getBaseUrl() + "?message=" + (message == null || message.isBlank() ? "紫鸟授权失败" : message);
public List<ZiniaoShopCacheDto> listShopsForConfiguredUser() {
ensureEnabled();
Long userId = parseConfiguredOpenStoreUserId();
String userToken = ziniaoClient.getUserLoginToken(resolveCompanyId(), userId);
return ziniaoClient.listUserStores(resolveCompanyId(), userId, userToken);
}
private String ensureSession() {
public String buildOpenStoreUrlForShop(ZiniaoShopCacheDto shop) {
ensureEnabled();
if (shop == null || shop.getShopId() == null || shop.getShopId().isBlank()) {
throw new BusinessException("店铺不存在");
}
Long userId = parseConfiguredOpenStoreUserId();
String loginToken = ziniaoClient.getUserLoginToken(resolveCompanyId(), userId);
return buildOpenStoreUrl(shop.getShopId(), userId, loginToken);
}
private ZiniaoSessionCacheDto initSession(Long userId) {
String sessionId = "ziniao_" + UUID.randomUUID().toString().replace("-", "");
ZiniaoSessionCacheDto session = new ZiniaoSessionCacheDto();
session.setSessionId(sessionId);
session.setAccessToken(maskApiKey(ziniaoProperties.getApiKey()));
String appToken = ziniaoClient.getAppToken();
session.setAccessToken(maskToken(appToken));
session.setTokenType("Bearer");
session.setExpireAt(Instant.now().plusSeconds(ziniaoProperties.getSessionTtlHours() * 3600).toEpochMilli());
session.setZiniaoUserId(String.valueOf(defaultCompanyId()));
session.setNickname("ApiKey模式");
session.setCompanyId(resolveCompanyId());
session.setCurrentUserId(userId);
session.setZiniaoUserId(String.valueOf(userId == null ? 0L : userId));
session.setNickname("API Key模式");
ziniaoSessionCacheService.saveSession(session);
return sessionId;
return session;
}
private ZiniaoSessionCacheDto requireSession(String sessionId) {
ensureEnabled();
private ZiniaoSessionCacheDto requireOrInitSession(String sessionId, Long userId) {
if (sessionId == null || sessionId.isBlank()) {
throw new BusinessException("sessionId 不能为空");
return initSession(userId);
}
ZiniaoSessionCacheDto session = ziniaoSessionCacheService.getSession(sessionId);
if (session == null) {
throw new BusinessException("当前会话不存在或已过期");
return initSession(userId);
}
if (userId != null && userId > 0) {
session.setCurrentUserId(userId);
session.setZiniaoUserId(String.valueOf(userId));
ziniaoSessionCacheService.saveSession(session);
}
return session;
}
private ZiniaoShopCacheDto getOrInitCurrentShop(String sessionId, List<ZiniaoShopCacheDto> shops) {
ZiniaoShopCacheDto current = ziniaoSessionCacheService.getCurrentShop(sessionId);
if (current != null) {
return current;
}
if (shops == null || shops.isEmpty()) {
private String resolveCurrentShopName(String sessionId, String currentShopId) {
if (currentShopId == null || currentShopId.isBlank()) {
return null;
}
ZiniaoShopCacheDto first = shops.get(0);
first.setSelectedAt(Instant.now().toEpochMilli());
ziniaoSessionCacheService.saveCurrentShop(sessionId, first);
return first;
return ziniaoSessionCacheService.getShops(sessionId).stream()
.filter(item -> currentShopId.equals(item.getShopId()))
.map(ZiniaoShopCacheDto::getShopName)
.findFirst()
.orElse(null);
}
private void ensureEnabled() {
if (!ziniaoProperties.isEnabled()) {
throw new BusinessException("紫鸟集成未启用,请先配置环境变量");
}
if (ziniaoProperties.getApiKey() == null || ziniaoProperties.getApiKey().isBlank() || "change-me".equalsIgnoreCase(ziniaoProperties.getApiKey())) {
throw new BusinessException("紫鸟 ApiKey 未配置");
}
}
private long defaultCompanyId() {
return ziniaoProperties.getCompanyId() == null ? 0L : ziniaoProperties.getCompanyId();
}
private String maskApiKey(String apiKey) {
if (apiKey == null || apiKey.isBlank()) {
return "";
}
if (apiKey.length() <= 8) {
return apiKey;
}
return apiKey.substring(0, 4) + "****" + apiKey.substring(apiKey.length() - 4);
}
private String buildOpenStoreUrl(ZiniaoShopCacheDto shop) {
private String buildOpenStoreUrl(String storeId, Long userId, String loginToken) {
String scheme = requireText(ziniaoProperties.getOpenStoreScheme(), "紫鸟 open-store-scheme 未配置");
String userId = requireText(ziniaoProperties.getOpenStoreUserId(), "紫鸟 open-store-user-id 未配置");
String apiKey = requireText(ziniaoProperties.getApiKey(), "紫鸟 ApiKey 未配置");
String launchUrl = requireText(ziniaoProperties.getOpenStoreLaunchUrl(), "紫鸟 open-store-launch-url 未配置");
String encodedLaunchUrl = Base64.getEncoder().encodeToString(launchUrl.getBytes(StandardCharsets.UTF_8));
UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(scheme)
.queryParam("storeId", shop.getShopId())
.queryParam("openapiToken", apiKey)
.queryParam("storeId", storeId)
.queryParam("openapiToken", loginToken)
.queryParam("userId", userId)
.queryParam("lanuchUrl", encodedLaunchUrl)
.queryParam("autoopen", Boolean.TRUE.equals(ziniaoProperties.getOpenStoreAutoOpen()))
@@ -202,8 +189,56 @@ public class ZiniaoAuthService {
return builder.build(true).toUriString();
}
private void ensureEnabled() {
if (!ziniaoProperties.isEnabled()) {
throw new BusinessException("紫鸟集成未启用,请先配置环境变量");
}
requireText(ziniaoProperties.getApiKey(), "紫鸟 apiKey 未配置");
}
private Long resolveCompanyIdForStaff() {
return resolveCompanyId();
}
private Long requireUserId(Long userId) {
if (userId == null || userId <= 0) {
throw new BusinessException("请先选择紫鸟员工");
}
return userId;
}
private Long resolveCompanyId() {
if (ziniaoProperties.getCompanyId() != null && ziniaoProperties.getCompanyId() > 0) {
return ziniaoProperties.getCompanyId();
}
return ziniaoClient.getCompanyIdByApiKey();
}
private Long requireCompanyId() {
return resolveCompanyId();
}
private Long parseConfiguredOpenStoreUserId() {
String configured = requireText(ziniaoProperties.getOpenStoreUserId(), "紫鸟 open-store-user-id 未配置");
try {
return Long.parseLong(configured);
} catch (Exception ex) {
throw new BusinessException("紫鸟 open-store-user-id 配置不合法");
}
}
private String maskToken(String token) {
if (token == null || token.isBlank()) {
return "";
}
if (token.length() <= 8) {
return token;
}
return token.substring(0, 4) + "****" + token.substring(token.length() - 4);
}
private String requireText(String value, String message) {
if (value == null || value.isBlank()) {
if (value == null || value.isBlank() || "change-me".equalsIgnoreCase(value.trim())) {
throw new BusinessException(message);
}
return value.trim();

View File

@@ -17,10 +17,27 @@ import java.util.List;
@RequiredArgsConstructor
public class ZiniaoSessionCacheService {
private static final String APP_TOKEN_KEY = "ziniao:app-token";
private final StringRedisTemplate stringRedisTemplate;
private final ObjectMapper objectMapper;
private final ZiniaoProperties ziniaoProperties;
public void saveAppToken(String appToken) {
if (appToken == null || appToken.isBlank()) {
return;
}
stringRedisTemplate.opsForValue().set(APP_TOKEN_KEY, appToken.trim(), Duration.ofHours(ziniaoProperties.getSessionTtlHours()));
}
public String getAppToken() {
String value = stringRedisTemplate.opsForValue().get(APP_TOKEN_KEY);
if (value == null || value.isBlank()) {
return null;
}
return value.trim();
}
public void saveSession(ZiniaoSessionCacheDto session) {
try {
stringRedisTemplate.opsForValue().set(buildSessionKey(session.getSessionId()), objectMapper.writeValueAsString(session), Duration.ofHours(ziniaoProperties.getSessionTtlHours()));

View File

@@ -15,12 +15,20 @@ AIIMAGE_OSS_ACCESS_KEY_SECRET=bQSZnFH455i8tzyOgeahJmUzwmhynz
AIIMAGE_STORAGE_LOCAL_TEMP_DIR=F:/project/crawler-plugin/backend-java/data/tmp
AIIMAGE_MODULE_CLEANUP_ENABLED=true
AIIMAGE_MODULE_CLEANUP_CRON=0 0 0 * * *
AIIMAGE_MODULE_CLEANUP_MODULE_TYPES=DEDUPE,SPLIT,CONVERT,DELETE_BRAND
AIIMAGE_ZINIAO_ENABLED=false
AIIMAGE_ZINIAO_BASE_URL=https://sbappstoreapi.ziniao.com/openapi-router
AIIMAGE_ZINIAO_API_KEY=change-me
AIIMAGE_ZINIAO_APP_ID=change-me
AIIMAGE_ZINIAO_APP_SECRET=change-me
AIIMAGE_ZINIAO_COMPANY_ID=0
AIIMAGE_ZINIAO_SHOPS_PATH=/superbrowser/rest/v1/erp/shop/list
AIIMAGE_ZINIAO_SWITCH_SHOP_PATH=
AIIMAGE_ZINIAO_APP_TOKEN_PATH=/auth/get_app_token
AIIMAGE_ZINIAO_STAFF_LIST_PATH=/superbrowser/rest/v1/erp/staff/list
AIIMAGE_ZINIAO_USER_STORES_PATH=/superbrowser/rest/v1/erp/user/stores
AIIMAGE_ZINIAO_USER_LOGIN_TOKEN_PATH=/superbrowser/rest/v1/token/user-login
AIIMAGE_ZINIAO_OPEN_STORE_SCHEME=superbrowser://OpenStrore
AIIMAGE_ZINIAO_OPEN_STORE_USER_ID=
AIIMAGE_ZINIAO_OPEN_STORE_LAUNCH_URL=https://www.baidu.com

View File

@@ -72,13 +72,21 @@ aiimage:
failed-ttl-hours: ${AIIMAGE_BRAND_PROGRESS_FAILED_TTL_HOURS:2}
heartbeat-timeout-minutes: ${AIIMAGE_BRAND_PROGRESS_HEARTBEAT_TIMEOUT_MINUTES:15}
stale-check-cron: ${AIIMAGE_BRAND_PROGRESS_STALE_CHECK_CRON:0 */2 * * * *}
module-cleanup:
enabled: ${AIIMAGE_MODULE_CLEANUP_ENABLED:true}
cron: ${AIIMAGE_MODULE_CLEANUP_CRON:0 0 0 * * *}
module-types: ${AIIMAGE_MODULE_CLEANUP_MODULE_TYPES:DEDUPE,SPLIT,CONVERT,DELETE_BRAND}
ziniao:
enabled: ${AIIMAGE_ZINIAO_ENABLED:false}
base-url: ${AIIMAGE_ZINIAO_BASE_URL:https://sbappstoreapi.ziniao.com/openapi-router}
api-key: ${AIIMAGE_ZINIAO_API_KEY:change-me}
api-key: ${AIIMAGE_ZINIAO_API_KEY:}
app-id: ${AIIMAGE_ZINIAO_APP_ID:change-me}
app-secret: ${AIIMAGE_ZINIAO_APP_SECRET:change-me}
company-id: ${AIIMAGE_ZINIAO_COMPANY_ID:0}
shops-path: ${AIIMAGE_ZINIAO_SHOPS_PATH:/superbrowser/rest/v1/erp/shop/list}
switch-shop-path: ${AIIMAGE_ZINIAO_SWITCH_SHOP_PATH:}
app-token-path: ${AIIMAGE_ZINIAO_APP_TOKEN_PATH:/auth/get_app_token}
staff-list-path: ${AIIMAGE_ZINIAO_STAFF_LIST_PATH:/superbrowser/rest/v1/erp/staff/list}
user-stores-path: ${AIIMAGE_ZINIAO_USER_STORES_PATH:/superbrowser/rest/v1/erp/user/stores}
user-login-token-path: ${AIIMAGE_ZINIAO_USER_LOGIN_TOKEN_PATH:/superbrowser/rest/v1/token/user-login}
open-store-scheme: ${AIIMAGE_ZINIAO_OPEN_STORE_SCHEME:superbrowser://OpenStrore}
open-store-user-id: ${AIIMAGE_ZINIAO_OPEN_STORE_USER_ID:}
open-store-launch-url: ${AIIMAGE_ZINIAO_OPEN_STORE_LAUNCH_URL:https://www.baidu.com}

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>删除品牌 - 数富AI</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/delete-brand-main.ts"></script>
</body>
</html>

View File

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

View File

@@ -0,0 +1,346 @@
<template>
<div class="page-shell module-page">
<BrandTopBar active="delete-brand" />
<div class="main-content">
<aside class="left-panel">
<div class="section-title">选择文件</div>
<div class="upload-zone">
<div class="hint">上传删除品牌 Excel无论是直接上传文件还是上传文件夹批量导入都会按每个 Excel 文件名匹配紫鸟店铺</div>
<div class="btns">
<button type="button" class="opt-btn" @click="selectFiles">选择 Excel 文件</button>
<button type="button" class="opt-btn" @click="selectFolder">选择文件夹</button>
</div>
<div class="selected-files clean-placeholder split-selected-files">
<template v-if="selectedPaths.length">
<span v-for="path in displayPaths" :key="path">{{ path }}</span>
<span v-if="selectedPaths.length > displayPaths.length" class="more-line">
还有 {{ selectedPaths.length - displayPaths.length }} 个文件未展开显示
</span>
</template>
<span v-else>暂未选择删除品牌文件</span>
</div>
</div>
<div class="section-title">执行说明</div>
<div class="option-group">
<label class="radio-item active">
<input type="checkbox" checked disabled />
<div>
<span class="label">文件名作为店铺名</span>
<div class="desc">例如 鲍丽明.xlsx 会匹配紫鸟店铺 鲍丽明选择文件夹时也仍按每个 Excel 文件名匹配</div>
</div>
</label>
<label class="radio-item active">
<input type="checkbox" checked disabled />
<div>
<span class="label">文件夹仅用于批量导入</span>
<div class="desc">文件夹名和子目录名不会参与店铺匹配只是方便一次上传多个 Excel</div>
</div>
</label>
<label class="radio-item active">
<input type="checkbox" checked disabled />
<div>
<span class="label">大表仅返回预览</span>
<div class="desc">当前最多展示前 200 避免大文件直接拖慢页面</div>
</div>
</label>
</div>
<div class="run-row">
<button type="button" class="btn-run" :disabled="running" @click="submitRun">
{{ running ? '解析中...' : '开始解析' }}
</button>
<span class="loading-msg">
{{ running ? '正在读取删除品牌数据并匹配紫鸟店铺,请稍候…' : '解析后会在右侧展示预览数据和紫鸟店铺打开链接' }}
</span>
</div>
</aside>
<section class="right-panel">
<div class="panel-header">删除品牌结果</div>
<div class="task-list-wrap">
<div class="clean-result-summary">
<div class="summary-card">
<span class="summary-label">已处理文件</span>
<strong>{{ summary.total }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">成功结果</span>
<strong>{{ summary.successCount }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">失败文件</span>
<strong>{{ summary.failedCount }}</strong>
</div>
</div>
<div class="result-list-wrap">
<div class="result-list-header">
<span>删除品牌结果列表</span>
</div>
<div v-if="resultItems.length === 0" class="empty-tasks">
暂无删除品牌结果完成解析后会在这里展示店铺匹配结果和预览数据
</div>
<ul v-else class="task-list clean-result-list">
<li
v-for="item in resultItems"
:key="`${item.resultId || item.sourceFilename}-${item.shopId || ''}`"
class="task-item split-result-item"
>
<div class="left split-result-main">
<span class="id" :title="item.sourceFilename">{{ item.sourceFilename || '-' }}</span>
<div class="files">店铺名{{ item.shopName || '-' }}</div>
<div class="files">匹配结果{{ item.matched ? `已匹配 ${item.shopId || ''}` : '未匹配到紫鸟店铺' }}</div>
<div v-if="item.platform" class="files">平台{{ item.platform }}</div>
<div v-if="item.totalRows !== undefined" class="time">预览 {{ item.previewRows?.length || 0 }} / {{ item.totalRows }} </div>
<div v-if="item.truncated" class="files">数据较大当前仅展示前 200 </div>
<div v-if="item.previewRows?.length" class="files split-entry-list delete-brand-preview">
{{ formatPreview(item.previewRows) }}
</div>
<div v-if="item.error" class="files">错误信息{{ item.error }}</div>
</div>
<div class="task-right split-result-actions">
<span class="status" :class="item.success ? 'success' : 'failed'">
{{ item.success ? '已完成' : '失败' }}
</span>
<button
v-if="item.openStoreUrl"
type="button"
class="download"
@click="openShop(item.openStoreUrl)"
>
打开店铺
</button>
<button
v-if="item.resultId"
type="button"
class="btn-delete"
@click="deleteHistoryRecord(item.resultId)"
>
删除
</button>
</div>
</li>
</ul>
</div>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import BrandTopBar from './BrandTopBar.vue'
import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
import {
deleteDeleteBrandHistory,
getDeleteBrandHistory,
runDeleteBrand,
type DeleteBrandPreviewRow,
type DeleteBrandResultItem,
type DeleteBrandRunVo,
} from '@/shared/api/java-modules'
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
const selectedPaths = ref<string[]>([])
const uploadedFiles = ref<UploadedJavaFile[]>([])
const running = ref(false)
const resultItems = ref<DeleteBrandResultItem[]>([])
const summary = ref<DeleteBrandRunVo>({ total: 0, successCount: 0, failedCount: 0, items: [] })
const displayPaths = computed(() => selectedPaths.value.slice(0, 8))
function updateSummary(items: DeleteBrandResultItem[]) {
summary.value = {
total: items.length,
successCount: items.filter((item) => item.success).length,
failedCount: items.filter((item) => !item.success).length,
items,
}
}
async function uploadPathsToJava(paths: Array<string | BrandExpandFolderItem>) {
const api = getPywebviewApi()
if (!api?.upload_file_to_java) {
throw new Error('当前桌面端未提供文件上传桥接能力')
}
const uploaded: UploadedJavaFile[] = []
for (const item of paths) {
const filePath = typeof item === 'string' ? item : item.absolutePath
const relativePath = typeof item === 'string' ? undefined : item.relativePath
const result = await api.upload_file_to_java(filePath, relativePath)
if (!result?.success || !result.data) {
throw new Error(result?.error || result?.message || `上传失败:${filePath}`)
}
uploaded.push(result.data)
}
return uploaded
}
async function selectFiles() {
const api = getPywebviewApi()
if (!api?.select_brand_xlsx_files) {
ElMessage.warning('当前环境不支持文件选择,请在本机客户端中打开')
return
}
try {
const paths = await api.select_brand_xlsx_files()
if (!paths?.length) return
selectedPaths.value = paths
uploadedFiles.value = await uploadPathsToJava(paths)
ElMessage.success(`已选择 ${paths.length} 个删除品牌文件`)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '选择失败')
}
}
async function selectFolder() {
const api = getPywebviewApi()
if (!api?.select_brand_folder) {
ElMessage.warning('当前环境不支持文件夹选择,请在本机客户端中打开')
return
}
try {
const folder = await api.select_brand_folder()
if (!folder) return
const result = await expandBrandFolderRecursive(folder)
if (!result.success || !result.items?.length) {
ElMessage.warning(result.error || '该文件夹下没有 xlsx 文件')
return
}
selectedPaths.value = result.items.map((item) => item.relativePath || item.absolutePath)
uploadedFiles.value = await uploadPathsToJava(result.items)
ElMessage.success(`已选择文件夹内 ${result.items.length} 个 xlsx 文件`)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '选择失败')
}
}
async function submitRun() {
if (!uploadedFiles.value.length) {
ElMessage.warning('请先选择待处理文件')
return
}
try {
running.value = true
const result = await runDeleteBrand({
files: uploadedFiles.value.map((item) => ({
fileKey: item.fileKey,
originalFilename: item.originalFilename,
relativePath: item.relativePath,
})),
})
summary.value = result
resultItems.value = result.items || []
await loadHistory()
ElMessage.success('删除品牌解析完成')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '执行失败')
} finally {
running.value = false
}
}
async function loadHistory() {
try {
const response = await getDeleteBrandHistory()
resultItems.value = response.items || []
updateSummary(resultItems.value)
} catch {
// ignore history load errors
}
}
async function deleteHistoryRecord(resultId: number) {
try {
await deleteDeleteBrandHistory(resultId)
await loadHistory()
ElMessage.success('已删除')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
function formatPreview(rows: DeleteBrandPreviewRow[]) {
const preview = rows.slice(0, 5).map((item) => `${item.country}:${item.asin}${item.status ? `${item.status}` : ''}`)
const hiddenCount = rows.length - preview.length
return hiddenCount > 0 ? `${preview.join('、')}${rows.length}` : preview.join('、')
}
function openShop(url: string) {
window.open(url, '_blank')
}
onMounted(() => {
loadHistory().catch(() => undefined)
})
</script>
<style scoped>
.module-page { min-height: 100vh; background: #1a1a1a; }
.main-content { display: flex; min-height: calc(100vh - 56px); height: calc(100vh - 56px); }
.left-panel { width: 380px; background: #1e1e1e; padding: 20px; overflow-y: auto; border-right: 1px solid #2a2a2a; }
.right-panel { flex: 1; display: flex; flex-direction: column; min-width: 0; background: #1a1a1a; }
.section-title { font-size: 13px; color: #bbb; margin-bottom: 10px; }
.upload-zone { border: 1px dashed #3a3a3a; border-radius: 10px; padding: 24px; text-align: center; background: #252525; margin-bottom: 20px; transition: all 0.2s ease; }
.upload-zone:hover { border-color: #3498db; background: #2a2a2a; }
.hint { color: #888; font-size: 13px; margin-bottom: 12px; line-height: 1.5; }
.btns { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; }
.opt-btn, .btn-run, .download, .btn-delete { border: none; cursor: pointer; transition: all 0.2s ease; }
.opt-btn { padding: 8px 16px; font-size: 13px; color: #ccc; background: #2a2a2a; border: 1px solid #3a3a3a; border-radius: 6px; }
.opt-btn:hover { color: #3498db; background: #333; border-color: #3498db; }
.selected-files { margin-top: 14px; font-size: 12px; color: #888; max-height: 112px; overflow-y: auto; text-align: left; }
.selected-files span { display: block; margin: 4px 0; word-break: break-all; }
.more-line { color: #b8c1cc; }
.split-selected-files { max-height: 120px; min-height: 72px; padding-right: 4px; }
.option-group { margin-bottom: 20px; }
.radio-item { display: flex; align-items: flex-start; gap: 10px; cursor: pointer; padding: 10px 12px; border-radius: 8px; background: #252525; border: 1px solid #2a2a2a; margin-bottom: 8px; transition: all 0.2s ease; }
.radio-item:hover, .radio-item.active { background: #2a2a2a; border-color: #3a3a3a; }
.radio-item input { margin-top: 3px; }
.label { font-weight: 500; color: #e0e0e0; font-size: 13px; }
.desc { font-size: 12px; color: #888; margin-top: 2px; line-height: 1.5; font-weight: 400; }
.run-row { display: flex; align-items: center; flex-wrap: wrap; gap: 10px 12px; margin-top: 16px; }
.btn-run { padding: 10px 22px; background: #3498db; color: #fff; border-radius: 8px; font-size: 14px; }
.btn-run:hover { background: #2980b9; }
.btn-run:disabled { background: #555; color: #999; cursor: not-allowed; }
.loading-msg { color: #3498db; font-size: 13px; }
.panel-header { padding: 16px 20px; border-bottom: 1px solid #2a2a2a; font-size: 15px; font-weight: 600; color: #ddd; }
.task-list-wrap { flex: 1; overflow-y: auto; padding: 16px; }
.task-list { list-style: none; margin: 0; padding: 0; }
.task-item { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 16px; border: 1px solid #2a2a2a; border-radius: 8px; margin-bottom: 10px; background: #1e1e1e; }
.split-result-item { align-items: flex-start; }
.left { flex: 1; min-width: 0; }
.split-result-main { display: flex; flex-direction: column; gap: 4px; }
.id { display: inline-block; font-weight: 600; color: #e0e0e0; font-size: 13px; }
.files { font-size: 12px; color: #888; margin-top: 4px; }
.time { font-size: 12px; color: #666; margin-top: 2px; }
.split-entry-list { line-height: 1.6; word-break: break-all; max-width: 100%; }
.delete-brand-preview { white-space: normal; }
.task-right { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; justify-content: flex-end; }
.split-result-actions { flex-shrink: 0; min-width: 180px; justify-content: flex-end; align-self: center; }
.status { padding: 4px 10px; border-radius: 6px; font-size: 12px; white-space: nowrap; }
.status.success { background: rgba(46, 204, 113, 0.18); color: #2ecc71; }
.status.failed { background: rgba(231, 76, 60, 0.18); color: #ff6b6b; }
.download { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(52, 152, 219, 0.18); color: #69b6ff; }
.download:hover { background: rgba(52, 152, 219, 0.28); }
.btn-delete { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(231, 76, 60, 0.12); color: #ff8f8f; }
.btn-delete:hover { background: rgba(231, 76, 60, 0.22); }
.empty-tasks { color: #666; font-size: 13px; padding: 24px 8px; }
.clean-placeholder { max-height: 112px; }
.clean-result-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 220px)); gap: 16px; margin-bottom: 18px; }
.summary-card { padding: 16px 18px; border: 1px solid #2a2a2a; border-radius: 10px; background: #1e1e1e; }
.summary-card strong { display: block; margin-top: 8px; font-size: 24px; color: #eaf4ff; }
.summary-label { font-size: 12px; color: #8d8d8d; }
.result-list-wrap { border: 1px solid #2a2a2a; border-radius: 10px; background: #1e1e1e; min-height: 260px; }
.result-list-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid #2a2a2a; font-size: 14px; color: #ddd; }
@media (max-width: 1100px) { .main-content { flex-direction: column; height: auto; } .left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2a2a2a; } .right-panel { min-height: 420px; } .task-item { flex-direction: column; align-items: flex-start; } .task-right { width: 100%; justify-content: flex-start; } .split-result-actions { min-width: 0; align-self: flex-start; } .clean-result-summary { grid-template-columns: 1fr; } }
</style>

View File

@@ -20,7 +20,7 @@
<script setup lang="ts">
const props = defineProps<{
active: 'brand' | 'dedupe' | 'convert' | 'split'
active: 'brand' | 'dedupe' | 'convert' | 'split' | 'delete-brand'
}>()
const active = props.active
@@ -30,6 +30,7 @@ const navItems = [
{ key: 'dedupe', label: '数据去重', href: '/new_web_source/dedupe.html' },
{ key: 'split', label: '数据拆分', href: '/new_web_source/split.html' },
{ key: 'convert', label: '格式转换', href: '/new_web_source/convert.html' },
{ key: 'delete-brand', label: '删除品牌', href: '/new_web_source/delete-brand.html' },
] as const
</script>

View File

@@ -1,5 +1,14 @@
import { requestDeleteJson, requestGetJson, requestPostJson } from '@/shared/api/http'
function getCurrentUserId() {
const raw = typeof window === 'undefined' ? '' : window.localStorage.getItem('uid') || ''
const value = Number(raw)
if (!Number.isFinite(value) || value <= 0) {
throw new Error('未获取到用户ID')
}
return value
}
export interface BrandExpandFolderItem {
absolutePath: string
relativePath: string
@@ -47,11 +56,11 @@ export interface BrandTaskMutationResponse {
const API_PREFIX = ''
export function getBrandTaskEventsUrl(taskId: string | number) {
return `${API_PREFIX}/api/brand/tasks/${taskId}/events`
return `${API_PREFIX}/api/brand/tasks/${taskId}/events?user_id=${encodeURIComponent(String(getCurrentUserId()))}`
}
export function getBrandTaskDownloadUrl(taskId: string | number) {
return `${API_PREFIX}/api/brand/download/${taskId}`
return `${API_PREFIX}/api/brand/tasks/${taskId}/download?user_id=${encodeURIComponent(String(getCurrentUserId()))}`
}
export function getBrandTemplateXlsxUrl() {
@@ -75,23 +84,23 @@ export function runBrandNow(paths: string[], strategy: string) {
}
export function createBrandTask(paths: string[], strategy: string) {
return requestPostJson<BrandTaskMutationResponse>(`${API_PREFIX}/api/brand/tasks`, { paths, strategy })
return requestPostJson<BrandTaskMutationResponse>(`${API_PREFIX}/api/brand/tasks?userId=${encodeURIComponent(String(getCurrentUserId()))}`, { paths, strategy })
}
export function getBrandTasks() {
return requestGetJson<BrandTaskListResponse>(`${API_PREFIX}/api/brand/tasks`)
return requestGetJson<BrandTaskListResponse>(`${API_PREFIX}/api/brand/tasks?userId=${encodeURIComponent(String(getCurrentUserId()))}`)
}
export function getBrandTask(taskId: string | number) {
return requestGetJson<BrandTaskDetailResponse>(`${API_PREFIX}/api/brand/tasks/${taskId}`)
return requestGetJson<BrandTaskDetailResponse>(`${API_PREFIX}/api/brand/tasks/${taskId}?userId=${encodeURIComponent(String(getCurrentUserId()))}`)
}
export function cancelBrandTask(taskId: string | number) {
return requestPostJson<BrandTaskMutationResponse>(`${API_PREFIX}/api/brand/tasks/${taskId}/cancel`)
return requestPostJson<BrandTaskMutationResponse>(`${API_PREFIX}/api/brand/tasks/${taskId}/cancel?userId=${encodeURIComponent(String(getCurrentUserId()))}`)
}
export function deleteBrandTask(taskId: string | number) {
return requestDeleteJson<BrandTaskMutationResponse>(`${API_PREFIX}/api/brand/tasks/${taskId}`)
return requestDeleteJson<BrandTaskMutationResponse>(`${API_PREFIX}/api/brand/tasks/${taskId}?userId=${encodeURIComponent(String(getCurrentUserId()))}`)
}
export function createBrandTaskEvents(taskId: string | number) {

View File

@@ -2,6 +2,15 @@ import { del, get, http, post, type JavaApiResponse, unwrapJavaResponse } from '
const JAVA_API_PREFIX = '/newApi/api'
function getCurrentUserId() {
const raw = typeof window === 'undefined' ? '' : window.localStorage.getItem('uid') || ''
const value = Number(raw)
if (!Number.isFinite(value) || value <= 0) {
throw new Error('未获取到用户ID')
}
return value
}
export interface UploadedFileRef {
fileKey: string
originalFilename?: string
@@ -43,6 +52,7 @@ export interface DedupeRunRequest {
keepUnderscoreIds: boolean
keepIntegerMainIdsWhenNoSubIds: boolean
archiveName?: string
user_id: number
}
export interface SplitArchiveEntry {
@@ -80,6 +90,7 @@ export interface SplitRunRequest {
rowsPerFile?: number
parts?: number
archiveName?: string
user_id: number
}
export interface ConvertResultItem {
@@ -106,6 +117,45 @@ export interface ConvertRunRequest {
files: UploadedFileRef[]
templateId: string
archiveName?: string
user_id: number
}
export interface DeleteBrandPreviewRow {
rowIndex: number
country: string
asin: string
status: string
}
export interface DeleteBrandResultItem {
resultId?: number
sourceFilename: string
shopName?: string
matched: boolean
shopId?: string
platform?: string
openStoreUrl?: string
totalRows?: number
truncated?: boolean
previewRows?: DeleteBrandPreviewRow[]
success: boolean
error?: string
}
export interface DeleteBrandRunVo {
total: number
successCount: number
failedCount: number
items: DeleteBrandResultItem[]
}
export interface DeleteBrandHistoryVo {
items: DeleteBrandResultItem[]
}
export interface DeleteBrandRunRequest {
files: UploadedFileRef[]
user_id: number
}
export interface ConvertTemplateVo {
@@ -133,28 +183,42 @@ export function getExcelInfo(fileKey: string) {
}))
}
export function runDedupe(request: DedupeRunRequest) {
return unwrapJavaResponse(post<JavaApiResponse<DedupeRunVo>, DedupeRunRequest>(`${JAVA_API_PREFIX}/dedupe/run`, request))
export function runDedupe(request: Omit<DedupeRunRequest, 'user_id'> | DedupeRunRequest) {
return unwrapJavaResponse(post<JavaApiResponse<DedupeRunVo>, DedupeRunRequest>(`${JAVA_API_PREFIX}/dedupe/run`, {
...request,
user_id: getCurrentUserId(),
}))
}
export function getDedupeHistory() {
return unwrapJavaResponse(get<JavaApiResponse<DedupeHistoryVo>>(`${JAVA_API_PREFIX}/dedupe/history`))
return unwrapJavaResponse(get<JavaApiResponse<DedupeHistoryVo>>(`${JAVA_API_PREFIX}/dedupe/history`, {
params: { user_id: getCurrentUserId() },
}))
}
export function deleteDedupeHistory(resultId: number) {
return unwrapJavaResponse(del<JavaApiResponse<null>>(`${JAVA_API_PREFIX}/dedupe/history/${resultId}`))
return unwrapJavaResponse(del<JavaApiResponse<null>>(`${JAVA_API_PREFIX}/dedupe/history/${resultId}`, {
params: { user_id: getCurrentUserId() },
}))
}
export function runSplit(request: SplitRunRequest) {
return unwrapJavaResponse(post<JavaApiResponse<SplitRunVo>, SplitRunRequest>(`${JAVA_API_PREFIX}/split/run`, request))
export function runSplit(request: Omit<SplitRunRequest, 'user_id'> | SplitRunRequest) {
return unwrapJavaResponse(post<JavaApiResponse<SplitRunVo>, SplitRunRequest>(`${JAVA_API_PREFIX}/split/run`, {
...request,
user_id: getCurrentUserId(),
}))
}
export function getSplitHistory() {
return unwrapJavaResponse(get<JavaApiResponse<SplitHistoryVo>>(`${JAVA_API_PREFIX}/split/history`))
return unwrapJavaResponse(get<JavaApiResponse<SplitHistoryVo>>(`${JAVA_API_PREFIX}/split/history`, {
params: { user_id: getCurrentUserId() },
}))
}
export function deleteSplitHistory(resultId: number) {
return unwrapJavaResponse(del<JavaApiResponse<null>>(`${JAVA_API_PREFIX}/split/history/${resultId}`))
return unwrapJavaResponse(del<JavaApiResponse<null>>(`${JAVA_API_PREFIX}/split/history/${resultId}`, {
params: { user_id: getCurrentUserId() },
}))
}
export function getConvertTemplates() {
@@ -175,20 +239,48 @@ export function deleteConvertTemplate(templateCode: string) {
return unwrapJavaResponse(del<JavaApiResponse<null>>(`${JAVA_API_PREFIX}/convert/templates/${templateCode}`))
}
export function runConvert(request: ConvertRunRequest) {
return unwrapJavaResponse(post<JavaApiResponse<ConvertRunVo>, ConvertRunRequest>(`${JAVA_API_PREFIX}/convert/run`, request))
export function runConvert(request: Omit<ConvertRunRequest, 'user_id'> | ConvertRunRequest) {
return unwrapJavaResponse(post<JavaApiResponse<ConvertRunVo>, ConvertRunRequest>(`${JAVA_API_PREFIX}/convert/run`, {
...request,
user_id: getCurrentUserId(),
}))
}
export function getConvertHistory() {
return unwrapJavaResponse(get<JavaApiResponse<ConvertHistoryVo>>(`${JAVA_API_PREFIX}/convert/history`))
return unwrapJavaResponse(get<JavaApiResponse<ConvertHistoryVo>>(`${JAVA_API_PREFIX}/convert/history`, {
params: { user_id: getCurrentUserId() },
}))
}
export function deleteConvertHistory(resultId: number) {
return unwrapJavaResponse(del<JavaApiResponse<null>>(`${JAVA_API_PREFIX}/convert/history/${resultId}`))
return unwrapJavaResponse(del<JavaApiResponse<null>>(`${JAVA_API_PREFIX}/convert/history/${resultId}`, {
params: { user_id: getCurrentUserId() },
}))
}
export function runDeleteBrand(request: Omit<DeleteBrandRunRequest, 'user_id'> | DeleteBrandRunRequest) {
return unwrapJavaResponse(post<JavaApiResponse<DeleteBrandRunVo>, DeleteBrandRunRequest>(`${JAVA_API_PREFIX}/delete-brand/run`, {
...request,
user_id: getCurrentUserId(),
}))
}
export function getDeleteBrandHistory() {
return unwrapJavaResponse(get<JavaApiResponse<DeleteBrandHistoryVo>>(`${JAVA_API_PREFIX}/delete-brand/history`, {
params: { user_id: getCurrentUserId() },
}))
}
export function deleteDeleteBrandHistory(resultId: number) {
return unwrapJavaResponse(del<JavaApiResponse<null>>(`${JAVA_API_PREFIX}/delete-brand/history/${resultId}`, {
params: { user_id: getCurrentUserId() },
}))
}
export function getJavaDownloadUrl(path: string) {
return path.startsWith('http://') || path.startsWith('https://') ? path : `${JAVA_API_PREFIX}${path}`
const raw = path.startsWith('http://') || path.startsWith('https://') ? path : `${JAVA_API_PREFIX}${path}`
const separator = raw.includes('?') ? '&' : '?'
return `${raw}${separator}user_id=${encodeURIComponent(String(getCurrentUserId()))}`
}
export { JAVA_API_PREFIX, http }

View File

@@ -45,6 +45,7 @@ export default defineConfig({
dedupe: resolve(__dirname, 'dedupe.html'),
convert: resolve(__dirname, 'convert.html'),
split: resolve(__dirname, 'split.html'),
'delete-brand': resolve(__dirname, 'delete-brand.html'),
},
output: {
entryFileNames: 'assets/[name].js',