改造后台权限和APP权限
This commit is contained in:
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.
@@ -267,7 +267,8 @@ def admin_delete_user(uid):
|
||||
@login_required
|
||||
def admin_user_column_permissions(uid):
|
||||
"""获取指定用户的栏目权限列表:当前用户只能查自己,管理员可查任意用户。超级管理员返回全部栏目。"""
|
||||
current_uid = session.get('user_id')
|
||||
current_uid = session.get('user_id')
|
||||
menu_type = (request.args.get('menu_type') or '').strip().lower()
|
||||
if current_uid != uid:
|
||||
role, _ = _get_current_admin_role()
|
||||
if not role:
|
||||
@@ -277,28 +278,44 @@ def admin_user_column_permissions(uid):
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT role FROM users WHERE id = %s", (uid,))
|
||||
user_row = cur.fetchone()
|
||||
if user_row and (user_row.get('role') or '').strip() == 'super_admin':
|
||||
cur.execute("""
|
||||
SELECT id, name, column_key, created_at FROM columns ORDER BY id
|
||||
""")
|
||||
rows = cur.fetchall()
|
||||
else:
|
||||
cur.execute("""
|
||||
SELECT c.id, c.name, c.column_key, c.created_at
|
||||
FROM columns c
|
||||
INNER JOIN user_column_permission ucp ON ucp.column_id = c.id
|
||||
WHERE ucp.user_id = %s
|
||||
ORDER BY c.id
|
||||
""", (uid,))
|
||||
rows = cur.fetchall()
|
||||
valid_menu_type = menu_type if menu_type in ('app', 'admin') else ''
|
||||
if user_row and (user_row.get('role') or '').strip() == 'super_admin':
|
||||
sql = """
|
||||
SELECT id, name, column_key, menu_type, route_path, sort_order, created_at
|
||||
FROM columns
|
||||
"""
|
||||
params = []
|
||||
if valid_menu_type:
|
||||
sql += " WHERE menu_type = %s"
|
||||
params.append(valid_menu_type)
|
||||
sql += " ORDER BY sort_order ASC, id ASC"
|
||||
cur.execute(sql, tuple(params))
|
||||
rows = cur.fetchall()
|
||||
else:
|
||||
sql = """
|
||||
SELECT c.id, c.name, c.column_key, c.menu_type, c.route_path, c.sort_order, c.created_at
|
||||
FROM columns c
|
||||
INNER JOIN user_column_permission ucp ON ucp.column_id = c.id
|
||||
WHERE ucp.user_id = %s
|
||||
"""
|
||||
params = [uid]
|
||||
if valid_menu_type:
|
||||
sql += " AND c.menu_type = %s"
|
||||
params.append(valid_menu_type)
|
||||
sql += " ORDER BY c.sort_order ASC, c.id ASC"
|
||||
cur.execute(sql, tuple(params))
|
||||
rows = cur.fetchall()
|
||||
conn.close()
|
||||
items = [
|
||||
{
|
||||
'id': r['id'],
|
||||
'name': r['name'],
|
||||
'column_key': r['column_key'],
|
||||
'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r.get('created_at') else '',
|
||||
}
|
||||
{
|
||||
'id': r['id'],
|
||||
'name': r['name'],
|
||||
'column_key': r['column_key'],
|
||||
'menu_type': r.get('menu_type') or 'app',
|
||||
'route_path': r.get('route_path') or '',
|
||||
'sort_order': r.get('sort_order') or 0,
|
||||
'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r.get('created_at') else '',
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
return jsonify({'success': True, 'items': items})
|
||||
|
||||
@@ -50,10 +50,10 @@ def wb():
|
||||
return _render_html('index.html')
|
||||
|
||||
|
||||
@main_bp.route('/brand')
|
||||
@login_required
|
||||
def brand_page():
|
||||
return _render_html('brand.html')
|
||||
@main_bp.route('/brand')
|
||||
@login_required
|
||||
def brand_page():
|
||||
return _render_html('brand.html', user_id=session.get('user_id'))
|
||||
|
||||
|
||||
|
||||
@@ -161,4 +161,4 @@ def proxy(path):
|
||||
return Response("无法连接到后端服务", status=502)
|
||||
except Exception as e:
|
||||
print(f"代理请求失败: {e}")
|
||||
return Response(f"代理错误: {str(e)}", status=500)
|
||||
return Response(f"代理错误: {str(e)}", status=500)
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
<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-D4gpiFjY.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-CXHemZwJ.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-D-mMH8F6.css">
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-ClWy2SbE.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-DBpLCgAF.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-hj_EXwAP.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/convert-7wWJ02Tw.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
<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-D4gpiFjY.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-CXHemZwJ.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-D-mMH8F6.css">
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-ClWy2SbE.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-DBpLCgAF.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-hj_EXwAP.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/dedupe-BpNHwt51.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
<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-D4gpiFjY.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-CXHemZwJ.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-D-mMH8F6.css">
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-ClWy2SbE.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-DBpLCgAF.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-hj_EXwAP.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/delete-brand-CdxGWtvK.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
<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-D4gpiFjY.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-CXHemZwJ.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-D-mMH8F6.css">
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-ClWy2SbE.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-DBpLCgAF.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-hj_EXwAP.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/split-CRUIYKS6.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -62,6 +62,10 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
body.nav-permissions-loading .nav-section[data-column-key] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-section + .nav-section::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
@@ -383,7 +387,7 @@
|
||||
.toast.show { opacity: 1; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<body data-user-id="{{ user_id or '' }}" class="nav-permissions-loading">
|
||||
<header class="top-bar">
|
||||
<div class="logo-area">
|
||||
<span class="app-name">数富AI-亚马逊</span>
|
||||
@@ -391,33 +395,33 @@
|
||||
</div>
|
||||
<nav class="nav-tabs">
|
||||
<div class="nav-tab-group">
|
||||
<div class="nav-section">
|
||||
<div class="nav-section-title">前端工具</div>
|
||||
<div class="nav-section" data-column-key="brand_front_tools">
|
||||
<div class="nav-section-title">前端工具</div>
|
||||
<div class="nav-section-items">
|
||||
<button type="button" class="nav-item active" data-panel="brandCheck">品牌检测</button>
|
||||
<span class="nav-item disabled">采集数据</span>
|
||||
<span class="nav-item disabled">变体分析</span>
|
||||
<a class="nav-item" href="/new_web_source/dedupe.html">数据去重</a>
|
||||
<a class="nav-item" href="/new_web_source/split.html">数据拆分</a>
|
||||
<a class="nav-item" href="/new_web_source/convert.html">格式转换</a>
|
||||
<span class="nav-item disabled">采集数据</span>
|
||||
<span class="nav-item disabled">变体分析</span>
|
||||
<button type="button" class="nav-item active" data-panel="brandCheck">品牌检测</button>
|
||||
<a class="nav-item" href="/new_web_source/dedupe.html">数据去重</a>
|
||||
<a class="nav-item" href="/new_web_source/split.html">数据拆分</a>
|
||||
<a class="nav-item" href="/new_web_source/convert.html">格式转换</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nav-section">
|
||||
<div class="nav-section-title">运营工具</div>
|
||||
<div class="nav-section" data-column-key="brand_operation_tools">
|
||||
<div class="nav-section-title">运营工具</div>
|
||||
<div class="nav-section-items">
|
||||
<a class="nav-item" href="/new_web_source/delete-brand.html">删除ASIN</a>
|
||||
<a class="nav-item" href="/new_web_source/product-risk.html">商品风险解决</a>
|
||||
<a class="nav-item" href="/new_web_source/shop-match.html">定时匹配</a>
|
||||
<span class="nav-item disabled">跟价</span>
|
||||
<span class="nav-item disabled">巡店删除</span>
|
||||
<span class="nav-item disabled">取款</span>
|
||||
<span class="nav-item disabled">店铺状态查询</span>
|
||||
<a class="nav-item" href="/new_web_source/delete-brand.html">删除ASIN</a>
|
||||
<a class="nav-item" href="/new_web_source/product-risk.html">商品风险解决</a>
|
||||
<a class="nav-item" href="/new_web_source/shop-match.html">定时匹配</a>
|
||||
<a class="nav-item" href="/new_web_source/price-track.html">跟价</a>
|
||||
<span class="nav-item disabled">巡店删除</span>
|
||||
<span class="nav-item disabled">取款</span>
|
||||
<span class="nav-item disabled">店铺状态查询</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nav-section">
|
||||
<div class="nav-section-title">后勤工具</div>
|
||||
<div class="nav-section" data-column-key="brand_logistics_tools">
|
||||
<div class="nav-section-title">后勤工具</div>
|
||||
<div class="nav-section-items">
|
||||
<span class="nav-item disabled">采购</span>
|
||||
<span class="nav-item disabled">采购</span>
|
||||
<span class="nav-item disabled">ERP</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -535,9 +539,13 @@
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var selectedPaths = [];
|
||||
var cachedTasks = [];
|
||||
var api = window.pywebview && window.pywebview.api;
|
||||
var selectedPaths = [];
|
||||
var cachedTasks = [];
|
||||
var api = window.pywebview && window.pywebview.api;
|
||||
|
||||
function getAppPermissionCacheKey(uid) {
|
||||
return 'app_column_permissions:' + String(uid || '');
|
||||
}
|
||||
|
||||
function getUid() {
|
||||
return localStorage.getItem('uid') || '';
|
||||
@@ -550,20 +558,80 @@
|
||||
}
|
||||
|
||||
// 顶部栏目切换:点击当前页菜单项时显示对应 data-panel 的 tab-panel
|
||||
document.querySelectorAll('.nav-item[data-panel]').forEach(function(tab) {
|
||||
tab.addEventListener('click', function() {
|
||||
var panelId = this.getAttribute('data-panel');
|
||||
document.querySelectorAll('.nav-item[data-panel]').forEach(function(tab) {
|
||||
tab.addEventListener('click', function() {
|
||||
var panelId = this.getAttribute('data-panel');
|
||||
if (!panelId) return;
|
||||
document.querySelectorAll('.nav-item[data-panel]').forEach(function(t) { t.classList.remove('active'); });
|
||||
document.querySelectorAll('.tab-panel').forEach(function(p) {
|
||||
p.classList.toggle('active', p.getAttribute('data-panel') === panelId);
|
||||
});
|
||||
this.classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
function showToast(msg) {
|
||||
var el = document.getElementById('toast');
|
||||
this.classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
(function applyNavSectionPermissions() {
|
||||
function finishNavPermissionLoading() {
|
||||
document.body.classList.remove('nav-permissions-loading');
|
||||
}
|
||||
|
||||
function showAllNavSections() {
|
||||
document.querySelectorAll('.nav-section[data-column-key]').forEach(function(section) {
|
||||
section.style.display = '';
|
||||
});
|
||||
}
|
||||
|
||||
var uid = document.body.getAttribute('data-user-id');
|
||||
if (!uid) {
|
||||
showAllNavSections();
|
||||
finishNavPermissionLoading();
|
||||
return;
|
||||
}
|
||||
localStorage.setItem('uid', uid);
|
||||
var cacheKey = getAppPermissionCacheKey(uid);
|
||||
try {
|
||||
var cachedItems = JSON.parse(localStorage.getItem(cacheKey) || 'null');
|
||||
if (Array.isArray(cachedItems)) {
|
||||
showAllNavSections();
|
||||
var cachedKeys = cachedItems.map(function(item) {
|
||||
return (item.column_key || '').toLowerCase();
|
||||
});
|
||||
document.querySelectorAll('.nav-section[data-column-key]').forEach(function(section) {
|
||||
var key = (section.getAttribute('data-column-key') || '').toLowerCase();
|
||||
section.style.display = cachedKeys.indexOf(key) >= 0 ? '' : 'none';
|
||||
});
|
||||
finishNavPermissionLoading();
|
||||
return;
|
||||
}
|
||||
} catch (e) {}
|
||||
fetch('/api/admin/user/' + uid + '/column-permissions?menu_type=app', { credentials: 'same-origin' })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(res) {
|
||||
showAllNavSections();
|
||||
if (!res || !res.success || !Array.isArray(res.items)) {
|
||||
finishNavPermissionLoading();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(cacheKey, JSON.stringify(res.items));
|
||||
} catch (e) {}
|
||||
var allowedKeys = res.items.map(function(item) {
|
||||
return (item.column_key || '').toLowerCase();
|
||||
});
|
||||
document.querySelectorAll('.nav-section[data-column-key]').forEach(function(section) {
|
||||
var key = (section.getAttribute('data-column-key') || '').toLowerCase();
|
||||
section.style.display = allowedKeys.indexOf(key) >= 0 ? '' : 'none';
|
||||
});
|
||||
finishNavPermissionLoading();
|
||||
})
|
||||
.catch(function() {
|
||||
showAllNavSections();
|
||||
finishNavPermissionLoading();
|
||||
});
|
||||
})();
|
||||
|
||||
function showToast(msg) {
|
||||
var el = document.getElementById('toast');
|
||||
el.textContent = msg;
|
||||
el.classList.add('show');
|
||||
setTimeout(function() { el.classList.remove('show'); }, 2500);
|
||||
|
||||
@@ -217,17 +217,24 @@
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
function getAppPermissionCacheKey(uid) {
|
||||
return 'app_column_permissions:' + String(uid || '');
|
||||
}
|
||||
// 权限校验:根据 column-permissions 接口按 column_key 显示入口
|
||||
(function() {
|
||||
localStorage.setItem("uid",{{ user_id }})
|
||||
var uid = document.body.getAttribute('data-user-id');
|
||||
if (!uid) return;
|
||||
var cacheKey = getAppPermissionCacheKey(uid);
|
||||
var baseUrl = window.location.origin;
|
||||
var apiUrl = baseUrl + '/api/admin/user/' + uid + '/column-permissions';
|
||||
fetch(apiUrl, { credentials: 'same-origin' })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(res) {
|
||||
if (!res || !res.success || !Array.isArray(res.items)) return;
|
||||
try {
|
||||
localStorage.setItem(cacheKey, JSON.stringify(res.items));
|
||||
} catch (e) {}
|
||||
var allowedKeys = res.items.map(function(item) { return (item.column_key || '').toLowerCase(); });
|
||||
document.querySelectorAll('.entrances .entrance-btn[data-column-key]').forEach(function(btn) {
|
||||
var key = (btn.getAttribute('data-column-key') || '').toLowerCase();
|
||||
@@ -251,6 +258,10 @@
|
||||
function handleLogout() {
|
||||
try{
|
||||
localStorage.removeItem('maixiang_api_key');
|
||||
var uid = document.body.getAttribute('data-user-id');
|
||||
if (uid) {
|
||||
localStorage.removeItem(getAppPermissionCacheKey(uid));
|
||||
}
|
||||
}catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
|
||||
@@ -117,6 +117,21 @@
|
||||
</form>
|
||||
</div>
|
||||
<script>
|
||||
function clearAppPermissionCaches() {
|
||||
try {
|
||||
var keysToRemove = [];
|
||||
for (var i = 0; i < localStorage.length; i++) {
|
||||
var key = localStorage.key(i);
|
||||
if (key && key.indexOf('app_column_permissions:') === 0) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
keysToRemove.forEach(function(key) {
|
||||
localStorage.removeItem(key);
|
||||
});
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
(function() {
|
||||
fetch('/api/auth/check', { credentials: 'same-origin' })
|
||||
.then(function(r) { return r.json(); })
|
||||
@@ -142,6 +157,7 @@
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(res) {
|
||||
if (res.success) {
|
||||
clearAppPermissionCaches();
|
||||
window.location.href = res.redirect || '/home';
|
||||
} else {
|
||||
var errEl = document.querySelector('.error-msg');
|
||||
|
||||
BIN
backend-java/aiimage-backend.log.2026-04-17.0.gz
Normal file
BIN
backend-java/aiimage-backend.log.2026-04-17.0.gz
Normal file
Binary file not shown.
@@ -15,6 +15,12 @@ public class PermissionMenuSchemaInitializer {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
private static final List<DefaultAppMenu> DEFAULT_APP_MENUS = List.of(
|
||||
new DefaultAppMenu("前端工具", "brand_front_tools", "brand-front-tools", 110),
|
||||
new DefaultAppMenu("运营工具", "brand_operation_tools", "brand-operation-tools", 120),
|
||||
new DefaultAppMenu("后勤工具", "brand_logistics_tools", "brand-logistics-tools", 130)
|
||||
);
|
||||
|
||||
private static final List<DefaultAdminMenu> DEFAULT_ADMIN_MENUS = List.of(
|
||||
new DefaultAdminMenu("用户管理", "admin_users", "users", 10),
|
||||
new DefaultAdminMenu("栏目权限配置", "admin_columns", "columns", 20),
|
||||
@@ -44,6 +50,7 @@ public class PermissionMenuSchemaInitializer {
|
||||
executeQuietly("UPDATE columns SET sort_order = id WHERE sort_order IS NULL OR sort_order = 0");
|
||||
executeQuietly("ALTER TABLE columns ADD UNIQUE KEY uk_menu_type_route_path (menu_type, route_path)");
|
||||
ensureDefaultAdminMenus();
|
||||
ensureDefaultAppMenus();
|
||||
executeQuietly("""
|
||||
CREATE TABLE IF NOT EXISTS user_column_permission (
|
||||
user_id INT NOT NULL,
|
||||
@@ -72,6 +79,23 @@ public class PermissionMenuSchemaInitializer {
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureDefaultAppMenus() {
|
||||
for (DefaultAppMenu menu : DEFAULT_APP_MENUS) {
|
||||
executeQuietly("""
|
||||
INSERT INTO columns (name, column_key, menu_type, route_path, sort_order)
|
||||
SELECT '%s', '%s', 'app', '%s', %d
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM columns WHERE column_key = '%s'
|
||||
)
|
||||
""".formatted(menu.name(), menu.columnKey(), menu.routePath(), menu.sortOrder(), menu.columnKey()));
|
||||
executeQuietly("""
|
||||
UPDATE columns
|
||||
SET sort_order = %d, route_path = '%s', menu_type = 'app'
|
||||
WHERE column_key = '%s'
|
||||
""".formatted(menu.sortOrder(), menu.routePath(), menu.columnKey()));
|
||||
}
|
||||
}
|
||||
|
||||
private void executeQuietly(String sql) {
|
||||
try {
|
||||
jdbcTemplate.execute(sql);
|
||||
@@ -82,4 +106,7 @@ public class PermissionMenuSchemaInitializer {
|
||||
|
||||
private record DefaultAdminMenu(String name, String columnKey, String routePath, int sortOrder) {
|
||||
}
|
||||
|
||||
private record DefaultAppMenu(String name, String columnKey, String routePath, int sortOrder) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackCandidateAddRequest;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackCountryPreferenceSaveRequest;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackCreateTaskRequest;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackLoopRunChildFinishedRequest;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackLoopRunCreateRequest;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackMatchShopsRequest;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackSubmitResultRequest;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackTaskBatchRequest;
|
||||
@@ -12,9 +14,12 @@ import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackCountryPreference
|
||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackCreateTaskVo;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackDashboardVo;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackHistoryVo;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackLoopRunDispatchVo;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackLoopRunVo;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackMatchShopsVo;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackPendingDeleteVo;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackTaskBatchVo;
|
||||
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackLoopRunService;
|
||||
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackService;
|
||||
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
@@ -53,6 +58,7 @@ public class PriceTrackController {
|
||||
|
||||
private final PriceTrackService priceTrackService;
|
||||
private final PriceTrackTaskService priceTrackTaskService;
|
||||
private final PriceTrackLoopRunService priceTrackLoopRunService;
|
||||
|
||||
@GetMapping("/candidates")
|
||||
@Operation(summary = "查询备选店铺列表", description = "返回当前用户在跟价模块中保存的备选店铺。")
|
||||
@@ -131,6 +137,45 @@ public class PriceTrackController {
|
||||
return ApiResponse.success(priceTrackTaskService.createTask(request));
|
||||
}
|
||||
|
||||
@PostMapping("/loop-runs")
|
||||
@Operation(summary = "创建跟价循环任务", description = "创建整批店铺的多轮循环主任务。")
|
||||
public ApiResponse<PriceTrackLoopRunVo> createLoopRun(@Valid @RequestBody PriceTrackLoopRunCreateRequest request) {
|
||||
return ApiResponse.success(priceTrackLoopRunService.createLoopRun(request));
|
||||
}
|
||||
|
||||
@GetMapping("/loop-runs/{loopRunId}")
|
||||
@Operation(summary = "查询跟价循环任务", description = "返回循环任务当前状态与进度。")
|
||||
public ApiResponse<PriceTrackLoopRunVo> getLoopRun(
|
||||
@PathVariable Long loopRunId,
|
||||
@RequestParam("user_id") Long userId) {
|
||||
return ApiResponse.success(priceTrackLoopRunService.getLoopRun(loopRunId, userId));
|
||||
}
|
||||
|
||||
@PostMapping("/loop-runs/{loopRunId}/dispatch-next")
|
||||
@Operation(summary = "获取循环任务下一条派发信息", description = "当前无活跃子任务时调用,返回下一条应该创建并入队的子任务请求。")
|
||||
public ApiResponse<PriceTrackLoopRunDispatchVo> dispatchNext(
|
||||
@PathVariable Long loopRunId,
|
||||
@RequestParam("user_id") Long userId) {
|
||||
return ApiResponse.success(priceTrackLoopRunService.dispatchNext(loopRunId, userId));
|
||||
}
|
||||
|
||||
@PostMapping("/loop-runs/{loopRunId}/child-finished")
|
||||
@Operation(summary = "通知循环任务子任务已结束", description = "子任务进入终态后调用,用于推进到下一店或下一轮。")
|
||||
public ApiResponse<PriceTrackLoopRunVo> childFinished(
|
||||
@PathVariable Long loopRunId,
|
||||
@RequestParam("user_id") Long userId,
|
||||
@Valid @RequestBody PriceTrackLoopRunChildFinishedRequest request) {
|
||||
return ApiResponse.success(priceTrackLoopRunService.childFinished(loopRunId, userId, request));
|
||||
}
|
||||
|
||||
@PostMapping("/loop-runs/{loopRunId}/stop")
|
||||
@Operation(summary = "停止循环任务", description = "请求停止循环;若无活跃子任务则立即停止,否则等待当前子任务完成后收尾。")
|
||||
public ApiResponse<PriceTrackLoopRunVo> stopLoopRun(
|
||||
@PathVariable Long loopRunId,
|
||||
@RequestParam("user_id") Long userId) {
|
||||
return ApiResponse.success(priceTrackLoopRunService.requestStop(loopRunId, userId));
|
||||
}
|
||||
|
||||
@DeleteMapping("/tasks/{taskId}")
|
||||
@Operation(summary = "删除整条任务", description = "删除该任务以及其下全部结果行,RUNNING、SUCCESS、FAILED 均可删除。")
|
||||
public ApiResponse<Void> deleteTask(
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.nanri.aiimage.modules.pricetrack.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.entity.PriceTrackLoopRunEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface PriceTrackLoopRunMapper extends BaseMapper<PriceTrackLoopRunEntity> {
|
||||
}
|
||||
@@ -33,4 +33,12 @@ public class PriceTrackCreateTaskRequest {
|
||||
@NotEmpty(message = "国家列表不能为空")
|
||||
@Schema(description = "国家代码列表,顺序即跟价处理顺序", requiredMode = Schema.RequiredMode.REQUIRED, example = "[\"DE\",\"UK\",\"FR\"]")
|
||||
private List<String> countryCodes;
|
||||
@Schema(description = "optional loop run id", example = "1")
|
||||
private Long loopRunId;
|
||||
|
||||
@Schema(description = "optional round index, 1-based", example = "2")
|
||||
private Integer roundIndex;
|
||||
|
||||
@Schema(description = "optional shop index, 0-based", example = "0")
|
||||
private Integer shopIndex;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.nanri.aiimage.modules.pricetrack.model.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "循环任务子任务完成通知")
|
||||
public class PriceTrackLoopRunChildFinishedRequest {
|
||||
|
||||
@NotNull(message = "childTaskId不能为空")
|
||||
private Long childTaskId;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.nanri.aiimage.modules.pricetrack.model.dto;
|
||||
|
||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackMatchShopsVo;
|
||||
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.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "创建跟价循环任务请求")
|
||||
public class PriceTrackLoopRunCreateRequest {
|
||||
|
||||
@NotNull(message = "user_id不能为空")
|
||||
private Long userId;
|
||||
|
||||
private boolean statusMode;
|
||||
|
||||
private boolean asinMode;
|
||||
|
||||
@NotEmpty(message = "items不能为空")
|
||||
@Valid
|
||||
private List<PriceTrackMatchShopsVo.PriceTrackShopQueueItem> items = new ArrayList<>();
|
||||
|
||||
private List<String> asinFiles = new ArrayList<>();
|
||||
|
||||
@NotEmpty(message = "countryCodes不能为空")
|
||||
private List<String> countryCodes = new ArrayList<>();
|
||||
|
||||
@NotBlank(message = "executionMode不能为空")
|
||||
@Schema(description = "FINITE or INFINITE", example = "FINITE")
|
||||
private String executionMode;
|
||||
|
||||
@Schema(description = "固定轮次时必填", example = "3")
|
||||
private Integer targetRounds;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.nanri.aiimage.modules.pricetrack.model.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("biz_price_track_loop_run")
|
||||
public class PriceTrackLoopRunEntity {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private String status;
|
||||
private String executionMode;
|
||||
private Integer targetRounds;
|
||||
private Integer currentRound;
|
||||
private Integer currentShopIndex;
|
||||
private Boolean statusMode;
|
||||
private Boolean asinMode;
|
||||
private String asinFilesJson;
|
||||
private String countryCodesJson;
|
||||
private String shopsJson;
|
||||
private Long activeTaskId;
|
||||
private Boolean stopRequested;
|
||||
private String errorMessage;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
private LocalDateTime finishedAt;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.nanri.aiimage.modules.pricetrack.model.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class PriceTrackCreateTaskRequestVo {
|
||||
private Long userId;
|
||||
private boolean statusMode;
|
||||
private boolean asinMode;
|
||||
private List<PriceTrackMatchShopsVo.PriceTrackShopQueueItem> items = new ArrayList<>();
|
||||
private List<String> asinFiles = new ArrayList<>();
|
||||
private List<String> countryCodes = new ArrayList<>();
|
||||
private Long loopRunId;
|
||||
private Integer roundIndex;
|
||||
private Integer shopIndex;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.nanri.aiimage.modules.pricetrack.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "循环任务下一条派发信息")
|
||||
public class PriceTrackLoopRunDispatchVo {
|
||||
|
||||
private PriceTrackLoopRunVo loopRun;
|
||||
|
||||
private PriceTrackCreateTaskRequestVo childTaskRequest;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.nanri.aiimage.modules.pricetrack.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 PriceTrackLoopRunVo {
|
||||
|
||||
private Long id;
|
||||
private String status;
|
||||
private String executionMode;
|
||||
private Integer targetRounds;
|
||||
private Integer currentRound;
|
||||
private Integer currentShopIndex;
|
||||
private Integer totalShopCount;
|
||||
private Long activeTaskId;
|
||||
private String activeTaskStatus;
|
||||
private Boolean stopRequested;
|
||||
private String errorMessage;
|
||||
private Boolean statusMode;
|
||||
private Boolean asinMode;
|
||||
private List<String> asinFiles = new ArrayList<>();
|
||||
private List<String> countryCodes = new ArrayList<>();
|
||||
}
|
||||
@@ -49,4 +49,10 @@ public class PriceTrackResultItemVo {
|
||||
|
||||
@Schema(description = "预签名下载 URL,列表可能带回,也可通过下载接口获取")
|
||||
private String downloadUrl;
|
||||
|
||||
@Schema(description = "所属循环任务 ID", example = "1")
|
||||
private Long loopRunId;
|
||||
|
||||
@Schema(description = "所属轮次,1-based", example = "2")
|
||||
private Integer roundIndex;
|
||||
}
|
||||
|
||||
@@ -26,4 +26,10 @@ public class PriceTrackTaskItemVo {
|
||||
|
||||
@Schema(description = "完成时间;未结束时为空")
|
||||
private String finishedAt;
|
||||
|
||||
@Schema(description = "所属循环任务 ID", example = "1")
|
||||
private Long loopRunId;
|
||||
|
||||
@Schema(description = "所属轮次,1-based", example = "2")
|
||||
private Integer roundIndex;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
package com.nanri.aiimage.modules.pricetrack.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.modules.pricetrack.mapper.PriceTrackLoopRunMapper;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackLoopRunChildFinishedRequest;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackLoopRunCreateRequest;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.entity.PriceTrackLoopRunEntity;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackCreateTaskRequestVo;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackLoopRunDispatchVo;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackLoopRunVo;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackMatchShopsVo;
|
||||
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
|
||||
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class PriceTrackLoopRunService {
|
||||
|
||||
private static final String MODULE_TYPE = "PRICE_TRACK";
|
||||
private static final String STATUS_RUNNING = "RUNNING";
|
||||
private static final String STATUS_SUCCESS = "SUCCESS";
|
||||
private static final String STATUS_FAILED = "FAILED";
|
||||
private static final String STATUS_STOPPED = "STOPPED";
|
||||
private static final String EXECUTION_MODE_FINITE = "FINITE";
|
||||
private static final String EXECUTION_MODE_INFINITE = "INFINITE";
|
||||
|
||||
private final PriceTrackLoopRunMapper loopRunMapper;
|
||||
private final FileTaskMapper fileTaskMapper;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ZiniaoShopSwitchService ziniaoShopSwitchService;
|
||||
|
||||
@Transactional
|
||||
public PriceTrackLoopRunVo createLoopRun(PriceTrackLoopRunCreateRequest request) {
|
||||
validateCreateRequest(request);
|
||||
List<PriceTrackMatchShopsVo.PriceTrackShopQueueItem> items = dedupeItems(request.getItems());
|
||||
if (items.isEmpty()) {
|
||||
throw new BusinessException("items不能为空");
|
||||
}
|
||||
PriceTrackLoopRunEntity entity = new PriceTrackLoopRunEntity();
|
||||
entity.setUserId(request.getUserId());
|
||||
entity.setStatus(STATUS_RUNNING);
|
||||
entity.setExecutionMode(normalizeExecutionMode(request.getExecutionMode()));
|
||||
entity.setTargetRounds("FINITE".equals(normalizeExecutionMode(request.getExecutionMode())) ? request.getTargetRounds() : null);
|
||||
entity.setCurrentRound(1);
|
||||
entity.setCurrentShopIndex(0);
|
||||
entity.setStatusMode(request.isStatusMode());
|
||||
entity.setAsinMode(request.isAsinMode());
|
||||
entity.setActiveTaskId(null);
|
||||
entity.setStopRequested(false);
|
||||
entity.setErrorMessage(null);
|
||||
entity.setCreatedAt(LocalDateTime.now());
|
||||
entity.setUpdatedAt(LocalDateTime.now());
|
||||
try {
|
||||
entity.setAsinFilesJson(objectMapper.writeValueAsString(request.getAsinFiles() == null ? List.of() : request.getAsinFiles()));
|
||||
entity.setCountryCodesJson(objectMapper.writeValueAsString(request.getCountryCodes()));
|
||||
entity.setShopsJson(objectMapper.writeValueAsString(items));
|
||||
} catch (Exception ex) {
|
||||
throw new BusinessException("循环任务序列化失败");
|
||||
}
|
||||
loopRunMapper.insert(entity);
|
||||
return toVo(entity);
|
||||
}
|
||||
|
||||
public PriceTrackLoopRunVo getLoopRun(Long loopRunId, Long userId) {
|
||||
PriceTrackLoopRunEntity entity = getOwnedLoopRun(loopRunId, userId);
|
||||
reconcileWithTerminalChild(entity);
|
||||
return toVo(entity);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PriceTrackLoopRunDispatchVo dispatchNext(Long loopRunId, Long userId) {
|
||||
PriceTrackLoopRunEntity entity = getOwnedLoopRun(loopRunId, userId);
|
||||
reconcileWithTerminalChild(entity);
|
||||
if (isTerminal(entity.getStatus())) {
|
||||
PriceTrackLoopRunDispatchVo vo = new PriceTrackLoopRunDispatchVo();
|
||||
vo.setLoopRun(toVo(entity));
|
||||
vo.setChildTaskRequest(null);
|
||||
return vo;
|
||||
}
|
||||
if (Boolean.TRUE.equals(entity.getStopRequested()) && entity.getActiveTaskId() == null) {
|
||||
markStopped(entity, null);
|
||||
PriceTrackLoopRunDispatchVo vo = new PriceTrackLoopRunDispatchVo();
|
||||
vo.setLoopRun(toVo(entity));
|
||||
vo.setChildTaskRequest(null);
|
||||
return vo;
|
||||
}
|
||||
if (entity.getActiveTaskId() != null) {
|
||||
throw new BusinessException("当前仍有执行中的子任务");
|
||||
}
|
||||
List<PriceTrackMatchShopsVo.PriceTrackShopQueueItem> items = parseShops(entity);
|
||||
if (items.isEmpty()) {
|
||||
throw new BusinessException("循环任务缺少店铺配置");
|
||||
}
|
||||
int shopIndex = Math.max(0, entity.getCurrentShopIndex() == null ? 0 : entity.getCurrentShopIndex());
|
||||
if (shopIndex >= items.size()) {
|
||||
advanceAfterSuccessfulRound(entity, items.size());
|
||||
if (isTerminal(entity.getStatus())) {
|
||||
PriceTrackLoopRunDispatchVo vo = new PriceTrackLoopRunDispatchVo();
|
||||
vo.setLoopRun(toVo(entity));
|
||||
vo.setChildTaskRequest(null);
|
||||
return vo;
|
||||
}
|
||||
shopIndex = Math.max(0, entity.getCurrentShopIndex() == null ? 0 : entity.getCurrentShopIndex());
|
||||
}
|
||||
PriceTrackCreateTaskRequestVo childRequest = new PriceTrackCreateTaskRequestVo();
|
||||
childRequest.setUserId(entity.getUserId());
|
||||
childRequest.setStatusMode(Boolean.TRUE.equals(entity.getStatusMode()));
|
||||
childRequest.setAsinMode(Boolean.TRUE.equals(entity.getAsinMode()));
|
||||
childRequest.setAsinFiles(parseStringList(entity.getAsinFilesJson()));
|
||||
childRequest.setCountryCodes(parseStringList(entity.getCountryCodesJson()));
|
||||
childRequest.setItems(List.of(items.get(shopIndex)));
|
||||
childRequest.setLoopRunId(entity.getId());
|
||||
childRequest.setRoundIndex(Math.max(1, entity.getCurrentRound() == null ? 1 : entity.getCurrentRound()));
|
||||
childRequest.setShopIndex(shopIndex);
|
||||
|
||||
PriceTrackLoopRunDispatchVo vo = new PriceTrackLoopRunDispatchVo();
|
||||
vo.setLoopRun(toVo(entity));
|
||||
vo.setChildTaskRequest(childRequest);
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PriceTrackLoopRunVo childFinished(Long loopRunId, Long userId, PriceTrackLoopRunChildFinishedRequest request) {
|
||||
PriceTrackLoopRunEntity entity = getOwnedLoopRun(loopRunId, userId);
|
||||
handleChildFinished(entity, request.getChildTaskId(), true);
|
||||
return toVo(entity);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PriceTrackLoopRunVo requestStop(Long loopRunId, Long userId) {
|
||||
PriceTrackLoopRunEntity entity = getOwnedLoopRun(loopRunId, userId);
|
||||
entity.setStopRequested(true);
|
||||
entity.setUpdatedAt(LocalDateTime.now());
|
||||
if (entity.getActiveTaskId() == null || isChildTaskTerminal(entity.getActiveTaskId())) {
|
||||
markStopped(entity, null);
|
||||
} else {
|
||||
loopRunMapper.updateById(entity);
|
||||
}
|
||||
return toVo(entity);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void bindChildTask(Long loopRunId, Long childTaskId, Integer roundIndex, Integer shopIndex) {
|
||||
if (loopRunId == null || childTaskId == null) {
|
||||
return;
|
||||
}
|
||||
PriceTrackLoopRunEntity entity = loopRunMapper.selectById(loopRunId);
|
||||
if (entity == null || isTerminal(entity.getStatus())) {
|
||||
return;
|
||||
}
|
||||
if (entity.getCurrentRound() == null || entity.getCurrentShopIndex() == null) {
|
||||
return;
|
||||
}
|
||||
if (!entity.getCurrentRound().equals(roundIndex) || !entity.getCurrentShopIndex().equals(shopIndex)) {
|
||||
return;
|
||||
}
|
||||
if (entity.getActiveTaskId() != null && !isChildTaskTerminal(entity.getActiveTaskId())) {
|
||||
return;
|
||||
}
|
||||
entity.setActiveTaskId(childTaskId);
|
||||
entity.setUpdatedAt(LocalDateTime.now());
|
||||
loopRunMapper.updateById(entity);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void syncLoopRunAfterChildTerminal(Long childTaskId) {
|
||||
if (childTaskId == null || childTaskId <= 0) {
|
||||
return;
|
||||
}
|
||||
List<PriceTrackLoopRunEntity> loops = loopRunMapper.selectList(new LambdaQueryWrapper<PriceTrackLoopRunEntity>()
|
||||
.eq(PriceTrackLoopRunEntity::getActiveTaskId, childTaskId)
|
||||
.last("limit 5"));
|
||||
for (PriceTrackLoopRunEntity entity : loops) {
|
||||
handleChildFinished(entity, childTaskId, false);
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void syncLoopRunAfterChildRemoved(Long childTaskId) {
|
||||
if (childTaskId == null || childTaskId <= 0) {
|
||||
return;
|
||||
}
|
||||
List<PriceTrackLoopRunEntity> loops = loopRunMapper.selectList(new LambdaQueryWrapper<PriceTrackLoopRunEntity>()
|
||||
.eq(PriceTrackLoopRunEntity::getActiveTaskId, childTaskId)
|
||||
.last("limit 5"));
|
||||
for (PriceTrackLoopRunEntity entity : loops) {
|
||||
if (entity == null || isTerminal(entity.getStatus())) {
|
||||
continue;
|
||||
}
|
||||
entity.setActiveTaskId(null);
|
||||
entity.setStatus(STATUS_STOPPED);
|
||||
entity.setStopRequested(true);
|
||||
entity.setErrorMessage("当前循环子任务已被删除,循环已停止");
|
||||
entity.setFinishedAt(LocalDateTime.now());
|
||||
entity.setUpdatedAt(LocalDateTime.now());
|
||||
loopRunMapper.updateById(entity);
|
||||
}
|
||||
}
|
||||
|
||||
private void handleChildFinished(PriceTrackLoopRunEntity entity, Long childTaskId, boolean validateOwnership) {
|
||||
if (entity == null || childTaskId == null || childTaskId <= 0 || isTerminal(entity.getStatus())) {
|
||||
return;
|
||||
}
|
||||
FileTaskEntity task = fileTaskMapper.selectById(childTaskId);
|
||||
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
||||
throw new BusinessException("子任务不存在");
|
||||
}
|
||||
if (validateOwnership && !entity.getUserId().equals(task.getUserId())) {
|
||||
throw new BusinessException("子任务不属于当前用户");
|
||||
}
|
||||
if (!isTerminal(task.getStatus())) {
|
||||
throw new BusinessException("子任务尚未结束");
|
||||
}
|
||||
if (!belongsToLoop(task, entity.getId())) {
|
||||
throw new BusinessException("子任务不属于当前循环");
|
||||
}
|
||||
if (entity.getActiveTaskId() != null && !entity.getActiveTaskId().equals(childTaskId)) {
|
||||
return;
|
||||
}
|
||||
if (entity.getActiveTaskId() == null) {
|
||||
ChildTaskContext ctx = parseChildTaskContext(task);
|
||||
if (ctx == null
|
||||
|| !ctx.roundIndex().equals(entity.getCurrentRound())
|
||||
|| !ctx.shopIndex().equals(entity.getCurrentShopIndex())) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
entity.setActiveTaskId(null);
|
||||
entity.setUpdatedAt(LocalDateTime.now());
|
||||
if (STATUS_FAILED.equals(task.getStatus())) {
|
||||
entity.setStatus(STATUS_FAILED);
|
||||
entity.setErrorMessage(task.getErrorMessage() == null || task.getErrorMessage().isBlank()
|
||||
? "子任务执行失败"
|
||||
: task.getErrorMessage());
|
||||
entity.setFinishedAt(LocalDateTime.now());
|
||||
loopRunMapper.updateById(entity);
|
||||
return;
|
||||
}
|
||||
List<PriceTrackMatchShopsVo.PriceTrackShopQueueItem> items = parseShops(entity);
|
||||
if (items.isEmpty()) {
|
||||
entity.setStatus(STATUS_FAILED);
|
||||
entity.setErrorMessage("循环任务缺少店铺配置");
|
||||
entity.setFinishedAt(LocalDateTime.now());
|
||||
loopRunMapper.updateById(entity);
|
||||
return;
|
||||
}
|
||||
if (Boolean.TRUE.equals(entity.getStopRequested())) {
|
||||
markStopped(entity, null);
|
||||
return;
|
||||
}
|
||||
int nextShopIndex = (entity.getCurrentShopIndex() == null ? 0 : entity.getCurrentShopIndex()) + 1;
|
||||
if (nextShopIndex < items.size()) {
|
||||
entity.setCurrentShopIndex(nextShopIndex);
|
||||
loopRunMapper.updateById(entity);
|
||||
return;
|
||||
}
|
||||
advanceAfterSuccessfulRound(entity, items.size());
|
||||
}
|
||||
|
||||
private void reconcileWithTerminalChild(PriceTrackLoopRunEntity entity) {
|
||||
if (entity == null || entity.getActiveTaskId() == null || isTerminal(entity.getStatus())) {
|
||||
return;
|
||||
}
|
||||
if (isChildTaskTerminal(entity.getActiveTaskId())) {
|
||||
handleChildFinished(entity, entity.getActiveTaskId(), false);
|
||||
}
|
||||
}
|
||||
|
||||
private void advanceAfterSuccessfulRound(PriceTrackLoopRunEntity entity, int shopCount) {
|
||||
if (Boolean.TRUE.equals(entity.getStopRequested())) {
|
||||
markStopped(entity, null);
|
||||
return;
|
||||
}
|
||||
if (EXECUTION_MODE_FINITE.equals(entity.getExecutionMode())) {
|
||||
int targetRounds = entity.getTargetRounds() == null ? 1 : Math.max(1, entity.getTargetRounds());
|
||||
int currentRound = entity.getCurrentRound() == null ? 1 : Math.max(1, entity.getCurrentRound());
|
||||
if (currentRound >= targetRounds) {
|
||||
entity.setStatus(STATUS_SUCCESS);
|
||||
entity.setFinishedAt(LocalDateTime.now());
|
||||
} else {
|
||||
entity.setCurrentRound(currentRound + 1);
|
||||
entity.setCurrentShopIndex(0);
|
||||
}
|
||||
} else {
|
||||
int currentRound = entity.getCurrentRound() == null ? 1 : Math.max(1, entity.getCurrentRound());
|
||||
entity.setCurrentRound(currentRound + 1);
|
||||
entity.setCurrentShopIndex(0);
|
||||
}
|
||||
entity.setActiveTaskId(null);
|
||||
entity.setUpdatedAt(LocalDateTime.now());
|
||||
loopRunMapper.updateById(entity);
|
||||
}
|
||||
|
||||
private void markStopped(PriceTrackLoopRunEntity entity, String message) {
|
||||
entity.setStatus(STATUS_STOPPED);
|
||||
entity.setActiveTaskId(null);
|
||||
entity.setErrorMessage(message);
|
||||
entity.setFinishedAt(LocalDateTime.now());
|
||||
entity.setUpdatedAt(LocalDateTime.now());
|
||||
loopRunMapper.updateById(entity);
|
||||
}
|
||||
|
||||
private boolean isChildTaskTerminal(Long taskId) {
|
||||
FileTaskEntity task = fileTaskMapper.selectById(taskId);
|
||||
return task != null && isTerminal(task.getStatus());
|
||||
}
|
||||
|
||||
private boolean belongsToLoop(FileTaskEntity task, Long loopRunId) {
|
||||
if (task == null || task.getRequestJson() == null || task.getRequestJson().isBlank()) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
Map<String, Object> payload = objectMapper.readValue(task.getRequestJson(), new TypeReference<Map<String, Object>>() {});
|
||||
Object raw = payload.get("loopRunId");
|
||||
if (raw instanceof Number number) {
|
||||
return loopRunId.equals(number.longValue());
|
||||
}
|
||||
if (raw instanceof String text && !text.isBlank()) {
|
||||
return loopRunId.equals(Long.parseLong(text));
|
||||
}
|
||||
return false;
|
||||
} catch (Exception ex) {
|
||||
log.warn("[price-track-loop] parse child loopRunId failed taskId={} msg={}", task.getId(), ex.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private PriceTrackLoopRunEntity getOwnedLoopRun(Long loopRunId, Long userId) {
|
||||
if (loopRunId == null || loopRunId <= 0) {
|
||||
throw new BusinessException("loopRunId不合法");
|
||||
}
|
||||
if (userId == null || userId <= 0) {
|
||||
throw new BusinessException("user_id不合法");
|
||||
}
|
||||
PriceTrackLoopRunEntity entity = loopRunMapper.selectById(loopRunId);
|
||||
if (entity == null || !userId.equals(entity.getUserId())) {
|
||||
throw new BusinessException("循环任务不存在");
|
||||
}
|
||||
return entity;
|
||||
}
|
||||
|
||||
private void validateCreateRequest(PriceTrackLoopRunCreateRequest request) {
|
||||
if (request == null) {
|
||||
throw new BusinessException("请求不能为空");
|
||||
}
|
||||
if (request.isStatusMode() == request.isAsinMode()) {
|
||||
throw new BusinessException("跟价模式必须二选一");
|
||||
}
|
||||
if (request.isAsinMode() && (request.getAsinFiles() == null || request.getAsinFiles().isEmpty())) {
|
||||
throw new BusinessException("自定义ASIN模式必须上传ASIN文件");
|
||||
}
|
||||
if (request.getUserId() == null || request.getUserId() <= 0) {
|
||||
throw new BusinessException("user_id不合法");
|
||||
}
|
||||
String executionMode = normalizeExecutionMode(request.getExecutionMode());
|
||||
if (EXECUTION_MODE_FINITE.equals(executionMode) && (request.getTargetRounds() == null || request.getTargetRounds() <= 0)) {
|
||||
throw new BusinessException("固定轮次必须大于0");
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeExecutionMode(String raw) {
|
||||
String value = raw == null ? "" : raw.trim().toUpperCase();
|
||||
if (EXECUTION_MODE_FINITE.equals(value) || EXECUTION_MODE_INFINITE.equals(value)) {
|
||||
return value;
|
||||
}
|
||||
throw new BusinessException("executionMode不合法");
|
||||
}
|
||||
|
||||
private List<PriceTrackMatchShopsVo.PriceTrackShopQueueItem> dedupeItems(List<PriceTrackMatchShopsVo.PriceTrackShopQueueItem> rawItems) {
|
||||
Map<String, PriceTrackMatchShopsVo.PriceTrackShopQueueItem> out = new LinkedHashMap<>();
|
||||
for (PriceTrackMatchShopsVo.PriceTrackShopQueueItem item : rawItems) {
|
||||
if (item == null || !Boolean.TRUE.equals(item.getMatched())) {
|
||||
continue;
|
||||
}
|
||||
String key = ziniaoShopSwitchService.normalizeShopName(item.getShopName());
|
||||
if (!key.isBlank()) {
|
||||
out.putIfAbsent(key, item);
|
||||
}
|
||||
}
|
||||
return new ArrayList<>(out.values());
|
||||
}
|
||||
|
||||
private PriceTrackLoopRunVo toVo(PriceTrackLoopRunEntity entity) {
|
||||
PriceTrackLoopRunVo vo = new PriceTrackLoopRunVo();
|
||||
vo.setId(entity.getId());
|
||||
vo.setStatus(entity.getStatus());
|
||||
vo.setExecutionMode(entity.getExecutionMode());
|
||||
vo.setTargetRounds(entity.getTargetRounds());
|
||||
vo.setCurrentRound(entity.getCurrentRound());
|
||||
vo.setCurrentShopIndex(entity.getCurrentShopIndex());
|
||||
vo.setTotalShopCount(parseShops(entity).size());
|
||||
vo.setActiveTaskId(entity.getActiveTaskId());
|
||||
vo.setStopRequested(Boolean.TRUE.equals(entity.getStopRequested()));
|
||||
vo.setErrorMessage(entity.getErrorMessage());
|
||||
vo.setStatusMode(Boolean.TRUE.equals(entity.getStatusMode()));
|
||||
vo.setAsinMode(Boolean.TRUE.equals(entity.getAsinMode()));
|
||||
vo.setAsinFiles(parseStringList(entity.getAsinFilesJson()));
|
||||
vo.setCountryCodes(parseStringList(entity.getCountryCodesJson()));
|
||||
if (entity.getActiveTaskId() != null) {
|
||||
FileTaskEntity activeTask = fileTaskMapper.selectById(entity.getActiveTaskId());
|
||||
if (activeTask != null) {
|
||||
vo.setActiveTaskStatus(activeTask.getStatus());
|
||||
}
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
|
||||
private List<PriceTrackMatchShopsVo.PriceTrackShopQueueItem> parseShops(PriceTrackLoopRunEntity entity) {
|
||||
if (entity == null || entity.getShopsJson() == null || entity.getShopsJson().isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
try {
|
||||
return objectMapper.readValue(entity.getShopsJson(), new TypeReference<List<PriceTrackMatchShopsVo.PriceTrackShopQueueItem>>() {});
|
||||
} catch (Exception ex) {
|
||||
log.warn("[price-track-loop] parse shops failed loopRunId={} msg={}", entity.getId(), ex.getMessage());
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> parseStringList(String rawJson) {
|
||||
if (rawJson == null || rawJson.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
try {
|
||||
return objectMapper.readValue(rawJson, new TypeReference<List<String>>() {});
|
||||
} catch (Exception ex) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
private ChildTaskContext parseChildTaskContext(FileTaskEntity task) {
|
||||
if (task == null || task.getRequestJson() == null || task.getRequestJson().isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
Map<String, Object> payload = objectMapper.readValue(task.getRequestJson(), new TypeReference<Map<String, Object>>() {});
|
||||
Integer roundIndex = toInteger(payload.get("roundIndex"));
|
||||
Integer shopIndex = toInteger(payload.get("shopIndex"));
|
||||
if (roundIndex == null || shopIndex == null) {
|
||||
return null;
|
||||
}
|
||||
return new ChildTaskContext(roundIndex, shopIndex);
|
||||
} catch (Exception ex) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isTerminal(String status) {
|
||||
return STATUS_SUCCESS.equals(status) || STATUS_FAILED.equals(status) || STATUS_STOPPED.equals(status);
|
||||
}
|
||||
|
||||
private Integer toInteger(Object raw) {
|
||||
if (raw instanceof Number number) {
|
||||
return number.intValue();
|
||||
}
|
||||
if (raw instanceof String text && !text.isBlank()) {
|
||||
return Integer.parseInt(text);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private record ChildTaskContext(Integer roundIndex, Integer shopIndex) {
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,7 @@ public class PriceTrackTaskService {
|
||||
private final SkipPriceAsinService skipPriceAsinService;
|
||||
private final PriceTrackExcelAssemblyService excelAssemblyService;
|
||||
private final PriceTrackTaskCacheService priceTrackTaskCacheService;
|
||||
private final PriceTrackLoopRunService priceTrackLoopRunService;
|
||||
|
||||
public PriceTrackDashboardVo dashboard(Long userId) {
|
||||
if (userId == null || userId <= 0) throw new BusinessException("user_id 不合法");
|
||||
@@ -142,6 +143,7 @@ public class PriceTrackTaskService {
|
||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE));
|
||||
fileTaskMapper.deleteById(taskId);
|
||||
priceTrackTaskCacheService.deleteTaskCache(taskId);
|
||||
priceTrackLoopRunService.syncLoopRunAfterChildRemoved(taskId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -253,6 +255,9 @@ public class PriceTrackTaskService {
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
fileTaskMapper.insert(task);
|
||||
priceTrackTaskCacheService.touchTaskHeartbeat(task.getId());
|
||||
if (request.getLoopRunId() != null) {
|
||||
priceTrackLoopRunService.bindChildTask(request.getLoopRunId(), task.getId(), request.getRoundIndex(), request.getShopIndex());
|
||||
}
|
||||
|
||||
List<PriceTrackResultItemVo> snapshot = new ArrayList<>();
|
||||
for (PriceTrackMatchShopsVo.PriceTrackShopQueueItem item : uniqueItems) {
|
||||
@@ -267,7 +272,10 @@ public class PriceTrackTaskService {
|
||||
fr.setSuccess(0);
|
||||
fr.setCreatedAt(LocalDateTime.now());
|
||||
fileResultMapper.insert(fr);
|
||||
snapshot.add(toSnapshotVo(fr, item, task.getStatus()));
|
||||
PriceTrackResultItemVo snapshotItem = toSnapshotVo(fr, item, task.getStatus());
|
||||
snapshotItem.setLoopRunId(request.getLoopRunId());
|
||||
snapshotItem.setRoundIndex(request.getRoundIndex());
|
||||
snapshot.add(snapshotItem);
|
||||
}
|
||||
|
||||
// 保存请求上下文快照,供 Python 消费和前端轮询展示使用
|
||||
@@ -280,6 +288,9 @@ public class PriceTrackTaskService {
|
||||
ctx.put("skipAsinsByCountry", skipAsinsByCountry);
|
||||
ctx.put("asinRowsByCountry", asinRowsByCountry);
|
||||
ctx.put("items", uniqueItems);
|
||||
ctx.put("loopRunId", request.getLoopRunId());
|
||||
ctx.put("roundIndex", request.getRoundIndex());
|
||||
ctx.put("shopIndex", request.getShopIndex());
|
||||
task.setRequestJson(objectMapper.writeValueAsString(ctx));
|
||||
task.setResultJson(objectMapper.writeValueAsString(snapshot));
|
||||
} catch (Exception ex) {
|
||||
@@ -441,6 +452,7 @@ public class PriceTrackTaskService {
|
||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
||||
.orderByAsc(FileResultEntity::getId));
|
||||
if (latest.isEmpty()) {
|
||||
priceTrackLoopRunService.syncLoopRunAfterChildRemoved(taskId);
|
||||
fileTaskMapper.deleteById(taskId);
|
||||
cleanupTaskCacheIfTerminal(taskId, "DELETE_EMPTY");
|
||||
return;
|
||||
@@ -768,6 +780,7 @@ public class PriceTrackTaskService {
|
||||
vo.setCreatedAt(fmt(task.getCreatedAt()));
|
||||
vo.setUpdatedAt(fmt(task.getUpdatedAt()));
|
||||
vo.setFinishedAt(fmt(task.getFinishedAt()));
|
||||
mergeTaskLoopFields(vo, task);
|
||||
return vo;
|
||||
}
|
||||
|
||||
@@ -794,10 +807,34 @@ public class PriceTrackTaskService {
|
||||
break;
|
||||
}
|
||||
}
|
||||
mergeLoopFields(payload, vo);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
private void mergeTaskLoopFields(PriceTrackTaskItemVo vo, FileTaskEntity task) {
|
||||
if (task == null || task.getRequestJson() == null || task.getRequestJson().isBlank()) return;
|
||||
try {
|
||||
Map<String, Object> payload = objectMapper.readValue(task.getRequestJson(), new TypeReference<Map<String, Object>>() {});
|
||||
Object rawLoopRunId = payload.get("loopRunId");
|
||||
if (rawLoopRunId instanceof Number n) vo.setLoopRunId(n.longValue());
|
||||
else if (rawLoopRunId instanceof String s && !s.isBlank()) vo.setLoopRunId(Long.parseLong(s));
|
||||
Object rawRoundIndex = payload.get("roundIndex");
|
||||
if (rawRoundIndex instanceof Number n) vo.setRoundIndex(n.intValue());
|
||||
else if (rawRoundIndex instanceof String s && !s.isBlank()) vo.setRoundIndex(Integer.parseInt(s));
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
private void mergeLoopFields(Map<String, Object> payload, PriceTrackResultItemVo vo) {
|
||||
Object rawLoopRunId = payload.get("loopRunId");
|
||||
if (rawLoopRunId instanceof Number n) vo.setLoopRunId(n.longValue());
|
||||
else if (rawLoopRunId instanceof String s && !s.isBlank()) vo.setLoopRunId(Long.parseLong(s));
|
||||
Object rawRoundIndex = payload.get("roundIndex");
|
||||
if (rawRoundIndex instanceof Number n) vo.setRoundIndex(n.intValue());
|
||||
else if (rawRoundIndex instanceof String s && !s.isBlank()) vo.setRoundIndex(Integer.parseInt(s));
|
||||
}
|
||||
|
||||
private List<PriceTrackResultItemVo> buildSnapshotFromDb(Long taskId, String taskStatus) {
|
||||
List<FileResultEntity> rows = fileResultMapper.selectList(
|
||||
new LambdaQueryWrapper<FileResultEntity>()
|
||||
@@ -867,6 +904,9 @@ public class PriceTrackTaskService {
|
||||
log.warn("[price-track] status updated from latest rows taskId={} oldStatus={} newStatus={} ok={} fail={} allDone={} errorMessage={}",
|
||||
task.getId(), oldStatus, task.getStatus(), ok, fail, allDone, task.getErrorMessage());
|
||||
cleanupTaskCacheIfTerminal(task.getId(), task.getStatus());
|
||||
if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) {
|
||||
priceTrackLoopRunService.syncLoopRunAfterChildTerminal(task.getId());
|
||||
}
|
||||
}
|
||||
|
||||
private void cleanupTaskCacheIfTerminal(Long taskId, String taskStatus) {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
CREATE TABLE IF NOT EXISTS biz_price_track_loop_run (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 'primary key',
|
||||
user_id BIGINT NOT NULL COMMENT 'user id',
|
||||
status VARCHAR(32) NOT NULL COMMENT 'PENDING/RUNNING/SUCCESS/FAILED/STOPPED',
|
||||
execution_mode VARCHAR(16) NOT NULL COMMENT 'FINITE/INFINITE',
|
||||
target_rounds INT NULL COMMENT 'configured rounds for finite mode',
|
||||
current_round INT NOT NULL DEFAULT 1 COMMENT '1-based current round',
|
||||
current_shop_index INT NOT NULL DEFAULT 0 COMMENT '0-based current shop index',
|
||||
status_mode TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'status mode enabled',
|
||||
asin_mode TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'asin mode enabled',
|
||||
asin_files_json MEDIUMTEXT NULL COMMENT 'asin file paths json',
|
||||
country_codes_json MEDIUMTEXT NOT NULL COMMENT 'country codes json',
|
||||
shops_json MEDIUMTEXT NOT NULL COMMENT 'shop queue items json',
|
||||
active_task_id BIGINT NULL COMMENT 'currently running child task id',
|
||||
stop_requested TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'manual stop requested',
|
||||
error_message VARCHAR(1000) NULL COMMENT 'terminal error message',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'created time',
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'updated time',
|
||||
finished_at DATETIME NULL COMMENT 'finished time',
|
||||
KEY idx_user_status (user_id, status),
|
||||
KEY idx_active_task_id (active_task_id)
|
||||
) COMMENT='price track loop runs';
|
||||
@@ -20,6 +20,24 @@
|
||||
</div>
|
||||
|
||||
<!-- 按指定ASIN文档:上传 -->
|
||||
<div class="section-title">执行配置</div>
|
||||
<div class="mode-zone">
|
||||
<label class="mode-check-row">
|
||||
<input v-model="executionMode" type="radio" class="mode-check-input" value="FINITE" />
|
||||
<span class="mode-check-text">执行固定轮次</span>
|
||||
<span class="mode-hint-inline">按整批店铺重复执行指定次数</span>
|
||||
</label>
|
||||
<label class="mode-check-row">
|
||||
<input v-model="executionMode" type="radio" class="mode-check-input" value="INFINITE" />
|
||||
<span class="mode-check-text">无限执行</span>
|
||||
<span class="mode-hint-inline">上一轮全部成功后立即开始下一轮</span>
|
||||
</label>
|
||||
<div v-if="executionMode === 'FINITE'" class="loop-round-input">
|
||||
<span class="hint">轮次</span>
|
||||
<el-input-number v-model="roundCount" :min="1" :max="9999" :disabled="hasRunningLoop || pushing" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="asinModeEnabled">
|
||||
<div class="section-title">ASIN文档 <span class="tag-badge">自定义模式</span></div>
|
||||
<div class="upload-zone">
|
||||
@@ -101,12 +119,23 @@
|
||||
<button type="button" class="btn-run" :disabled="matching" @click="runMatch">
|
||||
{{ matching ? '匹配中…' : '匹配店铺' }}
|
||||
</button>
|
||||
<button type="button" class="btn-run btn-queue" :disabled="pushing || !hasValidMode" @click="pushToPythonQueue">
|
||||
<button type="button" class="btn-run btn-queue" :disabled="pushing || !hasValidMode" @click="pushToPythonLoopQueue">
|
||||
{{ pushing ? '推送中…' : '推送到 Python 队列' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="loading-msg">上传ASIN文档和多选店铺<strong>可同时生效</strong>,跟价结果合并输出。全量和自定义的区别仅在于ASIN来源不同。</p>
|
||||
|
||||
<div v-if="activeLoopRun" class="loop-status-card">
|
||||
<div class="loop-status-line">循环任务 ID:{{ activeLoopRun.id }}</div>
|
||||
<div class="loop-status-line">状态:{{ activeLoopRun.status || '-' }}</div>
|
||||
<div class="loop-status-line">进度:{{ loopProgressText }}</div>
|
||||
<div class="loop-status-line">当前店铺:{{ ((activeLoopRun.currentShopIndex || 0) + 1) }}/{{ activeLoopRun.totalShopCount || 0 }}</div>
|
||||
<div v-if="activeLoopRun.errorMessage" class="loop-status-line loop-error">{{ activeLoopRun.errorMessage }}</div>
|
||||
<button type="button" class="opt-btn loop-stop-btn" :disabled="!hasRunningLoop || stopLoopPending" @click="stopLoopExecution">
|
||||
{{ stopLoopPending ? '停止中...' : '停止循环' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="queuePushResult" class="queue-debug-card">
|
||||
<div class="section-title queue-debug-title">推送结果</div>
|
||||
<div class="queue-debug-line">{{ queuePushResult }}</div>
|
||||
@@ -176,6 +205,7 @@
|
||||
<li v-for="item in currentSectionItems" :key="`cur-${item.resultId}-${item.taskId}`"
|
||||
class="task-item split-result-item">
|
||||
<div class="left split-result-main">
|
||||
<div v-if="item.roundIndex" class="files">轮次:第 {{ item.roundIndex }} 轮</div>
|
||||
<span class="id" :title="item.shopName || ''">{{ item.shopName || '-' }}</span>
|
||||
<div class="files">任务 ID:{{ item.taskId ?? '-' }}</div>
|
||||
<div v-if="item.platform" class="files">平台:{{ item.platform }}</div>
|
||||
@@ -198,6 +228,7 @@
|
||||
<li v-for="item in historySectionItems" :key="`his-${item.resultId}-${item.taskId}`"
|
||||
class="task-item split-result-item">
|
||||
<div class="left split-result-main">
|
||||
<div v-if="item.roundIndex" class="files">轮次:第 {{ item.roundIndex }} 轮</div>
|
||||
<span class="id" :title="item.shopName || ''">{{ item.shopName || '-' }}</span>
|
||||
<div class="files">任务 ID:{{ item.taskId ?? '-' }}</div>
|
||||
<div v-if="item.outputFilename" class="files">文件:{{ item.outputFilename }}</div>
|
||||
@@ -230,7 +261,10 @@ import { expandBrandFolderRecursive } from '@/shared/api/brand'
|
||||
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
||||
import {
|
||||
addPriceTrackCandidate,
|
||||
completePriceTrackLoopChild,
|
||||
createPriceTrackTask,
|
||||
createPriceTrackLoopRun,
|
||||
dispatchNextPriceTrackLoopRun,
|
||||
deletePendingPriceTrackShopResult,
|
||||
deletePriceTrackCandidate,
|
||||
deletePriceTrackHistory,
|
||||
@@ -238,17 +272,21 @@ import {
|
||||
getPriceTrackCountryPreference,
|
||||
getPriceTrackDashboard,
|
||||
getPriceTrackHistory,
|
||||
getPriceTrackLoopRun,
|
||||
getPriceTrackResultDownloadUrl,
|
||||
getPriceTrackTaskProgressBatch,
|
||||
getPriceTrackTasksBatch,
|
||||
listPriceTrackCandidates,
|
||||
matchPriceTrackShops,
|
||||
putPriceTrackCountryPreference,
|
||||
stopPriceTrackLoopRun,
|
||||
type PriceTrackAsinParsedRow,
|
||||
type PriceTrackCandidateVo,
|
||||
type PriceTrackCreateTaskVo,
|
||||
type PriceTrackDashboardVo,
|
||||
type PriceTrackExecutionMode,
|
||||
type PriceTrackHistoryItem,
|
||||
type PriceTrackLoopRunVo,
|
||||
type PriceTrackShopQueueItem,
|
||||
type PriceTrackTaskDetailVo,
|
||||
} from '@/shared/api/java-modules'
|
||||
@@ -273,9 +311,15 @@ const pushing = ref(false)
|
||||
const queuePushResult = ref('')
|
||||
const queuePayloadText = ref('')
|
||||
const matchAsinRowsByCountry = ref<Record<string, PriceTrackAsinParsedRow[]>>({})
|
||||
const executionMode = ref<PriceTrackExecutionMode>('FINITE')
|
||||
const roundCount = ref(1)
|
||||
const activeLoopRunId = ref<number | null>(null)
|
||||
const activeLoopRun = ref<PriceTrackLoopRunVo | null>(null)
|
||||
const loopDispatching = ref(false)
|
||||
const stopLoopPending = ref(false)
|
||||
|
||||
// 跟价模式(可同时启用)
|
||||
const statusModeEnabled = ref(false) // 按商品状态(全量)
|
||||
const statusModeEnabled = ref(true) // 按商品状态(全量)
|
||||
const asinModeEnabled = ref(false) // 按指定ASIN文档(自定义)
|
||||
|
||||
// 上传文件
|
||||
@@ -283,6 +327,19 @@ const asinFiles = ref<string[]>([]) // ASIN文档
|
||||
|
||||
// 是否有有效模式
|
||||
const hasValidMode = computed(() => statusModeEnabled.value || asinModeEnabled.value)
|
||||
const loopIsTerminal = computed(() => {
|
||||
const status = activeLoopRun.value?.status || ''
|
||||
return status === 'SUCCESS' || status === 'FAILED' || status === 'STOPPED'
|
||||
})
|
||||
const hasRunningLoop = computed(() => !!activeLoopRunId.value && !loopIsTerminal.value)
|
||||
const loopProgressText = computed(() => {
|
||||
const loop = activeLoopRun.value
|
||||
if (!loop) return ''
|
||||
const round = loop.currentRound || 1
|
||||
if (loop.executionMode === 'INFINITE') return `第 ${round} 轮(无限)`
|
||||
const total = Math.max(1, loop.targetRounds || roundCount.value || 1)
|
||||
return `第 ${round}/${total} 轮`
|
||||
})
|
||||
|
||||
const orderedCountryCodes = ref<string[]>(['DE', 'UK', 'FR', 'IT', 'ES'])
|
||||
const dragCountryIndex = ref<number | null>(null)
|
||||
@@ -324,6 +381,10 @@ function matchedItemsStorageKey() {
|
||||
return `price-track:matched-items:${uidForStorage()}`
|
||||
}
|
||||
|
||||
function activeLoopStorageKey() {
|
||||
return `price-track:active-loop:${uidForStorage()}`
|
||||
}
|
||||
|
||||
function setStorageJson(key: string, value: unknown, shouldRemove: boolean) {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
@@ -407,6 +468,24 @@ function saveMatchedItemsToStorage() {
|
||||
setStorageJson(matchedItemsStorageKey(), matchedItems.value, matchedItems.value.length === 0)
|
||||
}
|
||||
|
||||
function loadActiveLoopRunIdFromStorage() {
|
||||
try {
|
||||
const raw = typeof window !== 'undefined' ? window.localStorage.getItem(activeLoopStorageKey()) : null
|
||||
if (!raw) {
|
||||
activeLoopRunId.value = null
|
||||
return
|
||||
}
|
||||
const parsed = Number(JSON.parse(raw))
|
||||
activeLoopRunId.value = Number.isFinite(parsed) && parsed > 0 ? parsed : null
|
||||
} catch {
|
||||
activeLoopRunId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function saveActiveLoopRunId() {
|
||||
setStorageJson(activeLoopStorageKey(), activeLoopRunId.value, !activeLoopRunId.value)
|
||||
}
|
||||
|
||||
// ========== 文件上传 ==========
|
||||
async function selectAsinFile() {
|
||||
const api = getPywebviewApi()
|
||||
@@ -704,15 +783,18 @@ function rowKeyForMatch(row: { shopName?: string; shopId?: number | string | nul
|
||||
function removeMatchedRowsLocally(rows: Array<{ shopName?: string; shopId?: number | string | null }>) {
|
||||
if (!rows.length) return
|
||||
const keys = new Set(rows.map((row) => rowKeyForMatch(row)))
|
||||
matchedItems.value = matchedItems.value.filter((row) => !keys.has(rowKeyForMatch(row)))
|
||||
const names = new Set(rows.map((row) => (row.shopName || '').trim()).filter((name) => !!name))
|
||||
matchedItems.value = matchedItems.value.filter((row) => {
|
||||
const key = rowKeyForMatch(row)
|
||||
const name = (row.shopName || '').trim()
|
||||
return !keys.has(key) && (!name || !names.has(name))
|
||||
})
|
||||
saveMatchedItemsToStorage()
|
||||
}
|
||||
|
||||
async function removeMatchedRow(row: PriceTrackShopQueueItem) {
|
||||
const key = rowKeyForMatch(row)
|
||||
const backup = [...matchedItems.value]
|
||||
matchedItems.value = matchedItems.value.filter((r) => rowKeyForMatch(r) !== key)
|
||||
saveMatchedItemsToStorage()
|
||||
removeMatchedRowsLocally([row])
|
||||
const name = (row.shopName || '').trim()
|
||||
if (!name) {
|
||||
ElMessage.success('已从匹配结果中移除')
|
||||
@@ -942,6 +1024,185 @@ async function pushToPythonQueue() {
|
||||
}
|
||||
}
|
||||
|
||||
function setLoopRun(next: PriceTrackLoopRunVo | null) {
|
||||
activeLoopRun.value = next
|
||||
activeLoopRunId.value = next?.id ?? null
|
||||
saveActiveLoopRunId()
|
||||
}
|
||||
|
||||
function clearLoopRunIfTerminal() {
|
||||
if (!activeLoopRun.value) return
|
||||
const status = activeLoopRun.value.status || ''
|
||||
if (status === 'SUCCESS' || status === 'FAILED' || status === 'STOPPED') {
|
||||
activeLoopRun.value = null
|
||||
activeLoopRunId.value = null
|
||||
saveActiveLoopRunId()
|
||||
}
|
||||
}
|
||||
|
||||
async function syncActiveLoopRun() {
|
||||
if (!activeLoopRunId.value) return null
|
||||
try {
|
||||
const loop = await getPriceTrackLoopRun(activeLoopRunId.value)
|
||||
setLoopRun(loop)
|
||||
clearLoopRunIfTerminal()
|
||||
return activeLoopRun.value
|
||||
} catch {
|
||||
activeLoopRun.value = null
|
||||
activeLoopRunId.value = null
|
||||
saveActiveLoopRunId()
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function recordCreatedTask(taskVo: PriceTrackCreateTaskVo) {
|
||||
taskSnapshots.value = {
|
||||
...taskSnapshots.value,
|
||||
[taskVo.taskId]: {
|
||||
task: { id: taskVo.taskId, status: 'RUNNING' },
|
||||
items: taskVo.items,
|
||||
},
|
||||
}
|
||||
taskDetails.value = {
|
||||
...taskDetails.value,
|
||||
[taskVo.taskId]: 'RUNNING',
|
||||
}
|
||||
saveTaskSnapshotsToStorage()
|
||||
saveTaskDetailsToStorage()
|
||||
}
|
||||
|
||||
async function stopLoopExecution() {
|
||||
if (!activeLoopRunId.value || stopLoopPending.value) return
|
||||
stopLoopPending.value = true
|
||||
try {
|
||||
const loop = await stopPriceTrackLoopRun(activeLoopRunId.value)
|
||||
setLoopRun(loop)
|
||||
clearLoopRunIfTerminal()
|
||||
ElMessage.success(loop.status === 'STOPPED' ? '循环已停止' : '已请求停止,当前子任务完成后将收尾')
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '停止循环失败')
|
||||
} finally {
|
||||
stopLoopPending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runLoopExecution(loopId?: number) {
|
||||
if (loopDispatching.value) return
|
||||
if (loopId && activeLoopRunId.value !== loopId) {
|
||||
activeLoopRunId.value = loopId
|
||||
saveActiveLoopRunId()
|
||||
}
|
||||
if (!activeLoopRunId.value) return
|
||||
const api = getPywebviewApi()
|
||||
if (!api?.enqueue_json) {
|
||||
throw new Error('当前环境未启用 pywebview enqueue_json')
|
||||
}
|
||||
loopDispatching.value = true
|
||||
try {
|
||||
while (activeLoopRunId.value) {
|
||||
const loop = await syncActiveLoopRun()
|
||||
if (!loop || loop.status === 'SUCCESS' || loop.status === 'FAILED' || loop.status === 'STOPPED') break
|
||||
|
||||
if (loop.activeTaskId) {
|
||||
addPollingTask(loop.activeTaskId)
|
||||
scheduleNextPoll(true)
|
||||
const finalStatus = await waitForTaskTerminal(loop.activeTaskId)
|
||||
const updatedLoop = await completePriceTrackLoopChild(loop.id, loop.activeTaskId)
|
||||
setLoopRun(updatedLoop)
|
||||
await loadHistory()
|
||||
await loadDashboard()
|
||||
syncPollingIdsWithHistory()
|
||||
clearLoopRunIfTerminal()
|
||||
if (finalStatus !== 'SUCCESS') {
|
||||
throw new Error(updatedLoop.errorMessage || `任务 ${loop.activeTaskId} 执行失败`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const dispatch = await dispatchNextPriceTrackLoopRun(loop.id)
|
||||
setLoopRun(dispatch.loopRun || loop)
|
||||
if (!dispatch.childTaskRequest) {
|
||||
clearLoopRunIfTerminal()
|
||||
break
|
||||
}
|
||||
const row = dispatch.childTaskRequest.items?.[0]
|
||||
if (!row) throw new Error('循环任务缺少待执行店铺')
|
||||
const taskVo = await createPriceTrackTask(dispatch.childTaskRequest)
|
||||
recordCreatedTask(taskVo)
|
||||
const queuePayload = buildQueuePayload(taskVo, row)
|
||||
queuePayloadText.value = JSON.stringify(queuePayload, null, 2)
|
||||
const pushResult = await api.enqueue_json(queuePayload)
|
||||
if (!pushResult?.success) {
|
||||
throw new Error(pushResult?.error || `任务 ${taskVo.taskId} 推送失败`)
|
||||
}
|
||||
addPollingTask(taskVo.taskId)
|
||||
scheduleNextPoll(true)
|
||||
removeMatchedRowsLocally([row])
|
||||
queuePushResult.value = `循环任务 ${loop.id} 第 ${dispatch.childTaskRequest.roundIndex || 1} 轮已推送:${row.shopName || taskVo.taskId}`
|
||||
}
|
||||
} finally {
|
||||
loopDispatching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function pushToPythonLoopQueue() {
|
||||
if (hasRunningLoop.value) {
|
||||
try {
|
||||
await runLoopExecution(activeLoopRunId.value || undefined)
|
||||
} catch (error) {
|
||||
if (error instanceof Error && /子任务不存在|循环任务不存在|404/.test(error.message)) {
|
||||
activeLoopRun.value = null
|
||||
activeLoopRunId.value = null
|
||||
saveActiveLoopRunId()
|
||||
}
|
||||
const message = error instanceof Error ? error.message : '循环执行失败'
|
||||
queuePushResult.value = message
|
||||
ElMessage.error(message)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!hasValidMode.value) {
|
||||
ElMessage.warning('请至少选择一个跟价模式')
|
||||
return
|
||||
}
|
||||
if (asinModeEnabled.value && !asinFiles.value.length) {
|
||||
ElMessage.warning('请先选择 ASIN 文件')
|
||||
return
|
||||
}
|
||||
const matchedRows = matchedItems.value.filter((item) => item.matched)
|
||||
if (!matchedRows.length) {
|
||||
ElMessage.warning('请先匹配可用店铺')
|
||||
return
|
||||
}
|
||||
pushing.value = true
|
||||
queuePayloadText.value = ''
|
||||
try {
|
||||
const loop = await createPriceTrackLoopRun({
|
||||
userId: 0,
|
||||
statusMode: statusModeEnabled.value,
|
||||
asinMode: asinModeEnabled.value,
|
||||
items: matchedRows,
|
||||
asinFiles: asinFiles.value,
|
||||
countryCodes: [...orderedCountryCodes.value],
|
||||
executionMode: executionMode.value,
|
||||
targetRounds: executionMode.value === 'FINITE' ? Math.max(1, Number(roundCount.value) || 1) : undefined,
|
||||
})
|
||||
setLoopRun(loop)
|
||||
queuePushResult.value = `循环任务 ${loop.id} 已创建`
|
||||
await runLoopExecution(loop.id)
|
||||
clearLoopRunIfTerminal()
|
||||
if (!activeLoopRunId.value) {
|
||||
ElMessage.success('跟价循环已启动')
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '启动循环失败'
|
||||
queuePushResult.value = message
|
||||
ElMessage.error(message)
|
||||
} finally {
|
||||
pushing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getPollIntervalMs() {
|
||||
return document.visibilityState === 'visible' ? 6000 : 20000
|
||||
}
|
||||
@@ -1103,6 +1364,9 @@ async function deleteTaskRecord(item: PriceTrackHistoryItem) {
|
||||
} else if (item.taskId != null) {
|
||||
await deletePriceTrackTask(item.taskId)
|
||||
}
|
||||
if (activeLoopRun.value && ((item.loopRunId != null && item.loopRunId === activeLoopRun.value.id) || (item.taskId != null && item.taskId === activeLoopRun.value.activeTaskId))) {
|
||||
await syncActiveLoopRun()
|
||||
}
|
||||
if (item.taskId != null) removePollingTask(item.taskId)
|
||||
removeMatchedRowsLocally([item])
|
||||
await loadHistory()
|
||||
@@ -1116,15 +1380,33 @@ async function deleteTaskRecord(item: PriceTrackHistoryItem) {
|
||||
|
||||
// ========== 生命周期 ==========
|
||||
onMounted(() => {
|
||||
if (!statusModeEnabled.value && !asinModeEnabled.value) {
|
||||
selectMode('status')
|
||||
}
|
||||
loadPollingIdsFromStorage()
|
||||
loadTaskDetailsFromStorage()
|
||||
loadTaskSnapshotsFromStorage()
|
||||
loadMatchedItemsFromStorage()
|
||||
loadActiveLoopRunIdFromStorage()
|
||||
loadCandidates().catch(() => undefined)
|
||||
loadCountryPreference().catch(() => undefined)
|
||||
loadHistory().catch(() => undefined)
|
||||
loadDashboard().catch(() => undefined)
|
||||
if (pollingTaskIds.value.length) scheduleNextPoll(true)
|
||||
if (activeLoopRunId.value) {
|
||||
void syncActiveLoopRun().then((loop) => {
|
||||
if (loop?.activeTaskId) {
|
||||
addPollingTask(loop.activeTaskId)
|
||||
scheduleNextPoll(true)
|
||||
}
|
||||
if (loop && loop.status !== 'SUCCESS' && loop.status !== 'FAILED' && loop.status !== 'STOPPED') {
|
||||
void runLoopExecution(loop.id)
|
||||
}
|
||||
}).catch(() => {
|
||||
activeLoopRunId.value = null
|
||||
saveActiveLoopRunId()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -1258,6 +1540,22 @@ onUnmounted(() => {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.loop-round-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.loop-round-input :deep(.el-input-number) {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.loop-round-input :deep(.el-input__wrapper) {
|
||||
background-color: #2a2a2a;
|
||||
box-shadow: 0 0 0 1px #3a3a3a inset;
|
||||
}
|
||||
|
||||
.tag-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
@@ -1533,6 +1831,28 @@ onUnmounted(() => {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.loop-status-card {
|
||||
margin-top: 14px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #2f3d2f;
|
||||
background: rgba(39, 174, 96, 0.08);
|
||||
}
|
||||
|
||||
.loop-status-line {
|
||||
font-size: 12px;
|
||||
color: #d6e6d8;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.loop-error {
|
||||
color: #ff8a80;
|
||||
}
|
||||
|
||||
.loop-stop-btn {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.loading-msg code {
|
||||
font-size: 11px;
|
||||
color: #8fd3ff;
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
|
||||
<nav class="nav-sections" aria-label="顶部功能导航">
|
||||
<section
|
||||
v-for="group in navGroups"
|
||||
:key="group.label"
|
||||
v-for="group in visibleNavGroups"
|
||||
:key="group.columnKey"
|
||||
class="nav-section"
|
||||
>
|
||||
<div class="nav-section-title">{{ group.label }}</div>
|
||||
@@ -39,6 +39,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import { getCurrentUserAppColumnKeys } from '@/shared/api/permission'
|
||||
|
||||
type ActiveNavKey =
|
||||
| 'brand'
|
||||
| 'dedupe'
|
||||
@@ -55,14 +59,22 @@ type NavItem = {
|
||||
href?: string
|
||||
}
|
||||
|
||||
type NavGroup = {
|
||||
columnKey: string
|
||||
label: string
|
||||
items: ReadonlyArray<NavItem>
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
active: ActiveNavKey
|
||||
}>()
|
||||
|
||||
const active = props.active
|
||||
const allowedColumnKeys = ref<string[] | null>(null)
|
||||
|
||||
const navGroups: ReadonlyArray<{ label: string; items: ReadonlyArray<NavItem> }> = [
|
||||
const navGroups: ReadonlyArray<NavGroup> = [
|
||||
{
|
||||
columnKey: 'brand_front_tools',
|
||||
label: '前端工具',
|
||||
items: [
|
||||
{ key: 'collect', label: '采集数据' },
|
||||
@@ -74,6 +86,7 @@ const navGroups: ReadonlyArray<{ label: string; items: ReadonlyArray<NavItem> }>
|
||||
],
|
||||
},
|
||||
{
|
||||
columnKey: 'brand_operation_tools',
|
||||
label: '运营工具',
|
||||
items: [
|
||||
{ key: 'delete-brand', label: '删除ASIN', href: '/new_web_source/delete-brand.html' },
|
||||
@@ -86,6 +99,7 @@ const navGroups: ReadonlyArray<{ label: string; items: ReadonlyArray<NavItem> }>
|
||||
],
|
||||
},
|
||||
{
|
||||
columnKey: 'brand_logistics_tools',
|
||||
label: '后勤工具',
|
||||
items: [
|
||||
{ key: 'purchase', label: '采购' },
|
||||
@@ -93,6 +107,22 @@ const navGroups: ReadonlyArray<{ label: string; items: ReadonlyArray<NavItem> }>
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const visibleNavGroups = computed(() => {
|
||||
if (allowedColumnKeys.value === null) {
|
||||
return navGroups
|
||||
}
|
||||
const allowedSet = new Set(allowedColumnKeys.value)
|
||||
return navGroups.filter((group) => allowedSet.has(group.columnKey))
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
allowedColumnKeys.value = await getCurrentUserAppColumnKeys()
|
||||
} catch (_error) {
|
||||
allowedColumnKeys.value = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -955,6 +955,8 @@ export interface PriceTrackDashboardVo {
|
||||
export interface PriceTrackHistoryItem {
|
||||
resultId?: number;
|
||||
taskId?: number;
|
||||
loopRunId?: number;
|
||||
roundIndex?: number;
|
||||
shopName?: string;
|
||||
shopId?: number | string | null;
|
||||
platform?: string;
|
||||
@@ -998,6 +1000,8 @@ export interface PriceTrackTaskSummary {
|
||||
id?: number;
|
||||
taskNo?: string;
|
||||
status?: string;
|
||||
loopRunId?: number;
|
||||
roundIndex?: number;
|
||||
errorMessage?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
@@ -1146,6 +1150,45 @@ export interface PriceTrackCreateTaskRequest {
|
||||
items: PriceTrackShopQueueItem[];
|
||||
asinFiles: string[];
|
||||
countryCodes: string[];
|
||||
loopRunId?: number;
|
||||
roundIndex?: number;
|
||||
shopIndex?: number;
|
||||
}
|
||||
|
||||
export type PriceTrackExecutionMode = "FINITE" | "INFINITE";
|
||||
|
||||
export interface PriceTrackLoopRunCreateRequest {
|
||||
userId: number;
|
||||
statusMode: boolean;
|
||||
asinMode: boolean;
|
||||
items: PriceTrackShopQueueItem[];
|
||||
asinFiles: string[];
|
||||
countryCodes: string[];
|
||||
executionMode: PriceTrackExecutionMode;
|
||||
targetRounds?: number;
|
||||
}
|
||||
|
||||
export interface PriceTrackLoopRunVo {
|
||||
id: number;
|
||||
status?: string;
|
||||
executionMode?: PriceTrackExecutionMode;
|
||||
targetRounds?: number;
|
||||
currentRound?: number;
|
||||
currentShopIndex?: number;
|
||||
totalShopCount?: number;
|
||||
activeTaskId?: number;
|
||||
activeTaskStatus?: string;
|
||||
stopRequested?: boolean;
|
||||
errorMessage?: string;
|
||||
statusMode?: boolean;
|
||||
asinMode?: boolean;
|
||||
asinFiles?: string[];
|
||||
countryCodes?: string[];
|
||||
}
|
||||
|
||||
export interface PriceTrackLoopRunDispatchVo {
|
||||
loopRun?: PriceTrackLoopRunVo;
|
||||
childTaskRequest?: PriceTrackCreateTaskRequest;
|
||||
}
|
||||
|
||||
export function createPriceTrackTask(
|
||||
@@ -1176,6 +1219,52 @@ export function getPriceTrackTasksBatch(taskIds: number[]) {
|
||||
);
|
||||
}
|
||||
|
||||
export function createPriceTrackLoopRun(
|
||||
request: Omit<PriceTrackLoopRunCreateRequest, "userId"> | PriceTrackLoopRunCreateRequest,
|
||||
) {
|
||||
return unwrapJavaResponse(
|
||||
post<JavaApiResponse<PriceTrackLoopRunVo>, PriceTrackLoopRunCreateRequest>(
|
||||
`${JAVA_API_PREFIX}/price-track/loop-runs`,
|
||||
{ ...request, userId: getCurrentUserId() },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function getPriceTrackLoopRun(loopRunId: number) {
|
||||
return unwrapJavaResponse(
|
||||
get<JavaApiResponse<PriceTrackLoopRunVo>>(
|
||||
`${JAVA_API_PREFIX}/price-track/loop-runs/${loopRunId}?user_id=${encodeURIComponent(String(getCurrentUserId()))}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function dispatchNextPriceTrackLoopRun(loopRunId: number) {
|
||||
return unwrapJavaResponse(
|
||||
post<JavaApiResponse<PriceTrackLoopRunDispatchVo>, undefined>(
|
||||
`${JAVA_API_PREFIX}/price-track/loop-runs/${loopRunId}/dispatch-next?user_id=${encodeURIComponent(String(getCurrentUserId()))}`,
|
||||
undefined,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function completePriceTrackLoopChild(loopRunId: number, childTaskId: number) {
|
||||
return unwrapJavaResponse(
|
||||
post<JavaApiResponse<PriceTrackLoopRunVo>, { childTaskId: number }>(
|
||||
`${JAVA_API_PREFIX}/price-track/loop-runs/${loopRunId}/child-finished?user_id=${encodeURIComponent(String(getCurrentUserId()))}`,
|
||||
{ childTaskId },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function stopPriceTrackLoopRun(loopRunId: number) {
|
||||
return unwrapJavaResponse(
|
||||
post<JavaApiResponse<PriceTrackLoopRunVo>, undefined>(
|
||||
`${JAVA_API_PREFIX}/price-track/loop-runs/${loopRunId}/stop?user_id=${encodeURIComponent(String(getCurrentUserId()))}`,
|
||||
undefined,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function getPriceTrackTaskProgressBatch(taskIds: number[]) {
|
||||
return unwrapJavaResponse(
|
||||
post<JavaApiResponse<PriceTrackTaskBatchVo>, { taskIds: number[] }>(
|
||||
|
||||
63
frontend-vue/src/shared/api/permission.ts
Normal file
63
frontend-vue/src/shared/api/permission.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { requestGetJson } from '@/shared/api/http'
|
||||
|
||||
export interface PermissionMenuItem {
|
||||
id: number | string
|
||||
name?: string
|
||||
column_key?: string
|
||||
route_path?: string
|
||||
menu_type?: string
|
||||
sort_order?: number
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
interface PermissionMenuResponse {
|
||||
success: boolean
|
||||
items?: PermissionMenuItem[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function getAppPermissionCacheKey(uid: number) {
|
||||
return `app_column_permissions:${String(uid)}`
|
||||
}
|
||||
|
||||
export async function getCurrentUserAppColumnKeys() {
|
||||
const uid = getCurrentUserId()
|
||||
const cacheKey = getAppPermissionCacheKey(uid)
|
||||
|
||||
try {
|
||||
const cachedItems = JSON.parse(window.localStorage.getItem(cacheKey) || 'null') as PermissionMenuItem[] | null
|
||||
if (Array.isArray(cachedItems)) {
|
||||
return cachedItems
|
||||
.map((item) => String(item.column_key || '').trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
}
|
||||
} catch (_error) {}
|
||||
|
||||
const res = await requestGetJson<PermissionMenuResponse>(
|
||||
`/api/admin/user/${encodeURIComponent(String(uid))}/column-permissions`,
|
||||
{
|
||||
params: { menu_type: 'app' },
|
||||
},
|
||||
)
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(res.error || '获取菜单权限失败')
|
||||
}
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(cacheKey, JSON.stringify(res.items || []))
|
||||
} catch (_error) {}
|
||||
|
||||
return (res.items || [])
|
||||
.map((item) => String(item.column_key || '').trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
}
|
||||
Reference in New Issue
Block a user