feat(trademark):优化商标查询功能和Excel解析逻辑

- 重构品牌商标缓存服务,移除冗余的日志记录和存在检查- 简化Excel解析工具类,提取公共方法并优化列索引查找逻辑
- 增强Electron客户端开发模式下的后端启动控制能力
- 改进商标筛查面板的用户体验和数据处理流程-优化商标查询工具类,提高查询准确性和稳定性
- 调整商标控制器接口参数校验逻辑和资源清理机制
- 更新USPTO API测试用例以支持Spring容器环境运行
This commit is contained in:
2025-11-13 14:20:12 +08:00
parent cfb9096788
commit 007799fb2a
7 changed files with 402 additions and 527 deletions

View File

@@ -435,6 +435,19 @@ app.whenReady().then(() => {
createWindow();
createTray(mainWindow);
// 开发模式快捷键
if (isDev && mainWindow) {
mainWindow.webContents.on('before-input-event', (event, input) => {
if (input.control && input.shift && input.key.toLowerCase() === 'd') {
console.log('[开发模式] 手动跳过后端启动');
openAppIfNotOpened();
} else if (input.control && input.shift && input.key.toLowerCase() === 's') {
console.log('[开发模式] 手动启动后端服务');
startSpringBoot();
}
});
}
// 只有在不需要最小化启动时才显示 splash 窗口
if (!shouldMinimize) {
const config = loadConfig();
@@ -476,9 +489,22 @@ app.whenReady().then(() => {
}
console.log('[启动流程] 准备启动 Spring Boot...');
setTimeout(() => {
startSpringBoot();
}, 200);
// 开发模式:添加快捷键跳过后端启动
if (isDev) {
console.log('[开发模式] 按 Ctrl+Shift+D 跳过后端启动,直接进入应用');
console.log('[开发模式] 按 Ctrl+Shift+S 手动启动后端服务');
// 5秒后自动跳过避免卡死
setTimeout(() => {
console.log('[开发模式] 自动跳过后端启动,直接进入应用');
openAppIfNotOpened();
}, 5000);
}
// setTimeout(() => {
// startSpringBoot();
// }, 200);
app.on('activate', () => {
if (mainWindow && !mainWindow.isDestroyed()) {
@@ -847,6 +873,26 @@ ipcMain.handle('set-launch-config', (event, launchConfig: { autoLaunch: boolean;
// 刷新页面
ipcMain.handle('reload', () => mainWindow?.webContents.reload());
// 开发模式:跳过后端启动
ipcMain.handle('dev-skip-backend', () => {
if (isDev) {
console.log('[开发模式] 前端请求跳过后端启动');
openAppIfNotOpened();
return { success: true };
}
return { success: false, error: '仅开发模式可用' };
});
// 开发模式:手动启动后端
ipcMain.handle('dev-start-backend', () => {
if (isDev) {
console.log('[开发模式] 前端请求启动后端');
startSpringBoot();
return { success: true };
}
return { success: false, error: '仅开发模式可用' };
});
// 窗口控制 API
ipcMain.handle('window-minimize', () => {
if (mainWindow && !mainWindow.isDestroyed()) {

View File

@@ -116,18 +116,11 @@ function showMessage(message: string, type: 'success' | 'warning' | 'error' | 'i
// 拖拽上传
async function processTrademarkFile(file: File) {
uploadLoading.value = true
try {
// 根据选中的查询类型确定需要的表头
const requiredHeaders: string[] = []
if (queryTypes.value.includes('product')) {
requiredHeaders.push('商品主图')
}
if (queryTypes.value.includes('brand')) {
requiredHeaders.push('品牌')
}
const requiredHeaders = []
if (queryTypes.value.includes('product')) requiredHeaders.push('商品主图')
if (queryTypes.value.includes('brand')) requiredHeaders.push('品牌')
// 验证表头
if (requiredHeaders.length > 0) {
const validateResult = await markApi.validateHeaders(file, requiredHeaders)
if (validateResult.code !== 200 && validateResult.code !== 0) {
@@ -160,15 +153,11 @@ function removeTrademarkFile() {
trademarkFileName.value = ''
trademarkFile.value = null
uploadLoading.value = false
if (trademarkUpload.value) {
trademarkUpload.value.value = ''
}
if (trademarkUpload.value) trademarkUpload.value.value = ''
}
// 保存会话到后端
async function saveSession() {
if (!trademarkData.value.length) return
try {
const sessionData = {
fileName: trademarkFileName.value,
@@ -178,7 +167,6 @@ async function saveSession() {
taskProgress: taskProgress.value,
queryStatus: queryStatus.value
}
const result = await markApi.saveSession(sessionData)
if (result.code === 200 || result.code === 0) {
const username = getUsernameFromToken()
@@ -258,13 +246,10 @@ async function handleTrademarkUpload(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
const ok = /\.xlsx?$/.test(file.name)
if (!ok) {
if (!/\.xlsx?$/.test(file.name)) {
showMessage('仅支持 .xlsx/.xls 文件', 'warning')
return
}
await processTrademarkFile(file)
input.value = ''
}
@@ -274,9 +259,7 @@ async function startTrademarkQuery() {
showMessage('请先导入商标列表', 'warning')
return
}
if (refreshVipStatus) await refreshVipStatus()
if (!props.isVip) {
if (vipStatus) trialExpiredType.value = vipStatus.value.expiredType
showTrialExpiredDialog.value = true
@@ -286,6 +269,7 @@ async function startTrademarkQuery() {
const needProductCheck = queryTypes.value.includes('product')
const needBrandCheck = queryTypes.value.includes('brand')
// 立即显示加载状态,防止重复点击
trademarkLoading.value = true
trademarkProgress.value = 0
trademarkData.value = []
@@ -293,6 +277,9 @@ async function startTrademarkQuery() {
trademarkHeaders.value = []
queryStatus.value = 'inProgress'
// 立即显示"正在准备..."状态
showMessage('正在准备筛查任务...', 'info')
// 重置任务进度
taskProgress.value.product.total = 0
taskProgress.value.product.current = 0
@@ -476,11 +463,16 @@ async function startTrademarkQuery() {
if (brandList.length > 0) {
const brandData = taskProgress.value.brand
brandData.total = brandList.length
brandData.current = 1 // 立即显示初始进度
brandData.current = 0
brandData.completed = 0
// 生成任务ID并轮询真实进度
// 生成任务ID并立即开始轮询
brandTaskId.value = `task_${Date.now()}`
// 立即显示开始状态
showMessage(`开始品牌商标筛查,共${brandList.length}个品牌`, 'success')
// 立即开始进度轮询
brandProgressTimer = setInterval(async () => {
try {
const res = await markApi.getBrandCheckProgress(brandTaskId.value)
@@ -490,72 +482,69 @@ async function startTrademarkQuery() {
} catch (e) {
// 忽略进度查询错误
}
}, 1000)
}, 500)
const brandResult = await markApi.brandCheck(brandList, brandTaskId.value)
if (brandProgressTimer) clearInterval(brandProgressTimer)
if (brandResult.code === 200 || brandResult.code === 0) {
// 完成显示100%
if ( brandResult.code === 0) {
brandData.total = brandResult.data.checked || brandResult.data.total || brandData.total
brandData.current = brandData.total
brandData.completed = brandResult.data.unregistered || 0
isBrandTaskRealData.value = true
// 提取未注册品牌列表
const unregisteredBrands = brandResult.data.data.map((item: any) => item.brand).filter(Boolean)
if (unregisteredBrands.length > 0) {
// 从原始Excel中过滤出包含这些品牌的完整行
const filterResult = await markApi.filterByBrands(trademarkFile.value, unregisteredBrands)
if (filterResult.code === 200 || filterResult.code === 0) {
// 保存完整数据(用于导出,使用原始表头)
trademarkFullData.value = filterResult.data.filteredRows
trademarkHeaders.value = filterResult.data.headers || []
// 更新统计:显示过滤出的实际行数(而不是品牌数)
brandData.completed = filterResult.data.filteredRows.length
isBrandTaskRealData.value = true
// 将品牌筛查结果作为展示数据
const brandItems = filterResult.data.filteredRows.map((row: any) => ({
name: row['品牌'] || '',
status: '未注册',
class: '',
owner: '',
expireDate: row['注册时间'] || '',
similarity: 0,
asin: row['ASIN'] || '',
productImage: row['商品主图'] || '',
isBrand: true // 标记为品牌数据
}))
// 如果有产品筛查,也替换展示数据(只显示品牌筛查结果)
if (needProductCheck) {
trademarkData.value = brandItems
} else {
trademarkData.value = [...trademarkData.value, ...brandItems]
}
}
}
await processBrandResult(brandResult)
} else {
throw new Error(brandResult.msg || '品牌筛查失败')
}
}
}
// 只要流程正常完成就设置为done状态不再依赖trademarkLoading
queryStatus.value = 'done'
emit('updateData', trademarkData.value)
// 处理品牌查询结果的函数
async function processBrandResult(brandResult: any) {
// 提取未注册品牌列表
const unregisteredBrands = brandResult.data.data.map((item: any) => item.brand).filter(Boolean)
let summaryMsg = '筛查完成'
if (needProductCheck) summaryMsg += `,产品:${taskProgress.value.product.completed}/${taskProgress.value.product.total}`
if (needBrandCheck && brandList.length > 0) summaryMsg += `,品牌:${taskProgress.value.brand.completed}/${taskProgress.value.brand.total}`
showMessage(summaryMsg, 'success')
if (unregisteredBrands.length > 0) {
// 从原始Excel中过滤出包含这些品牌的完整行
const filterResult = await markApi.filterByBrands(trademarkFile.value, unregisteredBrands)
// 保存会话
await saveSession()
// 保存完整数据(用于导出,使用原始表头)
trademarkFullData.value = filterResult.data.filteredRows
trademarkHeaders.value = filterResult.data.headers || []
// 更新统计:显示过滤出的实际行数(而不是品牌数)
const brandData = taskProgress.value.brand
brandData.completed = filterResult.data.filteredRows.length
isBrandTaskRealData.value = true
// 将品牌筛查结果作为展示数据
const brandItems = filterResult.data.filteredRows.map((row: any) => ({
name: row['品牌'] || '',
status: '未注册',
class: '',
owner: '',
expireDate: row['注册时间'] || '',
similarity: 0,
asin: row['ASIN'] || '',
productImage: row['商品主图'] || '',
isBrand: true // 标记为品牌数据
}))
// 更新展示数据
trademarkData.value = [...trademarkData.value, ...brandItems]
}
}
// 只要流程正常完成就设置为done状态
queryStatus.value = 'done'
emit('updateData', trademarkData.value)
let summaryMsg = '筛查完成'
if (needProductCheck) summaryMsg += `,产品:${taskProgress.value.product.completed}/${taskProgress.value.product.total}`
if (needBrandCheck && brandList.length > 0) summaryMsg += `,品牌:${taskProgress.value.brand.completed}/${taskProgress.value.brand.total}`
showMessage(summaryMsg, 'success')
// 保存会话
await saveSession()
} catch (error: any) {
const hasProductData = isProductTaskRealData.value && taskProgress.value.product.total > 0
const hasBrandData = isBrandTaskRealData.value && taskProgress.value.brand.total > 0
@@ -715,27 +704,15 @@ function resetToIdle() {
trademarkHeaders.value = []
trademarkFileName.value = ''
trademarkFile.value = null
taskProgress.value.product.total = 0
taskProgress.value.product.current = 0
taskProgress.value.product.completed = 0
taskProgress.value.brand.total = 0
taskProgress.value.brand.current = 0
taskProgress.value.brand.completed = 0
taskProgress.value.platform.total = 0
taskProgress.value.platform.current = 0
taskProgress.value.platform.completed = 0
// 重置真实数据标记
Object.assign(taskProgress.value.product, { total: 0, current: 0, completed: 0 })
Object.assign(taskProgress.value.brand, { total: 0, current: 0, completed: 0 })
Object.assign(taskProgress.value.platform, { total: 0, current: 0, completed: 0 })
isProductTaskRealData.value = false
isBrandTaskRealData.value = false
// 清空localStorage中的会话数据
try {
const username = getUsernameFromToken()
localStorage.removeItem(`trademark_session_${username}`)
} catch (e) {
// 忽略错误
}
} catch (e) {}
}
defineExpose({

View File

@@ -61,9 +61,10 @@ public class TrademarkController {
String taskId = (String) request.get("taskId");
try {
List<String> list = brands.stream()
.filter(b -> b != null && !b.trim().isEmpty())
.filter(b -> !b.trim().isEmpty())
.map(String::trim)
.collect(Collectors.toList());
long start = System.currentTimeMillis();
// 1. 先从全局缓存获取
Map<String, Boolean> cached = cacheService.getCached(list);
@@ -74,8 +75,7 @@ public class TrademarkController {
Map<String, Boolean> queried = new HashMap<>();
if (!toQuery.isEmpty()) {
for (int i = 0; i < toQuery.size(); i++) {
// 检查任务是否被取消值
if (taskId != null && cancelMap.getOrDefault(taskId, false)) {
if (cancelMap.getOrDefault(taskId, false)) {
logger.info("任务 {} 已被取消,停止查询", taskId);
break;
}
@@ -86,15 +86,10 @@ public class TrademarkController {
Map<String, Boolean> results = util.batchCheck(Collections.singletonList(brand), queried);
queried.putAll(results);
// 更新进度
if (taskId != null) {
progressMap.put(taskId, cached.size() + queried.size());
}
if (taskId != null) progressMap.put(taskId, cached.size() + queried.size());
}
// 查询结束,保存所有品牌
if (!queried.isEmpty())
cacheService.saveResults(queried);
if (!queried.isEmpty()) cacheService.saveResults(queried);
}
// 5. 合并缓存和新查询结果
@@ -132,16 +127,11 @@ 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);
try { Thread.sleep(30000); } catch (InterruptedException ignored) {}
progressMap.remove(taskId);
cancelMap.remove(taskId);
}).start();
}
@@ -150,7 +140,7 @@ public class TrademarkController {
logger.error("筛查失败", e);
return JsonData.buildError("筛查失败: " + e.getMessage());
} finally {
util.closeDriver();
if (util != null && util.driver != null) util.driver.quit();
cacheService.cleanExpired();
}
}

View File

@@ -23,25 +23,18 @@ public class BrandTrademarkCacheServiceImpl implements BrandTrademarkCacheServic
public Map<String, Boolean> getCached(List<String> brands) {
LocalDateTime cutoffTime = LocalDateTime.now().minusDays(1);
List<BrandTrademarkCacheEntity> cached = repository.findByBrandInAndCreatedAtAfter(brands, cutoffTime);
Map<String, Boolean> 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<String, Boolean> results) {
results.forEach((brand, registered) -> {
if (!repository.existsByBrand(brand)) {
BrandTrademarkCacheEntity entity = new BrandTrademarkCacheEntity();
entity.setBrand(brand);
entity.setRegistered(registered);
repository.save(entity);
}
BrandTrademarkCacheEntity entity = new BrandTrademarkCacheEntity();
entity.setBrand(brand);
entity.setRegistered(registered);
repository.save(entity);
});
}
@@ -50,7 +43,6 @@ public class BrandTrademarkCacheServiceImpl implements BrandTrademarkCacheServic
public void cleanExpired() {
LocalDateTime cutoffTime = LocalDateTime.now().minusDays(1);
repository.deleteByCreatedAtBefore(cutoffTime);
log.info("清理1天前的品牌商标缓存");
}
}

View File

@@ -2,6 +2,12 @@ package com.tashow.erp.test;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.tashow.erp.ErpClientSbApplication;
import com.tashow.erp.utils.TrademarkCheckUtil;
import org.openqa.selenium.JavascriptExecutor;
import org.openqa.selenium.chrome.ChromeDriver;
import org.springframework.boot.SpringApplication;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.http.*;
import org.springframework.web.client.RestTemplate;
@@ -15,145 +21,211 @@ public class UsptoApiTest {
private static final ObjectMapper mapper = new ObjectMapper();
public static void main(String[] args) {
System.out.println("=== USPTO API 功能测试 ===\n");
System.out.println("API Key: " + API_KEY + "\n");
// 测试1通过序列号查询已知可用
testBySerialNumber();
System.out.println("\n" + "=".repeat(60) + "\n");
// 测试2尝试通过品牌名称搜索
testByBrandName();
System.out.println("\n" + "=".repeat(60) + "\n");
// 测试3对比当前实现的tmsearch
testCurrentImplementation();
}
/**
* 测试1通过序列号查询官方TSDR API
*/
private static void testBySerialNumber() {
System.out.println("【测试1】通过序列号查询");
System.out.println("端点: https://tsdrapi.uspto.gov/ts/cd/casestatus/sn88123456/info.json");
System.out.println("=== 商标查询测试使用Spring容器 ===\n");
// 启动Spring Boot应用上下文
ConfigurableApplicationContext context = null;
try {
HttpHeaders headers = new HttpHeaders();
headers.set("USPTO-API-KEY", API_KEY);
HttpEntity<String> entity = new HttpEntity<>(headers);
System.out.println("正在启动Spring Boot应用...");
context = SpringApplication.run(ErpClientSbApplication.class, args);
System.out.println("Spring Boot应用启动成功\n");
ResponseEntity<String> response = rest.exchange(
"https://tsdrapi.uspto.gov/ts/cd/casestatus/sn88123456/info.json",
HttpMethod.GET, entity, String.class
);
// 获取TrademarkCheckUtil Bean
TrademarkCheckUtil trademarkUtil = context.getBean(TrademarkCheckUtil.class);
JsonNode json = mapper.readTree(response.getBody());
String markElement = json.get("trademarks").get(0).get("status").get("markElement").asText();
int status = json.get("trademarks").get(0).get("status").get("status").asInt();
String regNumber = json.get("trademarks").get(0).get("status").get("usRegistrationNumber").asText();
// 测试单品牌查询(获取详细结果)
testSingleBrandWithDetailedResults("Remorlet");
System.out.println("✓ 成功!");
System.out.println(" 商标名称: " + markElement);
System.out.println(" 状态码: " + status);
System.out.println(" 注册号: " + (regNumber.isEmpty() ? "未注册" : regNumber));
System.out.println("\n结论: ✅ 可以查询,但必须知道序列号");
} catch (Exception e) {
System.out.println("失败: " + e.getMessage());
System.err.println("测试失败: " + e.getMessage());
e.printStackTrace();
} finally {
}
}
/**
* 测试2尝试通过品牌名称搜索
* 标准化品牌名称用于比较(移除特殊字符,转换为小写)
*/
private static void testByBrandName() {
System.out.println("【测试2】尝试通过品牌名称搜索");
private static String normalizeBrandName(String name) {
if (name == null) return "";
return name.toLowerCase()
.replaceAll("[\\s\\-_\\.]", "") // 移除空格、连字符、下划线、点号
.replaceAll("[^a-z0-9]", ""); // 只保留字母和数字
}
String[] brands = {"Nike", "MYLIFE", "TestBrand123"};
String[] searchUrls = {
"https://tsdrapi.uspto.gov/ts/cd/search?q=%s",
"https://tsdrapi.uspto.gov/search?keyword=%s",
"https://api.uspto.gov/trademark/search?q=%s",
"https://api.uspto.gov/tmsearch/search?q=%s",
};
/**
* 直接执行JavaScript获取详细结果
*
* USPTO API返回格式说明:
* {
* "hits": {
* "hits": [
* {
* "id": "商标ID",
* "score": 匹配分数,
* "source": {
* "alive": true/false, // 关键字段:是否有效注册
* "wordmark": "品牌名", // 商标名称
* "statusCode": 状态码, // 600=已放弃, 700=已注册
* "statusDescription": "状态描述", // ABANDONED/REGISTERED
* "registrationId": "注册号", // 注册号(如果已注册)
* "registrationDate": "注册日期",
* "abandonDate": "放弃日期",
* "filedDate": "申请日期",
* "goodsAndServices": ["商品服务类别"],
* "internationalClass": ["IC 018"], // 国际分类
* "ownerName": ["所有者名称"],
* "attorney": "代理律师"
* }
* }
* ],
* "totalValue": 总匹配数
* }
* }
*
* 判定逻辑: 只要有任何一个记录的 alive=true就判定为已注册(true)
*/
private static void testSingleBrandWithDetailedResults(String brandName) {
System.out.println("【单品牌测试】直接执行JavaScript获取详细结果");
System.out.println("品牌: " + brandName);
boolean foundSearchApi = false;
ChromeDriver driver = null;
try {
System.out.println("=== 开始测试品牌: " + brandName + " ===");
for (String brand : brands) {
System.out.println("\n测试品牌: " + brand);
// 初始化Chrome驱动
System.out.println("正在初始化Chrome驱动...");
driver = com.tashow.erp.utils.SeleniumUtil.createDriver(false, null);
driver.get("https://tmsearch.uspto.gov/search/search-results");
Thread.sleep(6000); // 等待页面加载
System.out.println("Chrome驱动初始化完成");
for (String urlTemplate : searchUrls) {
String url = String.format(urlTemplate, brand);
System.out.println(" 尝试: " + url);
long startTime = System.currentTimeMillis();
try {
HttpHeaders headers = new HttpHeaders();
headers.set("USPTO-API-KEY", API_KEY);
HttpEntity<String> entity = new HttpEntity<>(headers);
// 构建JavaScript脚本 - 获取所有结果
String script = "const brands = ['" + brandName.replace("'", "\\'") + "'];\n" +
"const callback = arguments[arguments.length - 1];\n" +
"Promise.all(brands.map(b => \n" +
" fetch('https://tmsearch.uspto.gov/prod-stage-v1-0-0/tmsearch', {\n" +
" method: 'POST',\n" +
" headers: {'Content-Type': 'application/json'},\n" +
" body: JSON.stringify({\n" +
" query: {bool: {must: [{bool: {should: [\n" +
" {match_phrase: {WM: {query: b, boost: 5}}},\n" +
" {match: {WM: {query: b, boost: 2}}},\n" +
" {match_phrase: {PM: {query: b, boost: 2}}}\n" +
" ]}}]}},\n" +
" size: 100\n" +
" })\n" +
" })\n" +
" .then(r => {\n" +
" if (!r.ok) {\n" +
" return {brand: b, error: 'HTTP ' + r.status + ': ' + r.statusText, allResults: []};\n" +
" }\n" +
" return r.json().then(d => {\n" +
" console.log('API Response:', d);\n" +
" return {\n" +
" brand: b,\n" +
" error: null,\n" +
" totalHits: d?.hits?.total?.value || d?.hits?.totalValue || 0,\n" +
" allResults: d?.hits?.hits || [],\n" +
" rawData: d\n" +
" };\n" +
" });\n" +
" })\n" +
" .catch(e => ({\n" +
" brand: b,\n" +
" error: e.name + ': ' + e.message,\n" +
" allResults: []\n" +
" }))\n" +
")).then(callback);";
ResponseEntity<String> response = rest.exchange(url, HttpMethod.GET, entity, String.class);
System.out.println("正在执行JavaScript脚本...");
System.out.println(" ✓✓✓ 成功! 找到品牌搜索API!");
System.out.println(" 响应: " + response.getBody().substring(0, Math.min(200, response.getBody().length())));
foundSearchApi = true;
break;
} catch (Exception e) {
System.out.println(" ✗ 404/失败");
// 执行JavaScript脚本
@SuppressWarnings("unchecked")
java.util.List<java.util.Map<String, Object>> results =
(java.util.List<java.util.Map<String, Object>>)
((JavascriptExecutor) driver).executeAsyncScript(script);
long duration = System.currentTimeMillis() - startTime;
// 极简输出 - 只返回 true/false 判定结果
boolean isRegistered = false;
if (results != null && !results.isEmpty()) {
java.util.Map<String, Object> result = results.get(0);
// 获取所有匹配的结果
@SuppressWarnings("unchecked")
java.util.List<java.util.Map<String, Object>> allResults =
(java.util.List<java.util.Map<String, Object>>) result.get("allResults");
// 如果allResults为空从rawData中获取
if ((allResults == null || allResults.isEmpty()) && result.get("rawData") != null) {
@SuppressWarnings("unchecked")
java.util.Map<String, Object> rawData = (java.util.Map<String, Object>) result.get("rawData");
@SuppressWarnings("unchecked")
java.util.Map<String, Object> hits = (java.util.Map<String, Object>) rawData.get("hits");
if (hits != null) {
@SuppressWarnings("unchecked")
java.util.List<java.util.Map<String, Object>> hitsArray =
(java.util.List<java.util.Map<String, Object>>) hits.get("hits");
if (hitsArray != null) {
allResults = hitsArray;
}
}
}
// 核心判定逻辑:品牌名称匹配 且 statusCode为686/700 才判定为已注册
String normalizedInputBrand = normalizeBrandName(brandName);
if (allResults != null && !allResults.isEmpty()) {
for (java.util.Map<String, Object> hit : allResults) {
@SuppressWarnings("unchecked")
java.util.Map<String, Object> source = (java.util.Map<String, Object>) hit.get("_source");
if (source == null) {
source = (java.util.Map<String, Object>) hit.get("source");
}
if (source != null) {
String wordmark = (String) source.get("wordmark");
String normalizedWordmark = normalizeBrandName(wordmark);
// 首先检查品牌名称是否匹配
if (normalizedInputBrand.equals(normalizedWordmark)) {
Number statusCodeNum = (Number) source.get("statusCode");
// 只有statusCode为688或700才返回true
if (statusCodeNum != null && (statusCodeNum.intValue() == 688 || statusCodeNum.intValue() == 700)) {
isRegistered = true;
break; // 找到一个符合条件的就够了
}
}
}
}
}
}
if (foundSearchApi) break;
}
// 极简输出 - 只显示最终结果
System.out.println(isRegistered);
if (!foundSearchApi) {
System.out.println("\n结论: ❌ USPTO官方API不支持品牌名称搜索");
}
}
/**
* 测试3当前实现的tmsearch方式
*/
private static void testCurrentImplementation() {
System.out.println("【测试3】当前实现方式tmsearch内部API");
System.out.println("端点: https://tmsearch.uspto.gov/prod-stage-v1-0-0/tmsearch");
String brand = "Nike";
String requestBody = String.format(
"{\"query\":{\"bool\":{\"must\":[{\"bool\":{\"should\":[" +
"{\"match_phrase\":{\"WM\":{\"query\":\"%s\",\"boost\":5}}}," +
"{\"match\":{\"WM\":{\"query\":\"%s\",\"boost\":2}}}," +
"{\"match_phrase\":{\"PM\":{\"query\":\"%s\",\"boost\":2}}}" +
"]}}]}},\"size\":1,\"_source\":[\"alive\"]}",
brand, brand, brand
);
try {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<String> entity = new HttpEntity<>(requestBody, headers);
ResponseEntity<String> response = rest.postForEntity(
"https://tmsearch.uspto.gov/prod-stage-v1-0-0/tmsearch",
entity, String.class
);
JsonNode json = mapper.readTree(response.getBody());
boolean hasResults = json.get("hits").get("hits").size() > 0;
System.out.println("✓ 成功!");
System.out.println(" 品牌: " + brand);
System.out.println(" 找到结果: " + (hasResults ? "" : ""));
if (hasResults) {
boolean alive = json.get("hits").get("hits").get(0).get("_source").get("alive").asBoolean();
System.out.println(" 是否注册: " + (alive ? "已注册" : "未注册"));
}
System.out.println("\n结论: ✅ 支持品牌名称直接搜索(无需序列号)");
System.out.println(" ⚠️ 但这是内部API非官方公开接口");
} catch (Exception e) {
System.out.println("✗ 失败: " + e.getMessage());
System.err.println("=== 测试失败 ===");
System.err.println("品牌: " + brandName);
System.err.println("错误: " + e.getMessage());
System.err.println("================");
e.printStackTrace();
} finally {
// 确保清理资源
if (driver != null) {
try {
driver.quit();
} catch (Exception e) {
System.err.println("清理Chrome驱动时出错: " + e.getMessage());
}
}
}
}
}

View File

@@ -11,19 +11,10 @@ import java.util.stream.Collectors;
/**
* Excel 解析工具类
* 统一处理各种平台的 Excel 文件解析需求
*
* @author Claude Code
*/
@Slf4j
public class ExcelParseUtil {
/**
* 自动查找表头行索引在前2行中查找
* @param rows Excel所有行
* @param columnName 列名(如"品牌"
* @return 表头行索引,未找到返回-1
*/
private static int findHeaderRow(List<List<Object>> rows, String columnName) {
for (int r = 0; r < Math.min(2, rows.size()); r++) {
for (Object cell : rows.get(r)) {
@@ -35,92 +26,37 @@ public class ExcelParseUtil {
return -1;
}
/**
* 解析 Excel 文件第一列数据
* 通用方法适用于店铺名、ASIN、订单号等标识符解析
*
* @param file Excel 文件
* @return 解析出的字符串列表,跳过表头,过滤空值
*/
public static List<String> parseFirstColumn(MultipartFile file) {
List<String> result = new ArrayList<>();
if (file == null || file.isEmpty()) {
log.warn("Excel 文件为空");
return result;
}
try (InputStream in = file.getInputStream()) {
ExcelReader reader = ExcelUtil.getReader(in, 0);
List<List<Object>> rows = reader.read();
for (int i = 1; i < rows.size(); i++) { // 从第2行开始跳过表头
List<Object> row = rows.get(i);
if (row != null && !row.isEmpty()) {
Object cell = row.get(0); // 获取第一列
if (cell != null) {
String value = cell.toString().trim();
if (!value.isEmpty()) {
result.add(value);
}
}
}
private static int findColumnIndex(List<Object> headerRow, String columnName) {
for (int c = 0; c < headerRow.size(); c++) {
if (headerRow.get(c) != null && columnName.equals(headerRow.get(c).toString().replaceAll("\\s+", ""))) {
return c;
}
log.info("成功解析 Excel 文件: {}, 共解析出 {} 条数据",
file.getOriginalFilename(), result.size());
} catch (Exception e) {
log.error("解析 Excel 文件失败: {}, 文件名: {}", e.getMessage(),
file.getOriginalFilename(), e);
}
return result;
return -1;
}
public static List<String> parseFirstColumn(MultipartFile file) {
return parseColumn(file, 0);
}
/**
* 解析指定列的数据
*
* @param file Excel 文件
* @param columnIndex 列索引从0开始
* @return 解析出的字符串列表
*/
public static List<String> parseColumn(MultipartFile file, int columnIndex) {
List<String> result = new ArrayList<>();
if (file == null || file.isEmpty()) {
log.warn("Excel 文件为空");
return result;
}
try (InputStream in = file.getInputStream()) {
ExcelReader reader = ExcelUtil.getReader(in, 0);
List<List<Object>> rows = reader.read();
for (int i = 1; i < rows.size(); i++) { // 从第2行开始跳过表头
for (int i = 1; i < rows.size(); i++) {
List<Object> row = rows.get(i);
if (row != null && row.size() > columnIndex) {
Object cell = row.get(columnIndex);
if (cell != null) {
String value = cell.toString().trim();
if (!value.isEmpty()) {
result.add(value);
}
}
if (row.size() > columnIndex && row.get(columnIndex) != null) {
String value = row.get(columnIndex).toString().trim();
if (!value.isEmpty()) result.add(value);
}
}
log.info("成功解析 Excel 文件第{}列: {}, 共解析出 {} 条数据",
columnIndex + 1, file.getOriginalFilename(), result.size());
} catch (Exception e) {
log.error("解析 Excel 文件第{}列失败: {}, 文件名: {}",
columnIndex + 1, e.getMessage(), file.getOriginalFilename(), e);
log.error("解析Excel失败", e);
}
return result;
}
/**
* 根据列名解析数据自动适配第1行或第2行为表头
*/
public static List<String> parseColumnByName(MultipartFile file, String columnName) {
List<String> result = new ArrayList<>();
try (InputStream in = file.getInputStream()) {
@@ -131,14 +67,8 @@ public class ExcelParseUtil {
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;
}
}
int colIdx = findColumnIndex(rows.get(headerRow), columnName);
if (colIdx < 0) return result;
for (int i = headerRow + 1; i < rows.size(); i++) {
List<Object> row = rows.get(i);
@@ -153,11 +83,6 @@ public class ExcelParseUtil {
return result;
}
/**
* 读取Excel的完整数据包含表头和所有行自动适配第1行或第2行为表头
* @param file Excel文件
* @return Map包含headers表头列表和rows数据行列表每行是Map
*/
public static Map<String, Object> parseFullExcel(MultipartFile file) {
Map<String, Object> result = new HashMap<>();
List<String> headers = new ArrayList<>();
@@ -166,17 +91,13 @@ public class ExcelParseUtil {
try (InputStream in = file.getInputStream()) {
ExcelReader reader = ExcelUtil.getReader(in, 0);
List<List<Object>> 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() : "");
}
@@ -192,89 +113,21 @@ public class ExcelParseUtil {
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);
log.error("解析Excel失败", e);
}
return result;
}
/**
* 根据ASIN列表从Excel中过滤完整行数据自动适配第1行或第2行为表头
* @param file Excel文件
* @param asins ASIN列表
* @return Map包含headers表头和filteredRows过滤后的完整行数据
*/
public static Map<String, Object> filterExcelByAsins(MultipartFile file, List<String> asins) {
Map<String, Object> result = new HashMap<>();
List<String> headers = new ArrayList<>();
List<Map<String, Object>> filteredRows = new ArrayList<>();
try (InputStream in = file.getInputStream()) {
ExcelReader reader = ExcelUtil.getReader(in, 0);
List<List<Object>> 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<Object> 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<String> asinSet = asins.stream().map(String::trim).collect(Collectors.toSet());
for (int i = headerRowIndex + 1; i < allRows.size(); i++) {
List<Object> row = allRows.get(i);
if (row.size() > asinColIndex && row.get(asinColIndex) != null
&& asinSet.contains(row.get(asinColIndex).toString().trim())) {
Map<String, Object> 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;
return filterExcelByColumn(file, "ASIN", asins);
}
/**
* 根据品牌列表从Excel中过滤完整行数据自动适配第1行或第2行为表头
* @param file Excel文件
* @param brands 品牌列表
* @return Map包含headers表头和filteredRows过滤后的完整行数据
*/
public static Map<String, Object> filterExcelByBrands(MultipartFile file, List<String> brands) {
return filterExcelByColumn(file, "品牌", brands);
}
private static Map<String, Object> filterExcelByColumn(MultipartFile file, String columnName, List<String> values) {
Map<String, Object> result = new HashMap<>();
List<String> headers = new ArrayList<>();
List<Map<String, Object>> filteredRows = new ArrayList<>();
@@ -282,40 +135,37 @@ public class ExcelParseUtil {
try (InputStream in = file.getInputStream()) {
ExcelReader reader = ExcelUtil.getReader(in, 0);
List<List<Object>> allRows = reader.read();
if (allRows.isEmpty()) {
result.put("headers", headers);
result.put("filteredRows", filteredRows);
return result;
}
int headerRowIndex = findHeaderRow(allRows, "品牌");
int headerRowIndex = findHeaderRow(allRows, columnName);
if (headerRowIndex < 0) {
log.warn("未找到'品牌'列");
result.put("headers", headers);
result.put("filteredRows", filteredRows);
return result;
}
int brandColIndex = -1;
List<Object> 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;
}
int colIndex = findColumnIndex(headerRow, columnName);
if (colIndex < 0) {
result.put("headers", headers);
result.put("filteredRows", filteredRows);
return result;
}
for (Object cell : headerRow) {
headers.add(cell != null ? cell.toString().trim() : "");
}
Set<String> brandSet = brands.stream().map(String::trim).collect(Collectors.toSet());
Set<String> valueSet = values.stream().map(String::trim).collect(Collectors.toSet());
for (int i = headerRowIndex + 1; i < allRows.size(); i++) {
List<Object> row = allRows.get(i);
if (row.size() > brandColIndex && row.get(brandColIndex) != null
&& brandSet.contains(row.get(brandColIndex).toString().trim())) {
if (row.size() > colIndex && row.get(colIndex) != null
&& valueSet.contains(row.get(colIndex).toString().trim())) {
Map<String, Object> rowMap = new HashMap<>();
for (int j = 0; j < Math.min(headers.size(), row.size()); j++) {
rowMap.put(headers.get(j), row.get(j));
@@ -326,12 +176,9 @@ public class ExcelParseUtil {
result.put("headers", headers);
result.put("filteredRows", filteredRows);
log.info("品牌过滤: {}, {}个品牌 -> {}行数据", file.getOriginalFilename(), brands.size(), filteredRows.size());
} catch (Exception e) {
log.error("品牌过滤失败: {}", e.getMessage(), e);
log.error("Excel过滤失败", e);
}
return result;
}
}

View File

@@ -1,10 +1,12 @@
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.*;
/**
@@ -17,133 +19,82 @@ public class TrademarkCheckUtil {
private ProxyPool proxyPool;
@Autowired
private BrandTrademarkCacheService cacheService;
private ChromeDriver driver;
public ChromeDriver driver;
private final int maxRetries = 3;
private String normalize(String name) {
return name.toLowerCase().replaceAll("[^a-z0-9]", "");
}
private synchronized void ensureInit() {
if (driver == null) {
for (int i = 0; i < 5; i++) {
try {
driver = SeleniumUtil.createDriver(true, proxyPool.getProxy());
driver.get("https://tmsearch.uspto.gov/search/search-results");
Thread.sleep(6000);
return; // 成功则返回
} catch (Exception e) {
System.err.println("初始化失败(尝试" + (i+1) + "/5: " + e.getMessage());
if (driver != null) {
try { driver.quit(); } catch (Exception ex) {}
driver = null;
}
if (i == 2) throw new RuntimeException("初始化失败已重试3次", e);
}
}
driver = SeleniumUtil.createDriver(true, proxyPool.getProxy());
driver.get("https://tmsearch.uspto.gov/search/search-results");
try { Thread.sleep(6000); } catch (InterruptedException ignored) {}
}
}
public synchronized Map<String, Boolean> batchCheck(List<String> brands, Map<String, Boolean> alreadyQueried) {
Map<String, Boolean> resultMap = new HashMap<>();
int maxRetries = 5;
for (String brand : brands) {
int retryCount = 0;
boolean success = false;
while (retryCount < maxRetries && !success) {
while (retryCount < 5 && !success) {
try {
ensureInit();
String script = "fetch('https://tmsearch.uspto.gov/prod-stage-v1-0-0/tmsearch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({query:{bool:{must:[{bool:{should:[{match_phrase:{WM:{query:'" + brand.replace("'", "\\'")+"',boost:5}}}]}}]}},size:100})}).then(r=>{if(!r.ok){return arguments[arguments.length-1]({hits:[],error:'HTTP '+r.status+': '+r.statusText});}return r.text().then(text=>{if(text.startsWith('<!DOCTYPE')||text.startsWith('<html')){return arguments[arguments.length-1]({hits:[],error:'HTML response detected, likely blocked'});}try{const d=JSON.parse(text);return arguments[arguments.length-1]({hits:d?.hits?.hits||[],error:null});}catch(e){return arguments[arguments.length-1]({hits:[],error:'JSON parse error: '+e.message});}});}).catch(e=>arguments[arguments.length-1]({hits:[],error:e.message}));";
@SuppressWarnings("unchecked") Map<String, Object> result = (Map<String, Object>) ((JavascriptExecutor) driver).executeAsyncScript(script);
String error = (String) result.get("error");
String script = "const brands = ['" + brand.replace("'", "\\'") + "'];\n" +
"const callback = arguments[arguments.length - 1];\n" +
"Promise.all(brands.map(b => \n" +
" fetch('https://tmsearch.uspto.gov/prod-stage-v1-0-0/tmsearch', {\n" +
" method: 'POST',\n" +
" headers: {'Content-Type': 'application/json'},\n" +
" body: JSON.stringify({\n" +
" query: {bool: {must: [{bool: {should: [\n" +
" {match_phrase: {WM: {query: b, boost: 5}}},\n" +
" {match: {WM: {query: b, boost: 2}}},\n" +
" {match_phrase: {PM: {query: b, boost: 2}}}\n" +
" ]}}]}},\n" +
" size: 1, _source: ['alive']\n" +
" })\n" +
" })\n" +
" .then(r => {\n" +
" if (!r.ok) {\n" +
" return {brand: b, alive: false, error: 'HTTP ' + r.status + ': ' + r.statusText};\n" +
" }\n" +
" return r.json().then(d => ({\n" +
" brand: b,\n" +
" alive: d?.hits?.hits?.[0]?.source?.alive || false,\n" +
" error: null\n" +
" }));\n" +
" })\n" +
" .catch(e => ({\n" +
" brand: b,\n" +
" alive: false,\n" +
" error: e.name + ': ' + e.message\n" +
" }))\n" +
")).then(callback);";
@SuppressWarnings("unchecked")
List<Map<String, Object>> results = (List<Map<String, Object>>)
((JavascriptExecutor) driver).executeAsyncScript(script);
Map<String, Object> item = results.get(0);
String error = (String) item.get("error");
if (error != null && (
error.contains("HTTP 403") ||
error.contains("Failed to fetch") ||
error.contains("NetworkError") ||
error.contains("TypeError") ||
error.contains("script timeout"))) {
System.err.println(brand + " 查询失败(" + (retryCount + 1) + "/" + maxRetries + "): " + error + ",切换代理...");
// 切换代理
try { driver.quit(); } catch (Exception e) {}
if (error != null && (error.contains("HTTP 403") || error.contains("Failed to fetch") || error.contains("NetworkError") || error.contains("TypeError") || error.contains("script timeout"))) {
System.err.println(brand + " 查询失败(" + (retryCount + 1) + "/3): " + error + ",切换代理...");
if (driver != null) driver.quit();
driver = null;
retryCount++;
continue;
}
// 成功或非网络错误
if (error == null) {
Boolean alive = (Boolean) item.get("alive");
resultMap.put(brand, alive);
System.out.println(brand + " -> " + (alive ? "✓ 已注册" : "✗ 未注册"));
@SuppressWarnings("unchecked") List<Map<String, Object>> hits = (List<Map<String, Object>>) result.get("hits");
String input = normalize(brand);
boolean registered = false;
for (Map<String, Object> hit : hits) {
@SuppressWarnings("unchecked") Map<String, Object> source = (Map<String, Object>) hit.get("source");
if (source != null && input.equals(normalize((String) source.get("wordmark")))) {
Number code = (Number) source.get("statusCode");
if (code != null && (code.intValue() == 688 || code.intValue() == 700)) {
registered = true;
break;
}
}
}
resultMap.put(brand, registered);
System.out.println(brand + " -> " + (registered ? "" : ""));
success = true;
} else {
System.err.println(brand + " -> [查询失败: " + error + "]");
resultMap.put(brand, false); // 失败也记录为未注册
resultMap.put(brand, true);
success = true;
}
} catch (Exception e) {
System.err.println(brand + " 查询异常(" + (retryCount + 1) + "/" + maxRetries + "): " + e.getMessage());
try { driver.quit(); } catch (Exception ex) {}
System.err.println(brand + " 查询异常(" + (retryCount + 1) + "/3): " + e.getMessage());
if (driver != null) driver.quit();
driver = null;
retryCount++;
}
}
if (!success) {
System.err.println(brand + " -> [查询失败: 已重试" + maxRetries + "次]");
resultMap.put(brand, false); // 失败也记录为未注册
System.err.println(brand + " -> [查询失败: 已重试3次]");
resultMap.put(brand, true);
}
}
return resultMap;
}
public synchronized void closeDriver() {
if (driver != null) {
try { driver.quit(); } catch (Exception e) {}
driver = null;
}
}
@PreDestroy
public void cleanup() {
closeDriver();
if (driver != null) driver.quit();
}
}