diff --git a/electron-vue-template/public/icon/icon.png b/electron-vue-template/public/icon/icon.png index d007c7e..f94b10c 100644 Binary files a/electron-vue-template/public/icon/icon.png and b/electron-vue-template/public/icon/icon.png differ diff --git a/electron-vue-template/src/main/main.ts b/electron-vue-template/src/main/main.ts index 18356b8..7580bc3 100644 --- a/electron-vue-template/src/main/main.ts +++ b/electron-vue-template/src/main/main.ts @@ -211,7 +211,7 @@ function startSpringBoot() { } } - // startSpringBoot(); + startSpringBoot(); function stopSpringBoot() { if (!springProcess) return; try { @@ -347,10 +347,10 @@ app.whenReady().then(() => { splashWindow.loadFile(splashPath); } } - - setTimeout(() => { - openAppIfNotOpened(); - }, 100); +//666 + // setTimeout(() => { + // openAppIfNotOpened(); + // }, 100); app.on('activate', () => { if (mainWindow && !mainWindow.isDestroyed()) { diff --git a/electron-vue-template/src/renderer/api/http.ts b/electron-vue-template/src/renderer/api/http.ts index 9893c66..7ad0c1d 100644 --- a/electron-vue-template/src/renderer/api/http.ts +++ b/electron-vue-template/src/renderer/api/http.ts @@ -1,9 +1,9 @@ export type HttpMethod = 'GET' | 'POST' | 'DELETE'; export const CONFIG = { CLIENT_BASE: 'http://localhost:8081', - // RUOYI_BASE: 'http://8.138.23.49:8085', - RUOYI_BASE: 'http://192.168.1.89:8085', - SSE_URL: 'http://192.168.1.89:8085/monitor/account/events' + RUOYI_BASE: 'http://8.138.23.49:8085', + //RUOYI_BASE: 'http://192.168.1.89:8085', + SSE_URL: 'http://8.138.23.49:8085/monitor/account/events' } as const; function resolveBase(path: string): string { @@ -31,8 +31,18 @@ async function getToken(): Promise { } } +async function getUsername(): Promise { + try { + const tokenModule = await import('../utils/token'); + return tokenModule.getUsernameFromToken() || ''; + } catch { + return ''; + } +} + async function request(path: string, options: RequestInit & { signal?: AbortSignal }): Promise { const token = await getToken(); + const username = await getUsername(); let res: Response; try { @@ -43,6 +53,7 @@ async function request(path: string, options: RequestInit & { signal?: AbortS headers: { 'Content-Type': 'application/json;charset=UTF-8', ...(token ? { 'Authorization': `Bearer ${token}` } : {}), + ...(username ? { 'username': username } : {}), ...options.headers } }); @@ -90,6 +101,7 @@ export const http = { async upload(path: string, form: FormData, signal?: AbortSignal) { const token = await getToken(); + const username = await getUsername(); let res: Response; try { @@ -98,7 +110,10 @@ export const http = { body: form, credentials: 'omit', cache: 'no-store', - headers: token ? { 'Authorization': `Bearer ${token}` } : {}, + headers: { + ...(token ? { 'Authorization': `Bearer ${token}` } : {}), + ...(username ? { 'username': username } : {}) + }, signal }); } catch (e) { diff --git a/electron-vue-template/src/renderer/api/mark.ts b/electron-vue-template/src/renderer/api/mark.ts index 8839afe..2288175 100644 --- a/electron-vue-template/src/renderer/api/mark.ts +++ b/electron-vue-template/src/renderer/api/mark.ts @@ -8,21 +8,108 @@ export const markApi = { return http.upload<{ code: number, data: any, msg: string }>('/tool/mark/newTask', formData) }, - // 获取任务列表及筛选数据 + // 获取任务列表及筛选数据(返回完整行数据和表头) getTask() { - return http.get<{ code: number, data: { original: any, filtered: any[] }, msg: string }>('/tool/mark/task') + return http.get<{ + code: number, + data: { + original: any, + filtered: Record[], // 完整的行数据(Map格式) + headers: string[] // 表头 + }, + msg: string + }>('/tool/mark/task') }, // 品牌商标筛查 - brandCheck(brands: string[]) { - return http.post<{ code: number, data: { total: number, filtered: number, passed: number, data: any[] }, msg: string }>('/tool/mark/brandCheck', brands) + brandCheck(brands: string[], taskId?: string) { + return http.post<{ code: number, data: { total: number, checked: number, registered: number, unregistered: number, failed: number, data: any[], duration: string }, msg: string }>('/api/trademark/brandCheck', { brands, taskId }) }, - // 从Excel提取品牌列表(客户端本地接口) + // 查询品牌筛查进度 + getBrandCheckProgress(taskId: string) { + return http.get<{ code: number, data: { current: number }, msg: string }>('/api/trademark/brandCheckProgress', { taskId }) + }, + + // 取消品牌筛查任务 + cancelBrandCheck(taskId: string) { + return http.post<{ code: number, data: string, msg: string }>('/api/trademark/cancelBrandCheck', { taskId }) + }, + + // 验证Excel表头 + validateHeaders(file: File, requiredHeaders?: string[]) { + const formData = new FormData() + formData.append('file', file) + if (requiredHeaders && requiredHeaders.length > 0) { + formData.append('requiredHeaders', JSON.stringify(requiredHeaders)) + } + return http.upload<{ + code: number, + data: { + headers: string[], + valid?: boolean, + missing?: string[] + }, + msg: string + }>('/api/trademark/validateHeaders', formData) + }, + + // 从Excel提取品牌列表(客户端本地接口,返回完整Excel数据) extractBrands(file: File) { const formData = new FormData() formData.append('file', file) - return http.upload<{ code: number, data: { total: number, brands: string[] }, msg: string }>('/api/trademark/extractBrands', formData) + return http.upload<{ + code: number, + data: { + total: number, + brands: string[], + headers: string[], + allRows: Record[] + }, + msg: string + }>('/api/trademark/extractBrands', formData) + }, + + // 根据ASIN列表从Excel中过滤完整行数据(客户端本地接口) + filterByAsins(file: File, asins: string[]) { + const formData = new FormData() + formData.append('file', file) + formData.append('asins', JSON.stringify(asins)) + return http.upload<{ + code: number, + data: { + headers: string[], + filteredRows: Record[], + total: number + }, + msg: string + }>('/api/trademark/filterByAsins', formData) + }, + + // 根据品牌列表从Excel中过滤完整行数据(客户端本地接口) + filterByBrands(file: File, brands: string[]) { + const formData = new FormData() + formData.append('file', file) + formData.append('brands', JSON.stringify(brands)) + return http.upload<{ + code: number, + data: { + headers: string[], + filteredRows: Record[], + total: number + }, + msg: string + }>('/api/trademark/filterByBrands', formData) + }, + + // 保存查询会话 + saveSession(sessionData: any) { + return http.post<{ code: number, data: { sessionId: string }, msg: string }>('/api/trademark/saveSession', sessionData) + }, + + // 恢复查询会话 + getSession(sessionId: string) { + return http.get<{ code: number, data: any, msg: string }>('/api/trademark/getSession', { sessionId }) } } diff --git a/electron-vue-template/src/renderer/components/amazon/AmazonDashboard.vue b/electron-vue-template/src/renderer/components/amazon/AmazonDashboard.vue index 4c8c1dd..5d43909 100644 --- a/electron-vue-template/src/renderer/components/amazon/AmazonDashboard.vue +++ b/electron-vue-template/src/renderer/components/amazon/AmazonDashboard.vue @@ -1,18 +1,48 @@ @@ -713,6 +768,36 @@ function handleRetryTask() { justify-content: space-between; height: 100%; } + +/* 商标筛查空状态样式 */ +.trademark-empty-wrapper { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + justify-content: space-between; +} +.trademark-empty-content { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} +.trademark-info-boxes { + display: flex; + text-align: left; + flex-direction: row; + align-items: flex-start; + padding: 8px 0; + gap: 10px; + width: 90%; + max-width: 872px; + margin: 0 auto; + background: rgba(0, 0, 0, 0.02); + border: 1px solid rgba(0, 0, 0, 0.06); + border-radius: 8px; +} .genmai-image-wrapper { flex: 1; display: flex; diff --git a/electron-vue-template/src/renderer/components/amazon/AsinQueryPanel.vue b/electron-vue-template/src/renderer/components/amazon/AsinQueryPanel.vue index 50de2fd..afcff14 100644 --- a/electron-vue-template/src/renderer/components/amazon/AsinQueryPanel.vue +++ b/electron-vue-template/src/renderer/components/amazon/AsinQueryPanel.vue @@ -44,6 +44,14 @@ function showMessage(message: string, type: 'success' | 'warning' | 'error' | 'i ElMessage({ message, type }) } +function removeSelectedFile() { + selectedFileName.value = '' + pendingAsins.value = [] + if (amazonUpload.value) { + amazonUpload.value.value = '' + } +} + async function processExcelFile(file: File) { try { loading.value = true @@ -275,6 +283,7 @@ defineExpose({
{{ selectedFileName }} + 🗑️
@@ -352,7 +361,7 @@ defineExpose({ .steps-flow:before { content: ''; position: absolute; left: 13px; top: 26px; bottom: 0; width: 2px; background: rgba(229, 231, 235, 0.6); } .flow-item { position: relative; display: grid; grid-template-columns: 28px 1fr; gap: 12px; padding: 10px 0; } .flow-item .step-index { position: static; width: 28px; height: 28px; line-height: 28px; text-align: center; border-radius: 50%; background: #1677FF; color: #fff; font-size: 14px; font-weight: 600; margin-top: 2px; } -.step-card { border: none; border-radius: 0; padding: 0; background: transparent; } +.step-card { border: none; border-radius: 0; padding: 0; background: transparent; min-width: 0; } .step-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; } .title { font-size: 14px; font-weight: 600; color: #303133; text-align: left; } .desc { font-size: 12px; color: #909399; margin-bottom: 10px; text-align: left; line-height: 1.5; } @@ -364,9 +373,11 @@ defineExpose({ .dz-el-icon { font-size: 18px; margin-bottom: 4px; color: #909399; } .dz-text { color: #303133; font-size: 13px; } .dz-sub { color: #909399; font-size: 12px; } -.file-chip { display: flex; align-items: center; gap: 6px; padding: 6px 8px; background: #f5f7fa; border-radius: 4px; font-size: 12px; color: #606266; margin-top: 6px; } -.file-chip .dot { width: 6px; height: 6px; background: #409EFF; border-radius: 50%; display: inline-block; } -.file-chip .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.file-chip { display: flex; align-items: center; gap: 6px; padding: 6px 8px; background: #f5f7fa; border-radius: 4px; font-size: 12px; color: #606266; margin-top: 6px; width: 100%; box-sizing: border-box; } +.file-chip .dot { width: 6px; height: 6px; background: #409EFF; border-radius: 50%; flex-shrink: 0; } +.file-chip .name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.file-chip .delete-btn { cursor: pointer; opacity: 0.6; flex-shrink: 0; } +.file-chip .delete-btn:hover { opacity: 1; } .action-buttons.column { display: flex; flex-direction: column; gap: 8px; } .btn-blue { background: #1677FF; border-color: #1677FF; color: #fff; } .btn-blue:disabled { background: #a6c8ff; border-color: #a6c8ff; color: #fff; } diff --git a/electron-vue-template/src/renderer/components/amazon/TrademarkCheckPanel.vue b/electron-vue-template/src/renderer/components/amazon/TrademarkCheckPanel.vue index d8e8ac1..a22c034 100644 --- a/electron-vue-template/src/renderer/components/amazon/TrademarkCheckPanel.vue +++ b/electron-vue-template/src/renderer/components/amazon/TrademarkCheckPanel.vue @@ -1,5 +1,5 @@ @@ -486,16 +679,18 @@ defineExpose({
导入Excel表格
产品筛查:需导入卖家精灵选品表格,并勾选"导出主图";品牌筛查:Excel需包含"品牌"列
-
-
📤
-
点击或将文件拖拽到这里上传
-
支持 .xls .xlsx
+
+
📤
+
+
{{ uploadLoading ? '正在验证表头...' : '点击或将文件拖拽到这里上传' }}
+
支持 .xls .xlsx
- +
{{ trademarkFileName }} + 🗑️
@@ -590,7 +785,7 @@ defineExpose({
- {{ trademarkLoading ? currentStep : 1 }}/4 + {{ completedSteps }}/4 开始筛查 @@ -625,39 +820,18 @@ defineExpose({ .steps-flow:before { content: ''; position: absolute; left: 13px; top: 26px; bottom: 0; width: 2px; background: rgba(229, 231, 235, 0.6); } .flow-item { position: relative; display: grid; grid-template-columns: 28px 1fr; gap: 12px; padding: 10px 0; } .flow-item .step-index { position: static; width: 28px; height: 28px; line-height: 28px; text-align: center; border-radius: 50%; background: #1677FF; color: #fff; font-size: 14px; font-weight: 600; margin-top: 2px; } -.step-card { border: none; border-radius: 0; padding: 0; background: transparent; } +.step-card { border: none; border-radius: 0; padding: 0; background: transparent; min-width: 0; } .step-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; } .title { font-size: 14px; font-weight: 600; color: #303133; text-align: left; } .desc { font-size: 12px; color: #909399; margin-bottom: 10px; text-align: left; line-height: 1.5; } .links { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; } .link { color: #909399; cursor: pointer; font-size: 12px; } -.file-chip { - display: flex; - align-items: center; - gap: 6px; - padding: 6px 8px; - background: #f5f7fa; - border-radius: 4px; - font-size: 12px; - color: #606266; - margin-top: 6px; - min-width: 0; -} -.file-chip .dot { - width: 6px; - height: 6px; - background: #409EFF; - border-radius: 50%; - flex-shrink: 0; -} -.file-chip .name { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - flex: 1; - min-width: 0; -} +.file-chip { display: flex; align-items: center; gap: 6px; padding: 6px 8px; background: #f5f7fa; border-radius: 4px; font-size: 12px; color: #606266; margin-top: 6px; width: 100%; box-sizing: border-box; } +.file-chip .dot { width: 6px; height: 6px; background: #409EFF; border-radius: 50%; flex-shrink: 0; } +.file-chip .name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.file-chip .delete-btn { cursor: pointer; opacity: 0.6; flex-shrink: 0; } +.file-chip .delete-btn:hover { opacity: 1; } .dropzone { border: 1px dashed #c0c4cc; @@ -669,9 +843,13 @@ defineExpose({ transition: all 0.2s ease; } .dropzone:hover { background: #f6fbff; border-color: #409EFF; } +.dropzone.uploading { cursor: not-allowed; opacity: 0.7; } +.dropzone.uploading:hover { background: #fafafa; border-color: #c0c4cc; } .dz-icon { font-size: 20px; margin-bottom: 6px; color: #909399; } .dz-text { color: #303133; font-size: 13px; margin-bottom: 2px; } .dz-sub { color: #909399; font-size: 12px; } +.spinner { animation: spin 1s linear infinite; } +@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } .query-options { display: flex; diff --git a/electron-vue-template/src/renderer/components/rakuten/RakutenDashboard.vue b/electron-vue-template/src/renderer/components/rakuten/RakutenDashboard.vue index 3738932..497ab58 100644 --- a/electron-vue-template/src/renderer/components/rakuten/RakutenDashboard.vue +++ b/electron-vue-template/src/renderer/components/rakuten/RakutenDashboard.vue @@ -363,6 +363,14 @@ function showMessage(message: string, type: 'info' | 'success' | 'warning' | 'er ElMessage({ message, type }) } +function removeSelectedFile() { + selectedFileName.value = '' + pendingFile.value = null + if (uploadInputRef.value) { + uploadInputRef.value.value = '' + } +} + async function exportToExcel() { if (!allProducts.value.length) { showMessage('没有数据可供导出', 'warning') @@ -481,6 +489,7 @@ onMounted(loadLatest)
{{ selectedFileName }} + 🗑️
@@ -665,7 +674,7 @@ onMounted(loadLatest) .flow-item { position: relative; display: grid; grid-template-columns: 22px 1fr; gap: 10px; padding: 8px 0; } .flow-item .step-index { position: static; width: 22px; height: 22px; line-height: 22px; text-align: center; border-radius: 50%; background: #1677FF; color: #fff; font-size: 12px; font-weight: 600; margin-top: 2px; } .flow-item:after { display: none; } -.step-card { border: none; border-radius: 0; padding: 0; background: transparent; } +.step-card { border: none; border-radius: 0; padding: 0; background: transparent; min-width: 0; } .step-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; } .title { font-size: 13px; font-weight: 600; color: #303133; text-align: left; } .desc { font-size: 12px; color: #909399; margin-bottom: 8px; text-align: left; } @@ -686,9 +695,11 @@ onMounted(loadLatest) .single-input.left { display: flex; gap: 8px; } .action-buttons.column { display: flex; flex-direction: column; gap: 8px; } -.file-chip { display: flex; align-items: center; gap: 6px; padding: 6px 8px; background: #f5f7fa; border-radius: 4px; font-size: 12px; color: #606266; margin-top: 6px; } -.file-chip .dot { width: 6px; height: 6px; background: #409EFF; border-radius: 50%; display: inline-block; } -.file-chip .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.file-chip { display: flex; align-items: center; gap: 6px; padding: 6px 8px; background: #f5f7fa; border-radius: 4px; font-size: 12px; color: #606266; margin-top: 6px; width: 100%; box-sizing: border-box; } +.file-chip .dot { width: 6px; height: 6px; background: #409EFF; border-radius: 50%; flex-shrink: 0; } +.file-chip .name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.file-chip .delete-btn { cursor: pointer; opacity: 0.6; flex-shrink: 0; } +.file-chip .delete-btn:hover { opacity: 1; } .progress-section.left { margin-top: 10px; } .full { width: 100%; } diff --git a/erp_client_sb/pom.xml b/erp_client_sb/pom.xml index 6321b6a..77cab20 100644 --- a/erp_client_sb/pom.xml +++ b/erp_client_sb/pom.xml @@ -10,7 +10,7 @@ com.tashow.erp erp_client_sb - 2.5.6 + 2.6.0 erp_client_sb erp客户端 diff --git a/erp_client_sb/src/main/java/com/tashow/erp/config/ChromeDriverPreloader.java b/erp_client_sb/src/main/java/com/tashow/erp/config/ChromeDriverPreloader.java index 39b078c..1a187ab 100644 --- a/erp_client_sb/src/main/java/com/tashow/erp/config/ChromeDriverPreloader.java +++ b/erp_client_sb/src/main/java/com/tashow/erp/config/ChromeDriverPreloader.java @@ -29,7 +29,7 @@ public class ChromeDriverPreloader implements ApplicationRunner { @Bean public ChromeDriver chromeDriver() { // 为兼容性保留 Bean,但不自动创建 - if (globalDriver == null) globalDriver = SeleniumUtil.createDriver(false); + if (globalDriver == null) globalDriver = SeleniumUtil.createDriver(true); return globalDriver; } diff --git a/erp_client_sb/src/main/java/com/tashow/erp/controller/TrademarkController.java b/erp_client_sb/src/main/java/com/tashow/erp/controller/TrademarkController.java index ae1b4bd..51761e6 100644 --- a/erp_client_sb/src/main/java/com/tashow/erp/controller/TrademarkController.java +++ b/erp_client_sb/src/main/java/com/tashow/erp/controller/TrademarkController.java @@ -1,4 +1,8 @@ package com.tashow.erp.controller; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.tashow.erp.entity.TrademarkSessionEntity; +import com.tashow.erp.repository.TrademarkSessionRepository; +import com.tashow.erp.service.BrandTrademarkCacheService; import com.tashow.erp.utils.ExcelParseUtil; import com.tashow.erp.utils.JsonData; import com.tashow.erp.utils.LoggerUtil; @@ -7,6 +11,7 @@ import org.slf4j.Logger; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; +import java.time.LocalDateTime; import java.util.*; import java.util.stream.Collectors; /** @@ -17,52 +22,99 @@ import java.util.stream.Collectors; @CrossOrigin public class TrademarkController { private static final Logger logger = LoggerUtil.getLogger(TrademarkController.class); + private static final ObjectMapper objectMapper = new ObjectMapper(); @Autowired private TrademarkCheckUtil util; + + @Autowired + private BrandTrademarkCacheService cacheService; + + @Autowired + private TrademarkSessionRepository sessionRepository; + + // 进度追踪 + private final Map progressMap = new java.util.concurrent.ConcurrentHashMap<>(); + + // 任务取消标志 + private final Map cancelMap = new java.util.concurrent.ConcurrentHashMap<>(); /** - * 批量品牌商标筛查(浏览器内并发,极速版) + * 批量品牌商标筛查 */ @PostMapping("/brandCheck") - public JsonData brandCheck(@RequestBody List brands) { + public JsonData brandCheck(@RequestBody Map request) { + @SuppressWarnings("unchecked") + List brands = (List) request.get("brands"); + String taskId = (String) request.get("taskId"); + try { List list = brands.stream() .filter(b -> b != null && !b.trim().isEmpty()) .map(String::trim) .distinct() .collect(Collectors.toList()); + long start = System.currentTimeMillis(); - // 串行查询(不加延迟) + + // 1. 先从全局缓存获取 + Map cached = cacheService.getCached(list); + + // 2. 找出缓存未命中的品牌 + List toQuery = list.stream() + .filter(b -> !cached.containsKey(b)) + .collect(Collectors.toList()); + + logger.info("全局缓存命中: {}/{},需查询: {}", cached.size(), list.size(), toQuery.size()); + + // 3. 查询未命中的品牌 + Map queried = new HashMap<>(); + if (!toQuery.isEmpty()) { + for (int i = 0; i < toQuery.size(); i++) { + // 检查任务是否被取消 + if (taskId != null && cancelMap.getOrDefault(taskId, false)) { + logger.info("任务 {} 已被取消,停止查询", taskId); + break; + } + + String brand = toQuery.get(i); + logger.info("处理第 {} 个: {}", i + 1, brand); + + Map results = util.batchCheck(Collections.singletonList(brand), queried); + queried.putAll(results); + + // 更新进度 + if (taskId != null) { + progressMap.put(taskId, cached.size() + queried.size()); + } + } + + // 查询结束,保存所有品牌 + if (!queried.isEmpty()) + cacheService.saveResults(queried); + } + + // 5. 合并缓存和新查询结果 + Map allResults = new HashMap<>(cached); + allResults.putAll(queried); + + // 6. 统计结果 List> unregistered = new ArrayList<>(); - int checkedCount = 0; int registeredCount = 0; - for (int i = 0; i < list.size(); i++) { - String brand = list.get(i); - logger.info("处理第 {} 个: {}", i + 1, brand); - - Map results = util.batchCheck(Collections.singletonList(brand)); - - results.forEach((b, isReg) -> { - if (!isReg) { - Map m = new HashMap<>(); - m.put("brand", b); - m.put("status", "未注册"); - unregistered.add(m); - } - }); - - // 统计成功查询的数量 - if (!results.isEmpty()) { - checkedCount++; - if (results.values().iterator().next()) { - registeredCount++; - } + for (Map.Entry entry : allResults.entrySet()) { + if (!entry.getValue()) { + Map m = new HashMap<>(); + m.put("brand", entry.getKey()); + m.put("status", "未注册"); + unregistered.add(m); + } else { + registeredCount++; } } long t = (System.currentTimeMillis() - start) / 1000; + int checkedCount = allResults.size(); int failedCount = list.size() - checkedCount; Map res = new HashMap<>(); @@ -76,30 +128,281 @@ public class TrademarkController { logger.info("完成: 共{}个,成功查询{}个(已注册{}个,未注册{}个),查询失败{}个,耗时{}秒", list.size(), checkedCount, registeredCount, unregistered.size(), failedCount, t); + + // 30秒后清理进度和取消标志 + if (taskId != null) { + String finalTaskId = taskId; + new Thread(() -> { + try { Thread.sleep(30000); } catch (InterruptedException ignored) {} + progressMap.remove(finalTaskId); + cancelMap.remove(finalTaskId); + }).start(); + } + return JsonData.buildSuccess(res); } catch (Exception e) { logger.error("筛查失败", e); return JsonData.buildError("筛查失败: " + e.getMessage()); } finally { - // 采集完成或失败后关闭浏览器 util.closeDriver(); + cacheService.cleanExpired(); } } + + /** + * 查询品牌筛查进度 + */ + @GetMapping("/brandCheckProgress") + public JsonData getBrandCheckProgress(@RequestParam("taskId") String taskId) { + Integer current = progressMap.get(taskId); + if (current == null) { + return JsonData.buildError("任务不存在或已完成"); + } + Map result = new HashMap<>(); + result.put("current", current); + return JsonData.buildSuccess(result); + } + + /** + * 取消品牌筛查任务 + */ + @PostMapping("/cancelBrandCheck") + public JsonData cancelBrandCheck(@RequestBody Map request) { + String taskId = request.get("taskId"); + if (taskId != null) { + cancelMap.put(taskId, true); + logger.info("任务 {} 已标记为取消", taskId); + return JsonData.buildSuccess("任务已取消"); + } + return JsonData.buildError("缺少taskId参数"); + } /** - * 从Excel提取品牌列表 + * 验证Excel表头 + */ + @PostMapping("/validateHeaders") + public JsonData validateHeaders(@RequestParam("file") MultipartFile file, + @RequestParam(value = "requiredHeaders", required = false) String requiredHeadersJson) { + try { + Map fullData = ExcelParseUtil.parseFullExcel(file); + @SuppressWarnings("unchecked") + List headers = (List) fullData.get("headers"); + + if (headers == null || headers.isEmpty()) { + return JsonData.buildError("无法读取Excel表头"); + } + + Map result = new HashMap<>(); + result.put("headers", headers); + + // 如果提供了必需表头,进行验证 + if (requiredHeadersJson != null && !requiredHeadersJson.trim().isEmpty()) { + List requiredHeaders = objectMapper.readValue(requiredHeadersJson, + objectMapper.getTypeFactory().constructCollectionType(List.class, String.class)); + + List missing = new ArrayList<>(); + for (String required : requiredHeaders) { + if (!headers.contains(required)) { + missing.add(required); + } + } + + result.put("valid", missing.isEmpty()); + result.put("missing", missing); + + if (!missing.isEmpty()) { + return JsonData.buildError("缺少必需的列: " + String.join(", ", missing)); + } + } + + return JsonData.buildSuccess(result); + } catch (Exception e) { + logger.error("验证表头失败", e); + return JsonData.buildError("验证失败: " + e.getMessage()); + } + } + + /** + * 从Excel提取品牌列表(同时返回完整Excel数据) */ @PostMapping("/extractBrands") public JsonData extractBrands(@RequestParam("file") MultipartFile file) { try { List brands = ExcelParseUtil.parseColumnByName(file, "品牌"); if (brands.isEmpty()) return JsonData.buildError("未找到品牌列或品牌数据为空"); + + // 读取完整Excel数据 + Map fullData = ExcelParseUtil.parseFullExcel(file); + Map result = new HashMap<>(); result.put("total", brands.size()); result.put("brands", brands); + result.put("headers", fullData.get("headers")); + result.put("allRows", fullData.get("rows")); return JsonData.buildSuccess(result); } catch (Exception e) { return JsonData.buildError("提取失败: " + e.getMessage()); } } + + /** + * 根据ASIN列表从Excel中过滤完整行数据 + */ + @PostMapping("/filterByAsins") + public JsonData filterByAsins(@RequestParam("file") MultipartFile file, @RequestParam("asins") String asinsJson) { + try { + if (asinsJson == null || asinsJson.trim().isEmpty()) { + return JsonData.buildError("ASIN列表不能为空"); + } + + // 使用Jackson解析JSON数组 + List asins; + try { + asins = objectMapper.readValue(asinsJson, + objectMapper.getTypeFactory().constructCollectionType(List.class, String.class)); + } catch (Exception e) { + logger.error("解析ASIN列表JSON失败: {}", asinsJson, e); + return JsonData.buildError("ASIN列表格式错误: " + e.getMessage()); + } + + if (asins == null || asins.isEmpty()) { + return JsonData.buildError("ASIN列表不能为空"); + } + + logger.info("接收到ASIN过滤请求,ASIN数量: {}", asins.size()); + + Map result = ExcelParseUtil.filterExcelByAsins(file, asins); + + @SuppressWarnings("unchecked") + List> filteredRows = (List>) result.get("filteredRows"); + + Map response = new HashMap<>(); + response.put("headers", result.get("headers")); + response.put("filteredRows", filteredRows); + response.put("total", filteredRows.size()); + + logger.info("ASIN过滤完成,过滤出 {} 行数据", filteredRows.size()); + + return JsonData.buildSuccess(response); + } catch (Exception e) { + logger.error("根据ASIN过滤失败", e); + return JsonData.buildError("过滤失败: " + e.getMessage()); + } + } + + /** + * 根据品牌列表从Excel中过滤完整行数据 + */ + @PostMapping("/filterByBrands") + public JsonData filterByBrands(@RequestParam("file") MultipartFile file, @RequestParam("brands") String brandsJson) { + try { + if (brandsJson == null || brandsJson.trim().isEmpty()) { + return JsonData.buildError("品牌列表不能为空"); + } + + // 使用Jackson解析JSON数组 + List brands; + try { + brands = objectMapper.readValue(brandsJson, + objectMapper.getTypeFactory().constructCollectionType(List.class, String.class)); + } catch (Exception e) { + logger.error("解析品牌列表JSON失败: {}", brandsJson, e); + return JsonData.buildError("品牌列表格式错误: " + e.getMessage()); + } + + if (brands == null || brands.isEmpty()) { + return JsonData.buildError("品牌列表不能为空"); + } + + logger.info("接收到品牌过滤请求,品牌数量: {}", brands.size()); + + Map result = ExcelParseUtil.filterExcelByBrands(file, brands); + + @SuppressWarnings("unchecked") + List> filteredRows = (List>) result.get("filteredRows"); + + Map response = new HashMap<>(); + response.put("headers", result.get("headers")); + response.put("filteredRows", filteredRows); + response.put("total", filteredRows.size()); + + logger.info("品牌过滤完成,过滤出 {} 行数据", filteredRows.size()); + + return JsonData.buildSuccess(response); + } catch (Exception e) { + logger.error("根据品牌过滤失败", e); + return JsonData.buildError("过滤失败: " + e.getMessage()); + } + } + + /** + * 保存商标查询会话 + */ + @PostMapping("/saveSession") + public JsonData saveSession(@RequestBody Map sessionData, + @RequestHeader(value = "username", required = false) String username) { + try { + if (username == null || username.trim().isEmpty()) { + username = "default"; + } + + String sessionId = UUID.randomUUID().toString(); + TrademarkSessionEntity entity = new TrademarkSessionEntity(); + entity.setSessionId(sessionId); + entity.setUsername(username); + entity.setFileName((String) sessionData.get("fileName")); + entity.setResultData(objectMapper.writeValueAsString(sessionData.get("resultData"))); + entity.setFullData(objectMapper.writeValueAsString(sessionData.get("fullData"))); + entity.setHeaders(objectMapper.writeValueAsString(sessionData.get("headers"))); + entity.setTaskProgress(objectMapper.writeValueAsString(sessionData.get("taskProgress"))); + entity.setQueryStatus((String) sessionData.get("queryStatus")); + + sessionRepository.save(entity); + + // 清理7天前的数据 + sessionRepository.deleteByCreatedAtBefore(LocalDateTime.now().minusDays(7)); + + logger.info("保存商标查询会话: {} (用户: {})", sessionId, username); + + Map result = new HashMap<>(); + result.put("sessionId", sessionId); + return JsonData.buildSuccess(result); + } catch (Exception e) { + logger.error("保存会话失败", e); + return JsonData.buildError("保存失败: " + e.getMessage()); + } + } + + /** + * 根据sessionId恢复查询会话 + */ + @GetMapping("/getSession") + public JsonData getSession(@RequestParam("sessionId") String sessionId, + @RequestHeader(value = "username", required = false) String username) { + try { + if (username == null || username.trim().isEmpty()) { + username = "default"; + } + + Optional opt = sessionRepository.findBySessionIdAndUsername(sessionId, username); + if (!opt.isPresent()) { + return JsonData.buildError("会话不存在或已过期"); + } + + TrademarkSessionEntity entity = opt.get(); + Map result = new HashMap<>(); + result.put("fileName", entity.getFileName()); + result.put("resultData", objectMapper.readValue(entity.getResultData(), List.class)); + result.put("fullData", objectMapper.readValue(entity.getFullData(), List.class)); + result.put("headers", objectMapper.readValue(entity.getHeaders(), List.class)); + result.put("taskProgress", objectMapper.readValue(entity.getTaskProgress(), Map.class)); + result.put("queryStatus", entity.getQueryStatus()); + + logger.info("恢复商标查询会话: {} (用户: {})", sessionId, username); + return JsonData.buildSuccess(result); + } catch (Exception e) { + logger.error("恢复会话失败", e); + return JsonData.buildError("恢复失败: " + e.getMessage()); + } + } } diff --git a/erp_client_sb/src/main/java/com/tashow/erp/entity/BrandTrademarkCacheEntity.java b/erp_client_sb/src/main/java/com/tashow/erp/entity/BrandTrademarkCacheEntity.java new file mode 100644 index 0000000..652a9be --- /dev/null +++ b/erp_client_sb/src/main/java/com/tashow/erp/entity/BrandTrademarkCacheEntity.java @@ -0,0 +1,35 @@ +package com.tashow.erp.entity; + +import jakarta.persistence.*; +import lombok.Data; +import java.time.LocalDateTime; + +@Entity +@Table(name = "brand_trademark_cache", + uniqueConstraints = @UniqueConstraint(columnNames = {"brand"})) +@Data +public class BrandTrademarkCacheEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String brand; + + @Column(nullable = false) + private Boolean registered; + + @Column + private String username; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + username = "global"; // 全局缓存 + } +} + diff --git a/erp_client_sb/src/main/java/com/tashow/erp/entity/TrademarkSessionEntity.java b/erp_client_sb/src/main/java/com/tashow/erp/entity/TrademarkSessionEntity.java new file mode 100644 index 0000000..66dff91 --- /dev/null +++ b/erp_client_sb/src/main/java/com/tashow/erp/entity/TrademarkSessionEntity.java @@ -0,0 +1,48 @@ +package com.tashow.erp.entity; + +import jakarta.persistence.*; +import lombok.Data; +import java.time.LocalDateTime; + +@Entity +@Table(name = "trademark_sessions") +@Data +public class TrademarkSessionEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "session_id", unique = true, nullable = false) + private String sessionId; + + @Column(nullable = false) + private String username; + + @Column(name = "file_name") + private String fileName; + + @Column(name = "result_data", columnDefinition = "TEXT") + private String resultData; + + @Column(name = "full_data", columnDefinition = "TEXT") + private String fullData; + + @Column(columnDefinition = "TEXT") + private String headers; + + @Column(name = "task_progress", columnDefinition = "TEXT") + private String taskProgress; + + @Column(name = "query_status") + private String queryStatus; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + } +} + diff --git a/erp_client_sb/src/main/java/com/tashow/erp/repository/BrandTrademarkCacheRepository.java b/erp_client_sb/src/main/java/com/tashow/erp/repository/BrandTrademarkCacheRepository.java new file mode 100644 index 0000000..947e3a8 --- /dev/null +++ b/erp_client_sb/src/main/java/com/tashow/erp/repository/BrandTrademarkCacheRepository.java @@ -0,0 +1,29 @@ +package com.tashow.erp.repository; + +import com.tashow.erp.entity.BrandTrademarkCacheEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Repository +public interface BrandTrademarkCacheRepository extends JpaRepository { + + boolean existsByBrand(String brand); + + Optional findByBrandAndCreatedAtAfter( + String brand, LocalDateTime cutoffTime); + + List findByBrandInAndCreatedAtAfter( + List brands, LocalDateTime cutoffTime); + + @Modifying + @Transactional + @Query("DELETE FROM BrandTrademarkCacheEntity WHERE createdAt < ?1") + void deleteByCreatedAtBefore(LocalDateTime cutoffTime); +} + diff --git a/erp_client_sb/src/main/java/com/tashow/erp/repository/TrademarkSessionRepository.java b/erp_client_sb/src/main/java/com/tashow/erp/repository/TrademarkSessionRepository.java new file mode 100644 index 0000000..85c601f --- /dev/null +++ b/erp_client_sb/src/main/java/com/tashow/erp/repository/TrademarkSessionRepository.java @@ -0,0 +1,22 @@ +package com.tashow.erp.repository; + +import com.tashow.erp.entity.TrademarkSessionEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; +import java.util.Optional; + +@Repository +public interface TrademarkSessionRepository extends JpaRepository { + + Optional findBySessionIdAndUsername(String sessionId, String username); + + @Modifying + @Transactional + @Query("DELETE FROM TrademarkSessionEntity WHERE createdAt < ?1") + void deleteByCreatedAtBefore(LocalDateTime cutoffTime); +} + diff --git a/erp_client_sb/src/main/java/com/tashow/erp/service/BrandTrademarkCacheService.java b/erp_client_sb/src/main/java/com/tashow/erp/service/BrandTrademarkCacheService.java new file mode 100644 index 0000000..44cbe38 --- /dev/null +++ b/erp_client_sb/src/main/java/com/tashow/erp/service/BrandTrademarkCacheService.java @@ -0,0 +1,23 @@ +package com.tashow.erp.service; + +import java.util.List; +import java.util.Map; + +public interface BrandTrademarkCacheService { + + /** + * 批量获取缓存(1天内有效,全局共享) + */ + Map getCached(List brands); + + /** + * 批量保存查询结果(全局共享) + */ + void saveResults(Map results); + + /** + * 清理1天前的过期数据 + */ + void cleanExpired(); +} + diff --git a/erp_client_sb/src/main/java/com/tashow/erp/service/CacheService.java b/erp_client_sb/src/main/java/com/tashow/erp/service/CacheService.java index 23bcd1e..c08d38d 100644 --- a/erp_client_sb/src/main/java/com/tashow/erp/service/CacheService.java +++ b/erp_client_sb/src/main/java/com/tashow/erp/service/CacheService.java @@ -30,6 +30,10 @@ public class CacheService { private CacheDataRepository cacheDataRepository; @Autowired private UpdateStatusRepository updateStatusRepository; + @Autowired + private BrandTrademarkCacheRepository brandTrademarkCacheRepository; + @Autowired + private TrademarkSessionRepository trademarkSessionRepository; public void saveAuthToken(String service, String token, long expireTimeMillis) { try { @@ -46,25 +50,14 @@ public class CacheService { @Transactional public void clearCache() { - - - // 清理所有产品数据 rakutenProductRepository.deleteAll(); - - amazonProductRepository.deleteAll(); - - alibaba1688ProductRepository.deleteAll(); - - - // 清理所有订单数据 banmaOrderRepository.deleteAll(); - zebraOrderRepository.deleteAll(); - // 清理通用缓存和更新状态 cacheDataRepository.deleteAll(); - + brandTrademarkCacheRepository.deleteAll(); + trademarkSessionRepository.deleteAll(); } } diff --git a/erp_client_sb/src/main/java/com/tashow/erp/service/impl/BrandTrademarkCacheServiceImpl.java b/erp_client_sb/src/main/java/com/tashow/erp/service/impl/BrandTrademarkCacheServiceImpl.java new file mode 100644 index 0000000..2202a17 --- /dev/null +++ b/erp_client_sb/src/main/java/com/tashow/erp/service/impl/BrandTrademarkCacheServiceImpl.java @@ -0,0 +1,56 @@ +package com.tashow.erp.service.impl; + +import com.tashow.erp.entity.BrandTrademarkCacheEntity; +import com.tashow.erp.repository.BrandTrademarkCacheRepository; +import com.tashow.erp.service.BrandTrademarkCacheService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Slf4j +@Service +public class BrandTrademarkCacheServiceImpl implements BrandTrademarkCacheService { + + @Autowired + private BrandTrademarkCacheRepository repository; + + @Override + public Map getCached(List brands) { + LocalDateTime cutoffTime = LocalDateTime.now().minusDays(1); + List cached = repository.findByBrandInAndCreatedAtAfter(brands, cutoffTime); + + Map result = new HashMap<>(); + cached.forEach(e -> result.put(e.getBrand(), e.getRegistered())); + + if (!result.isEmpty()) { + log.info("从全局缓存获取 {} 个品牌数据", result.size()); + } + return result; + } + + @Override + public void saveResults(Map results) { + results.forEach((brand, registered) -> { + if (!repository.existsByBrand(brand)) { + BrandTrademarkCacheEntity entity = new BrandTrademarkCacheEntity(); + entity.setBrand(brand); + entity.setRegistered(registered); + repository.save(entity); + } + }); + } + + @Override + @Transactional + public void cleanExpired() { + LocalDateTime cutoffTime = LocalDateTime.now().minusDays(1); + repository.deleteByCreatedAtBefore(cutoffTime); + log.info("清理1天前的品牌商标缓存"); + } +} + diff --git a/erp_client_sb/src/main/java/com/tashow/erp/utils/ExcelParseUtil.java b/erp_client_sb/src/main/java/com/tashow/erp/utils/ExcelParseUtil.java index 84b7b90..c7a847a 100644 --- a/erp_client_sb/src/main/java/com/tashow/erp/utils/ExcelParseUtil.java +++ b/erp_client_sb/src/main/java/com/tashow/erp/utils/ExcelParseUtil.java @@ -6,8 +6,8 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.web.multipart.MultipartFile; import java.io.InputStream; -import java.util.ArrayList; -import java.util.List; +import java.util.*; +import java.util.stream.Collectors; /** * Excel 解析工具类 @@ -18,6 +18,23 @@ import java.util.List; @Slf4j public class ExcelParseUtil { + /** + * 自动查找表头行索引(在前2行中查找) + * @param rows Excel所有行 + * @param columnName 列名(如"品牌") + * @return 表头行索引,未找到返回-1 + */ + private static int findHeaderRow(List> rows, String columnName) { + for (int r = 0; r < Math.min(2, rows.size()); r++) { + for (Object cell : rows.get(r)) { + if (cell != null && columnName.equals(cell.toString().replaceAll("\\s+", ""))) { + return r; + } + } + } + return -1; + } + /** * 解析 Excel 文件第一列数据 * 通用方法,适用于店铺名、ASIN、订单号等标识符解析 @@ -102,7 +119,6 @@ public class ExcelParseUtil { } /** - * 根据列名解析数据(自动适配第1行或第2行为表头) */ public static List parseColumnByName(MultipartFile file, String columnName) { @@ -112,23 +128,18 @@ public class ExcelParseUtil { List> rows = reader.read(); if (rows.isEmpty()) return result; - // 查找表头行和列索引 - int headerRow = -1, colIdx = -1; - for (int r = 0; r < Math.min(2, rows.size()); r++) { - for (int c = 0; c < rows.get(r).size(); c++) { - String col = rows.get(r).get(c).toString().replaceAll("\\s+", ""); - if (col.equals(columnName)) { - headerRow = r; - colIdx = c; - break; - } - } - if (colIdx != -1) break; - } - - if (colIdx == -1) return result; + int headerRow = findHeaderRow(rows, columnName); + if (headerRow < 0) return result; + + int colIdx = -1; + for (int c = 0; c < rows.get(headerRow).size(); c++) { + if (rows.get(headerRow).get(c) != null && + columnName.equals(rows.get(headerRow).get(c).toString().replaceAll("\\s+", ""))) { + colIdx = c; + break; + } + } - // 从表头下一行开始读数据 for (int i = headerRow + 1; i < rows.size(); i++) { List row = rows.get(i); if (row.size() > colIdx && row.get(colIdx) != null) { @@ -141,4 +152,186 @@ public class ExcelParseUtil { } return result; } + + /** + * 读取Excel的完整数据(包含表头和所有行,自动适配第1行或第2行为表头) + * @param file Excel文件 + * @return Map包含headers(表头列表)和rows(数据行列表,每行是Map) + */ + public static Map parseFullExcel(MultipartFile file) { + Map result = new HashMap<>(); + List headers = new ArrayList<>(); + List> rows = new ArrayList<>(); + + try (InputStream in = file.getInputStream()) { + ExcelReader reader = ExcelUtil.getReader(in, 0); + List> allRows = reader.read(); + + if (allRows.isEmpty()) { + log.warn("Excel文件为空"); + result.put("headers", headers); + result.put("rows", rows); + return result; + } + + int headerRowIndex = Math.max(0, findHeaderRow(allRows, "品牌")); + log.info("检测到表头行:第{}行", headerRowIndex + 1); + + for (Object cell : allRows.get(headerRowIndex)) { + headers.add(cell != null ? cell.toString().trim() : ""); + } + + for (int i = headerRowIndex + 1; i < allRows.size(); i++) { + List row = allRows.get(i); + Map rowMap = new HashMap<>(); + for (int j = 0; j < Math.min(headers.size(), row.size()); j++) { + rowMap.put(headers.get(j), row.get(j)); + } + rows.add(rowMap); + } + + result.put("headers", headers); + result.put("rows", rows); + log.info("解析Excel: {}, 表头{}列, 数据{}行", file.getOriginalFilename(), headers.size(), rows.size()); + + } catch (Exception e) { + log.error("解析Excel失败: {}", e.getMessage(), e); + } + + return result; + } + + /** + * 根据ASIN列表从Excel中过滤完整行数据(自动适配第1行或第2行为表头) + * @param file Excel文件 + * @param asins ASIN列表 + * @return Map包含headers(表头)和filteredRows(过滤后的完整行数据) + */ + public static Map filterExcelByAsins(MultipartFile file, List asins) { + Map result = new HashMap<>(); + List headers = new ArrayList<>(); + List> filteredRows = new ArrayList<>(); + + try (InputStream in = file.getInputStream()) { + ExcelReader reader = ExcelUtil.getReader(in, 0); + List> allRows = reader.read(); + + if (allRows.isEmpty()) { + result.put("headers", headers); + result.put("filteredRows", filteredRows); + return result; + } + + int headerRowIndex = Math.max(0, findHeaderRow(allRows, "ASIN")); + if (headerRowIndex < 0) { + log.warn("未找到'ASIN'列"); + result.put("headers", headers); + result.put("filteredRows", filteredRows); + return result; + } + + int asinColIndex = -1; + List headerRow = allRows.get(headerRowIndex); + for (int c = 0; c < headerRow.size(); c++) { + if (headerRow.get(c) != null && "ASIN".equals(headerRow.get(c).toString().replaceAll("\\s+", ""))) { + asinColIndex = c; + break; + } + } + + for (Object cell : headerRow) { + headers.add(cell != null ? cell.toString().trim() : ""); + } + + Set asinSet = asins.stream().map(String::trim).collect(Collectors.toSet()); + + for (int i = headerRowIndex + 1; i < allRows.size(); i++) { + List row = allRows.get(i); + if (row.size() > asinColIndex && row.get(asinColIndex) != null + && asinSet.contains(row.get(asinColIndex).toString().trim())) { + Map rowMap = new HashMap<>(); + for (int j = 0; j < Math.min(headers.size(), row.size()); j++) { + rowMap.put(headers.get(j), row.get(j)); + } + filteredRows.add(rowMap); + } + } + + result.put("headers", headers); + result.put("filteredRows", filteredRows); + log.info("ASIN过滤: {}, {}个ASIN -> {}行数据", file.getOriginalFilename(), asins.size(), filteredRows.size()); + + } catch (Exception e) { + log.error("ASIN过滤失败: {}", e.getMessage(), e); + } + + return result; + } + + /** + * 根据品牌列表从Excel中过滤完整行数据(自动适配第1行或第2行为表头) + * @param file Excel文件 + * @param brands 品牌列表 + * @return Map包含headers(表头)和filteredRows(过滤后的完整行数据) + */ + public static Map filterExcelByBrands(MultipartFile file, List brands) { + Map result = new HashMap<>(); + List headers = new ArrayList<>(); + List> filteredRows = new ArrayList<>(); + + try (InputStream in = file.getInputStream()) { + ExcelReader reader = ExcelUtil.getReader(in, 0); + List> allRows = reader.read(); + + if (allRows.isEmpty()) { + result.put("headers", headers); + result.put("filteredRows", filteredRows); + return result; + } + + int headerRowIndex = findHeaderRow(allRows, "品牌"); + if (headerRowIndex < 0) { + log.warn("未找到'品牌'列"); + result.put("headers", headers); + result.put("filteredRows", filteredRows); + return result; + } + + int brandColIndex = -1; + List headerRow = allRows.get(headerRowIndex); + for (int c = 0; c < headerRow.size(); c++) { + if (headerRow.get(c) != null && "品牌".equals(headerRow.get(c).toString().replaceAll("\\s+", ""))) { + brandColIndex = c; + break; + } + } + + for (Object cell : headerRow) { + headers.add(cell != null ? cell.toString().trim() : ""); + } + + Set brandSet = brands.stream().map(String::trim).collect(Collectors.toSet()); + + for (int i = headerRowIndex + 1; i < allRows.size(); i++) { + List row = allRows.get(i); + if (row.size() > brandColIndex && row.get(brandColIndex) != null + && brandSet.contains(row.get(brandColIndex).toString().trim())) { + Map rowMap = new HashMap<>(); + for (int j = 0; j < Math.min(headers.size(), row.size()); j++) { + rowMap.put(headers.get(j), row.get(j)); + } + filteredRows.add(rowMap); + } + } + + result.put("headers", headers); + result.put("filteredRows", filteredRows); + log.info("品牌过滤: {}, {}个品牌 -> {}行数据", file.getOriginalFilename(), brands.size(), filteredRows.size()); + + } catch (Exception e) { + log.error("品牌过滤失败: {}", e.getMessage(), e); + } + + return result; + } } \ No newline at end of file diff --git a/erp_client_sb/src/main/java/com/tashow/erp/utils/SeleniumUtil.java b/erp_client_sb/src/main/java/com/tashow/erp/utils/SeleniumUtil.java index e10584d..060f124 100644 --- a/erp_client_sb/src/main/java/com/tashow/erp/utils/SeleniumUtil.java +++ b/erp_client_sb/src/main/java/com/tashow/erp/utils/SeleniumUtil.java @@ -2,9 +2,6 @@ package com.tashow.erp.utils; import io.github.bonigarcia.wdm.WebDriverManager; import org.openqa.selenium.chrome.ChromeDriver; import org.openqa.selenium.chrome.ChromeOptions; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.web.client.RestTemplate; - import java.net.URL; import java.util.Collections; import java.util.Map; diff --git a/erp_client_sb/src/main/java/com/tashow/erp/utils/TrademarkCheckUtil.java b/erp_client_sb/src/main/java/com/tashow/erp/utils/TrademarkCheckUtil.java index 61ddf40..675cbce 100644 --- a/erp_client_sb/src/main/java/com/tashow/erp/utils/TrademarkCheckUtil.java +++ b/erp_client_sb/src/main/java/com/tashow/erp/utils/TrademarkCheckUtil.java @@ -1,10 +1,10 @@ package com.tashow.erp.utils; +import com.tashow.erp.service.BrandTrademarkCacheService; import jakarta.annotation.PreDestroy; import org.openqa.selenium.JavascriptExecutor; import org.openqa.selenium.chrome.ChromeDriver; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; - import java.util.*; /** @@ -15,12 +15,15 @@ import java.util.*; public class TrademarkCheckUtil { @Autowired private ProxyPool proxyPool; + @Autowired + private BrandTrademarkCacheService cacheService; private ChromeDriver driver; + private synchronized void ensureInit() { if (driver == null) { for (int i = 0; i < 5; i++) { try { - driver = SeleniumUtil.createDriver(false, proxyPool.getProxy()); + driver = SeleniumUtil.createDriver(true, proxyPool.getProxy()); driver.get("https://tmsearch.uspto.gov/search/search-results"); Thread.sleep(6000); return; // 成功则返回 @@ -36,7 +39,7 @@ public class TrademarkCheckUtil { } } - public synchronized Map batchCheck(List brands) { + public synchronized Map batchCheck(List brands, Map alreadyQueried) { ensureInit(); // 构建批量查询脚本(带错误诊断) @@ -80,16 +83,32 @@ public class TrademarkCheckUtil { List> results = (List>) ((JavascriptExecutor) driver).executeAsyncScript(script, brands); - // 检测是否有403错误 - boolean has403 = results.stream() + // 检测是否有网络错误(包括403、Failed to fetch等) + boolean hasNetworkError = results.stream() .anyMatch(item -> { String error = (String) item.get("error"); - return error != null && error.contains("HTTP 403"); + return error != null && ( + error.contains("HTTP 403") || + error.contains("Failed to fetch") || + error.contains("NetworkError") || + error.contains("TypeError") + ); }); - // 如果有403,切换代理并重试 - if (has403) { - System.err.println("检测到403,切换代理并重试..."); + // 如果有网络错误,切换代理并重试 + if (hasNetworkError) { + System.err.println("检测到网络错误,切换代理并重试..."); + + // 切换代理前保存已查询的品牌 + if (alreadyQueried != null && !alreadyQueried.isEmpty()) { + try { + cacheService.saveResults(alreadyQueried); + System.out.println("代理切换,已保存 " + alreadyQueried.size() + " 个品牌到缓存"); + } catch (Exception e) { + System.err.println("保存缓存失败: " + e.getMessage()); + } + } + try { driver.quit(); } catch (Exception e) {} driver = null; ensureInit(); diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/tool/MarkController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/tool/MarkController.java index fa184aa..c1f1841 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/tool/MarkController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/tool/MarkController.java @@ -25,16 +25,13 @@ import java.util.*; @Anonymous public class MarkController { private static final String API_SECRET = "e10adc3949ba59abbe56e057f20f883e"; - // erp_client_sb 服务地址 private static final String ERP_CLIENT_BASE_URL = "http://127.0.0.1:8081"; - private final RestTemplate restTemplate = new RestTemplate(); private final ObjectMapper objectMapper = new ObjectMapper(); @Autowired private RedisCache redisCache; @Autowired private IMarkService markService; - /** * 获取任务列表 */ @@ -82,42 +79,49 @@ public class MarkController { dNode = reJson.get("D").get("items").get(0); downloadUrl = reJson.get("D").get("items").get(0).get("download_url").asText(); } - String tempFilePath = System.getProperty("java.io.tmpdir") + "/trademark_" + System.currentTimeMillis() + ".xlsx"; HttpUtil.downloadFile(downloadUrl, FileUtil.file(tempFilePath)); - List> filteredData = new ArrayList<>(); + List excelHeaders = new ArrayList<>(); ExcelReader reader = null; try { reader = ExcelUtil.getReader(FileUtil.file(tempFilePath)); List> rows = reader.read(); - // 找到各列的索引 - int asinIndex = -1, brandIndex = -1, trademarkTypeIndex = -1, registerDateIndex = -1, productImageIndex = -1; - if (!rows.isEmpty()) { - List header = rows.get(0); - for (int i = 0; i < header.size(); i++) { - String headerName = header.get(i).toString().trim(); - if (headerName.equals("ASIN")) asinIndex = i; - else if (headerName.equals("品牌")) brandIndex = i; - else if (headerName.equals("商标类型")) trademarkTypeIndex = i; - else if (headerName.equals("注册时间")) registerDateIndex = i; - else if (headerName.equals("商品主图")) productImageIndex = i; + if (rows.isEmpty()) { + throw new RuntimeException("Excel文件为空"); + } + + // 读取表头 + List headerRow = rows.get(0); + for (Object cell : headerRow) { + excelHeaders.add(cell != null ? cell.toString().trim() : ""); + } + + // 找到商标类型列的索引 + int trademarkTypeIndex = -1; + for (int i = 0; i < excelHeaders.size(); i++) { + if ("商标类型".equals(excelHeaders.get(i))) { + trademarkTypeIndex = i; + break; } } - // 过滤TM和未注册数据 + if (trademarkTypeIndex < 0) { + throw new RuntimeException("未找到'商标类型'列"); + } + + // 过滤TM和未注册数据,保留所有列 for (int i = 1; i < rows.size(); i++) { List row = rows.get(i); - if (trademarkTypeIndex >= 0 && row.size() > trademarkTypeIndex) { + if (row.size() > trademarkTypeIndex) { String trademarkType = row.get(trademarkTypeIndex).toString().trim(); if ("TM".equals(trademarkType) || "未注册".equals(trademarkType)) { Map item = new HashMap<>(); - if (asinIndex >= 0 && row.size() > asinIndex) item.put("ASIN", row.get(asinIndex)); - if (brandIndex >= 0 && row.size() > brandIndex) item.put("品牌", row.get(brandIndex)); - if (trademarkTypeIndex >= 0 && row.size() > trademarkTypeIndex) item.put("商标类型", row.get(trademarkTypeIndex)); - if (registerDateIndex >= 0 && row.size() > registerDateIndex) item.put("注册时间", row.get(registerDateIndex)); - if (productImageIndex >= 0 && row.size() > productImageIndex) item.put("商品主图", row.get(productImageIndex)); + // 保存所有列的数据 + for (int j = 0; j < excelHeaders.size() && j < row.size(); j++) { + item.put(excelHeaders.get(j), row.get(j)); + } filteredData.add(item); } } @@ -131,6 +135,7 @@ public class MarkController { Map combinedResult = new HashMap<>(); combinedResult.put("original", dNode); combinedResult.put("filtered", filteredData); + combinedResult.put("headers", excelHeaders); return AjaxResult.success(combinedResult); } catch (Exception e) { @@ -138,7 +143,6 @@ public class MarkController { } } - // 新建任务 @PostMapping("newTask") public AjaxResult newTask(@RequestParam("file") MultipartFile file) { diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml index efd968e..79aec47 100644 --- a/ruoyi-admin/src/main/resources/application.yml +++ b/ruoyi-admin/src/main/resources/application.yml @@ -108,11 +108,6 @@ spring: max-wait: 10s # 关闭超时时间 shutdown-timeout: 100ms - # 心跳检测配置 - cluster: - refresh: - adaptive: true - period: 30s # token配置 token: # 令牌自定义标识 diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/core/redis/RedisHealthCheck.java b/ruoyi-common/src/main/java/com/ruoyi/common/core/redis/RedisHealthCheck.java deleted file mode 100644 index 4e42fbb..0000000 --- a/ruoyi-common/src/main/java/com/ruoyi/common/core/redis/RedisHealthCheck.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.ruoyi.common.core.redis; - -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.data.redis.connection.RedisConnectionFactory; -import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; - -/** - * Redis连接健康检查组件 - * 定期检测Redis连接状态,确保连接可用 - * - * @author ruoyi - */ -@Slf4j -@Component -public class RedisHealthCheck { - - @Autowired - private RedisConnectionFactory redisConnectionFactory; - - /** - * 每5分钟检查一次Redis连接状态 - */ - @Scheduled(fixedRate = 300000) - public void checkRedisConnection() { - try { - if (redisConnectionFactory instanceof LettuceConnectionFactory) { - LettuceConnectionFactory lettuceFactory = (LettuceConnectionFactory) redisConnectionFactory; - - // 验证连接是否有效 - if (!lettuceFactory.getConnection().ping().equals("PONG")) { - log.warn("Redis连接异常,尝试重新连接..."); - lettuceFactory.resetConnection(); - log.info("Redis连接已重置"); - } else { - log.debug("Redis连接正常"); - } - } - } catch (Exception e) { - log.error("Redis连接检查失败: {}", e.getMessage()); - try { - // 尝试重置连接 - ((LettuceConnectionFactory) redisConnectionFactory).resetConnection(); - log.info("Redis连接已重置"); - } catch (Exception ex) { - log.error("Redis连接重置失败: {}", ex.getMessage()); - } - } - } -} \ No newline at end of file diff --git a/ruoyi-framework/src/main/java/com/ruoyi/framework/config/LettuceConfig.java b/ruoyi-framework/src/main/java/com/ruoyi/framework/config/LettuceConfig.java new file mode 100644 index 0000000..75905e2 --- /dev/null +++ b/ruoyi-framework/src/main/java/com/ruoyi/framework/config/LettuceConfig.java @@ -0,0 +1,39 @@ +package com.ruoyi.framework.config; + +import io.lettuce.core.ClientOptions; +import io.lettuce.core.SocketOptions; +import org.springframework.boot.autoconfigure.data.redis.LettuceClientConfigurationBuilderCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Duration; + +/** + * Lettuce 连接配置 + * 解决跨公网连接空闲后被中间设备关闭的问题 + * + * @author ruoyi + */ +@Configuration +public class LettuceConfig { + + /** + * 配置 Lettuce 客户端,双重保障防止连接失效 + * 1. TCP Keepalive - 操作系统层维持连接活性 + * 2. Ping Before Activate - 获取连接前验证有效性 + */ + @Bean + public LettuceClientConfigurationBuilderCustomizer lettuceClientConfigurationBuilderCustomizer() { + return clientConfigurationBuilder -> { + clientConfigurationBuilder.clientOptions(ClientOptions.builder() + .autoReconnect(true) // 自动重连 + .pingBeforeActivateConnection(true) // 获取连接前 ping 验证(关键配置) + .socketOptions(SocketOptions.builder() + .keepAlive(true) // TCP Keepalive 辅助保持连接 + .connectTimeout(Duration.ofSeconds(10)) // 连接超时 10 秒 + .build()) + .build()); + }; + } +} + diff --git a/ruoyi-framework/src/main/java/com/ruoyi/framework/config/RedisPoolConfig.java b/ruoyi-framework/src/main/java/com/ruoyi/framework/config/RedisPoolConfig.java deleted file mode 100644 index 78b3c48..0000000 --- a/ruoyi-framework/src/main/java/com/ruoyi/framework/config/RedisPoolConfig.java +++ /dev/null @@ -1,79 +0,0 @@ -package com.ruoyi.framework.config; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.data.redis.connection.RedisConnectionFactory; -import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; -import org.springframework.data.redis.connection.lettuce.LettucePoolingClientConfiguration; -import org.springframework.data.redis.core.RedisTemplate; -import org.apache.commons.pool2.impl.GenericObjectPoolConfig; -import io.lettuce.core.ClientOptions; -import io.lettuce.core.resource.ClientResources; -import io.lettuce.core.resource.DefaultClientResources; - -import java.time.Duration; - -/** - * Redis连接池优化配置 - * 解决Lettuce连接超时问题 - * - * @author ruoyi - */ -@Configuration -public class RedisPoolConfig { - - /** - * 配置Lettuce客户端,启用心跳检测和自动重连 - */ - @Bean - public ClientResources clientResources() { - return DefaultClientResources.builder() - .ioThreadPoolSize(4) // IO线程数 - .computationThreadPoolSize(4) // 计算线程数 - .build(); - } - - /** - * 优化Redis连接池配置 - */ - @Bean - public LettucePoolingClientConfiguration lettucePoolConfig(ClientResources clientResources) { - GenericObjectPoolConfig poolConfig = new GenericObjectPoolConfig<>(); - poolConfig.setMaxTotal(50); // 最大连接数 - poolConfig.setMaxIdle(20); // 最大空闲连接 - poolConfig.setMinIdle(5); // 最小空闲连接 - poolConfig.setMaxWaitMillis(10000); // 获取连接最大等待时间 - - return LettucePoolingClientConfiguration.builder() - .poolConfig(poolConfig) - .clientResources(clientResources) - .clientOptions(ClientOptions.builder() - .autoReconnect(true) // 自动重连 - .pingBeforeActivateConnection(true) // 连接激活前ping检测 - .build()) - .commandTimeout(Duration.ofSeconds(10)) // 命令超时时间 - .shutdownTimeout(Duration.ofMillis(100)) // 关闭超时时间 - .build(); - } - - /** - * 定期检查Redis连接状态 - */ - @Bean - public RedisTemplate optimizedRedisTemplate(RedisConnectionFactory connectionFactory) { - RedisTemplate template = new RedisTemplate<>(); - template.setConnectionFactory(connectionFactory); - - // 使用FastJson序列化器 - FastJson2JsonRedisSerializer serializer = new FastJson2JsonRedisSerializer(Object.class); - - // 设置序列化器 - template.setKeySerializer(new org.springframework.data.redis.serializer.StringRedisSerializer()); - template.setValueSerializer(serializer); - template.setHashKeySerializer(new org.springframework.data.redis.serializer.StringRedisSerializer()); - template.setHashValueSerializer(serializer); - - template.afterPropertiesSet(); - return template; - } -} \ No newline at end of file