This commit is contained in:
2025-09-25 16:05:29 +08:00
parent bb997857fd
commit 5e876b0f1d
17 changed files with 1001 additions and 632 deletions

View File

@@ -25,6 +25,8 @@ import java.time.ZoneOffset;
import java.util.*;
import java.util.stream.Collectors;
import java.util.Base64;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@RestController
@RequestMapping("/api/rakuten")
@@ -40,24 +42,21 @@ public class RakutenController {
private JavaBridge javaBridge;
@Autowired
private DataReportUtil dataReportUtil;
/**
* 获取乐天商品数据(支持单个店铺名或 Excel 文件上传)
* 获取乐天商品数据
*
* @param file 可选,Excel 文件(首列为店铺名)
* @param shopName 可选,单个店铺名
* @param batchId 可选,批次号
* @param file Excel文件首列为店铺名
* @param batchId 可选,批次号
* @return JsonData 响应
*/
@PostMapping(value = "/products")
public JsonData getProducts(@RequestParam(value = "file", required = false) MultipartFile file, @RequestParam(value = "shopName", required = false) String shopName, @RequestParam(value = "batchId", required = false) String batchId) {
public JsonData getProducts(@RequestParam("file") MultipartFile file, @RequestParam(value = "batchId", required = false) String batchId) {
try {
// 1. 获取店铺名集合(优先 shopName其次 Excel
List<String> shopNames = Optional.ofNullable(shopName).filter(s -> !s.trim().isEmpty()).map(s -> List.of(s.trim())).orElseGet(() -> file != null ? ExcelParseUtil.parseFirstColumn(file) : new ArrayList<>());
List<String> shopNames = ExcelParseUtil.parseFirstColumn(file);
if (CollectionUtils.isEmpty(shopNames)) {
return JsonData.buildError("未从 Excel解析到店铺名,且 shopName 参数为空");
return JsonData.buildError("Excel文件中未解析到店铺名");
}
List<RakutenProduct> allProducts = new ArrayList<>();
List<String> skippedShops = new ArrayList<>();
@@ -89,10 +88,7 @@ public class RakutenController {
dataReportUtil.reportDataCollection("RAKUTEN_CACHE", cachedCount, "0");
}
// 5. 如果是单店铺查询,只返回该店铺的商品
List<RakutenProduct> finalProducts = (shopName != null && !shopName.trim().isEmpty()) ? allProducts.stream().filter(p -> shopName.trim().equals(p.getOriginalShopName())).toList() : allProducts;
return JsonData.buildSuccess(Map.of("products", finalProducts, "total", finalProducts.size(), "sessionId", batchId, "skippedShops", skippedShops, "newProductsCount", newProducts.size()));
return JsonData.buildSuccess(Map.of("products", allProducts, "total", allProducts.size(), "sessionId", batchId, "skippedShops", skippedShops, "newProductsCount", newProducts.size()));
} catch (Exception e) {
log.error("获取乐天商品失败", e);
return JsonData.buildError("获取乐天商品失败: " + e.getMessage());
@@ -129,6 +125,7 @@ public class RakutenController {
return JsonData.buildError("获取最新数据失败: " + e.getMessage());
}
}
@PostMapping("/export-and-save")
public JsonData exportAndSave(@RequestBody Map<String, Object> body) {
try {
@@ -138,8 +135,52 @@ public class RakutenController {
boolean skipImages = Optional.ofNullable((Boolean) body.get("skipImages")).orElse(false);
String fileName = Optional.ofNullable((String) body.get("fileName")).filter(name -> !name.trim().isEmpty()).orElse("乐天商品数据_" + java.time.LocalDate.now() + ".xlsx");
String[] headers = {"店铺名", "商品图片", "商品链接", "排名", "商品标题", "价格", "1688识图链接", "1688价格", "1688重量"};
byte[] excelData = com.tashow.erp.utils.ExcelExportUtil.createExcelWithImages("乐天商品数据", headers, products, skipImages ? -1 : 1, skipImages ? null : "imgUrl");
// 构建与前端表格一致的列顺序与字段
String[] headers = {
"店铺名",
"商品链接",
"商品图片",
"排名",
"商品标题",
"价格",
"1688识图链接",
"1688运费",
"1688中位价",
"1688最低价",
"1688中间价",
"1688最高价"
};
List<Map<String, Object>> rows = new ArrayList<>();
for (Map<String, Object> p : products) {
LinkedHashMap<String, Object> row = new LinkedHashMap<>();
List<Double> priceList = parseSkuPriceList(p.get("skuPriceJson"), p.get("skuPrice"));
Double minPrice = priceList.isEmpty() ? null : priceList.get(0);
Double midPrice = priceList.isEmpty() ? null : priceList.get(priceList.size() / 2);
Double maxPrice = priceList.isEmpty() ? null : priceList.get(priceList.size() - 1);
row.put("店铺名", p.get("originalShopName"));
row.put("商品链接", p.get("productUrl"));
row.put("商品图片", p.get("imgUrl"));
row.put("排名", p.get("ranking"));
row.put("商品标题", p.get("productTitle"));
row.put("价格", p.get("price"));
row.put("1688识图链接", p.get("mapRecognitionLink"));
row.put("1688运费", p.get("freight"));
row.put("1688中位价", p.get("median"));
row.put("1688最低价", minPrice);
row.put("1688中间价", midPrice);
row.put("1688最高价", maxPrice);
rows.add(row);
}
byte[] excelData = com.tashow.erp.utils.ExcelExportUtil.createExcelWithImages(
"乐天商品数据",
headers,
rows,
skipImages ? -1 : 1,
skipImages ? null : "商品图片"
);
if (excelData == null || excelData.length == 0) return JsonData.buildError("生成Excel失败");
@@ -155,5 +196,24 @@ public class RakutenController {
}
}
// 解析 skuPriceJson 或 skuPrice 字段中的价格键,返回从小到大排序的价格列表
private static List<Double> parseSkuPriceList(Object skuPriceJson, Object skuPrice) {
String src = skuPriceJson != null ? String.valueOf(skuPriceJson) : (skuPrice != null ? String.valueOf(skuPrice) : null);
if (src == null || src.isEmpty()) return Collections.emptyList();
try {
Pattern pattern = Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*:");
Matcher m = pattern.matcher(src);
List<Double> prices = new ArrayList<>();
while (m.find()) {
String num = m.group(1);
try { prices.add(Double.parseDouble(num)); } catch (NumberFormatException ignored) {}
}
Collections.sort(prices);
return prices;
} catch (Exception ignored) {
return Collections.emptyList();
}
}
}

View File

@@ -1,23 +1,23 @@
package com.tashow.erp.service;
import com.tashow.erp.entity.AmazonProductEntity;
import java.util.List;
import java.util.Map;
/**
* 亚马逊数据采集服务接口
*
*
* @author ruoyi
*/
public interface IAmazonScrapingService {
/**
* 批量获取亚马逊产品信息
*
*
* @param asinList ASIN列表
* @param batchId 批次ID
* @return 产品信息列表
*/
Map<String, Object> batchGetProductInfo(List<String> asinList, String batchId);
List<AmazonProductEntity> batchGetProductInfo(List<String> asinList, String batchId);

View File

@@ -10,16 +10,13 @@ import java.util.Map;
*/
public interface IBanmaOrderService {
/**
* 刷新认证Token
*/
void refreshToken();
// 客户端不再暴露刷新认证Token
/**
* 获取店铺列表
* @return 店铺列表数据
*/
Map<String, Object> getShops();
Map<String, Object> getShops(Long accountId);
/**
* 分页获取订单数据支持batchId
@@ -31,5 +28,5 @@ public interface IBanmaOrderService {
* @param shopIds 店铺ID列表
* @return 订单数据列表和总数
*/
Map<String, Object> getOrdersByPage(String startDate, String endDate, int page, int pageSize, String batchId, List<String> shopIds);
Map<String, Object> getOrdersByPage(Long accountId, String startDate, String endDate, int page, int pageSize, String batchId, List<String> shopIds);
}

View File

@@ -1,11 +1,11 @@
package com.tashow.erp.service.impl;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.tashow.erp.entity.BanmaOrderEntity;
import com.tashow.erp.repository.BanmaOrderRepository;
import com.tashow.erp.service.ICacheService;
import com.tashow.erp.service.IBanmaOrderService;
import com.tashow.erp.utils.DataReportUtil;
import com.tashow.erp.utils.ErrorReporter;
import com.tashow.erp.utils.LoggerUtil;
import com.tashow.erp.utils.SagawaExpressSdk;
import com.tashow.erp.utils.StringUtils;
@@ -14,15 +14,13 @@ import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
/**
* 斑马订单服务实现类 - 极简版
* 所有功能统一到核心方法,彻底消除代码分散
* 斑马订单服务实现类
*
* @author ruoyi
*/
@@ -30,76 +28,74 @@ import java.util.stream.Collectors;
public class BanmaOrderServiceImpl implements IBanmaOrderService {
private static final Logger logger = LoggerUtil.getLogger(BanmaOrderServiceImpl.class);
private static final String SERVICE_NAME = "banma";
private static final String LOGIN_URL = "https://banma365.cn/api/login";
private static final String LOGIN_USERNAME = "大赢家网络科技(主账号)";
private static final String LOGIN_PASSWORD = "banma123456";
private static final String RUOYI_ADMIN_BASE = "http://127.0.0.1:8080";
private static final String API_URL = "https://banma365.cn/api/order/list?%srecipientName=&page=%d&size=%d&markFlag=0&state=4&_t=%d";
private static final String API_URL_WITH_TIME = "https://banma365.cn/api/order/list?%srecipientName=&page=%d&size=%d&markFlag=0&state=4&orderedAtStart=%s&orderedAtEnd=%s&_t=%d";
private static final String TRACKING_URL = "https://banma365.cn/zebraExpressHub/web/tracking/getByExpressNumber/%s";
private static final long TOKEN_EXPIRE_TIME = 24 * 60 * 60 * 1000;
private RestTemplate restTemplate;
private final ObjectMapper objectMapper = new ObjectMapper();
private final ICacheService cacheService;
private final BanmaOrderRepository banmaOrderRepository;
private final DataReportUtil dataReportUtil;
private final ErrorReporter errorReporter;
private String currentAuthToken;
private Long currentAccountId;
// 当前批量采集的sessionId
private String currentBatchSessionId = null;
// 物流信息缓存,避免重复查询
private final Map<String, String> trackingInfoCache = new ConcurrentHashMap<>();
public BanmaOrderServiceImpl(BanmaOrderRepository banmaOrderRepository, ICacheService cacheService, DataReportUtil dataReportUtil) {
public BanmaOrderServiceImpl(BanmaOrderRepository banmaOrderRepository, ICacheService cacheService, DataReportUtil dataReportUtil, ErrorReporter errorReporter) {
this.banmaOrderRepository = banmaOrderRepository;
this.cacheService = cacheService;
this.dataReportUtil = dataReportUtil;
this.errorReporter = errorReporter;
RestTemplateBuilder builder = new RestTemplateBuilder();
builder.connectTimeout(Duration.ofSeconds(5));
builder.readTimeout(Duration.ofSeconds(10));
restTemplate = builder.build();
initializeAuthToken();
}
/**
* 初始化认证令牌
*/
private void initializeAuthToken() {
refreshToken();
/* currentAuthToken = cacheService.getAuthToken(SERVICE_NAME);
if (currentAuthToken == null) {
@SuppressWarnings("unchecked")
private void fetchTokenFromServer(Long accountId) {
ResponseEntity<Map> resp = restTemplate.getForEntity(RUOYI_ADMIN_BASE + "/tool/banma/accounts", Map.class);
Object body = resp.getBody();
if (body == null) return;
Object data = ((Map<String, Object>) body).get("data");
List<Map<String, Object>> list;
if (data instanceof List) {
list = (List<Map<String, Object>>) data;
} else if (body instanceof Map && ((Map) body).get("list") instanceof List) {
list = (List<Map<String, Object>>) ((Map) body).get("list");
} else {
logger.info("从缓存加载斑马认证令牌成功");
}*/
}
/**
* 刷新斑马认证令牌
*/
@Override
public void refreshToken() {
Map<String, String> params = new HashMap<>();
params.put("username", LOGIN_USERNAME);
params.put("password", LOGIN_PASSWORD);
HttpHeaders headers = new HttpHeaders();
headers.set("Content-Type", "application/json");
ResponseEntity<Map> response = restTemplate.postForEntity(LOGIN_URL, new HttpEntity<>(params, headers), Map.class);
Optional.ofNullable(response.getBody())
.filter(body -> Integer.valueOf(0).equals(body.get("code")))
.map(body -> (Map<String, Object>) body.get("data"))
.map(data -> (String) data.get("token"))
.filter(StringUtils::isNotEmpty)
.ifPresent(token -> {
currentAuthToken = "Bearer " + token;
cacheService.saveAuthToken(SERVICE_NAME, currentAuthToken, System.currentTimeMillis() + TOKEN_EXPIRE_TIME);
});
return;
}
if (list.isEmpty()) return;
Map<String, Object> picked;
if (accountId != null) {
picked = list.stream().filter(m -> Objects.equals(((Number) m.get("id")).longValue(), accountId)).findFirst().orElse(null);
if (picked == null) return;
} else {
picked = list.stream()
.filter(m -> Objects.equals(((Number) m.getOrDefault("status", 1)).intValue(), 1))
.sorted((a,b) -> Integer.compare(((Number) b.getOrDefault("isDefault", 0)).intValue(), ((Number) a.getOrDefault("isDefault", 0)).intValue()))
.findFirst().orElse(list.get(0));
}
Object token = picked.get("token");
if (token instanceof String && !((String) token).isEmpty()) {
String t = (String) token;
currentAuthToken = t.startsWith("Bearer ") ? t : ("Bearer " + t);
currentAccountId = accountId;
}
}
/**
* 获取店铺列表
*/
@Override
public Map<String, Object> getShops() {
public Map<String, Object> getShops(Long accountId) {
fetchTokenFromServer(accountId);
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", currentAuthToken);
if (currentAuthToken != null) headers.set("Authorization", currentAuthToken);
HttpEntity<String> httpEntity = new HttpEntity<>(headers);
String url = "https://banma365.cn/api/shop/list?_t=" + System.currentTimeMillis();
@@ -112,13 +108,14 @@ public class BanmaOrderServiceImpl implements IBanmaOrderService {
* 分页获取订单数据
*/
@Override
public Map<String, Object> getOrdersByPage(String startDate, String endDate, int page, int pageSize, String batchId, List<String> shopIds) {
public Map<String, Object> getOrdersByPage(Long accountId, String startDate, String endDate, int page, int pageSize, String batchId, List<String> shopIds) {
if (page == 1) {
currentBatchSessionId = batchId;
trackingInfoCache.clear();
}
fetchTokenFromServer(accountId);
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", currentAuthToken);
if (currentAuthToken != null) headers.set("Authorization", currentAuthToken);
HttpEntity<String> httpEntity = new HttpEntity<>(headers);
String shopIdsParam = "";
@@ -138,7 +135,7 @@ public class BanmaOrderServiceImpl implements IBanmaOrderService {
if (response.getBody() == null || !Integer.valueOf(0).equals(response.getBody().get("code"))) {
Map<String, Object> errorResult = new HashMap<>();
errorResult.put("success", false);
errorResult.put("message", "获取订单数据失败,请点击'刷新认证'按钮重试");
errorResult.put("message", "获取订单数据失败,请稍后重试或联系管理员刷新认证");
return errorResult;
}
@@ -209,10 +206,15 @@ public class BanmaOrderServiceImpl implements IBanmaOrderService {
BanmaOrderEntity entity = new BanmaOrderEntity();
String entityTrackingNumber = (String) result.get("internationalTrackingNumber");
String shopOrderNumber = (String) result.get("shopOrderNumber");
String productTitle = (String) result.get("productTitle");
// 检查并上报空数据
if (StringUtils.isEmpty(entityTrackingNumber)) errorReporter.reportDataEmpty("banma", String.valueOf(result.get("id")), entityTrackingNumber);
if (StringUtils.isEmpty(shopOrderNumber)) errorReporter.reportDataEmpty("banma", String.valueOf(result.get("id")), shopOrderNumber);
if (StringUtils.isEmpty(productTitle)) errorReporter.reportDataEmpty("banma", String.valueOf(result.get("id")), productTitle);
if (StringUtils.isEmpty(entityTrackingNumber)) {
String shopOrderNumber = (String) result.get("shopOrderNumber");
String productTitle = (String) result.get("productTitle");
if (StringUtils.isNotEmpty(shopOrderNumber)) {
entityTrackingNumber = "ORDER_" + shopOrderNumber;
} else if (StringUtils.isNotEmpty(productTitle)) {

View File

@@ -9,6 +9,7 @@ import com.tashow.erp.model.RakutenProduct;
import com.tashow.erp.model.SearchResult;
import com.tashow.erp.repository.RakutenProductRepository;
import com.tashow.erp.service.RakutenScrapingService;
import com.tashow.erp.utils.ErrorReporter;
import com.tashow.erp.utils.RakutenProxyUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
@@ -36,6 +37,8 @@ public class RakutenScrapingServiceImpl implements RakutenScrapingService {
@Autowired
private RakutenProductRepository rakutenProductRepository;
@Autowired
private ErrorReporter errorReporter;
@Autowired
ObjectMapper objectMapper;
/**
@@ -46,7 +49,7 @@ public class RakutenScrapingServiceImpl implements RakutenScrapingService {
String url = "https://ranking.rakuten.co.jp/search?stx=" + URLEncoder.encode(shopName, StandardCharsets.UTF_8);
List<RakutenProduct> products = new ArrayList<>();
Spider spider = Spider.create(new RakutenPageProcessor(products)).addUrl(url).setDownloader(new RakutenProxyUtil().createProxyDownloader(new RakutenProxyUtil().detectSystemProxy(url))).thread(1);
Spider spider = Spider.create(new RakutenPageProcessor(products, errorReporter)).addUrl(url).setDownloader(new RakutenProxyUtil().createProxyDownloader(new RakutenProxyUtil().detectSystemProxy(url))).thread(1);
spider.run();
log.info("采集完成,店铺: {},数量: {}", shopName, products.size());
@@ -55,9 +58,11 @@ public class RakutenScrapingServiceImpl implements RakutenScrapingService {
private class RakutenPageProcessor implements PageProcessor {
private final List<RakutenProduct> products;
private final ErrorReporter errorReporter;
RakutenPageProcessor(List<RakutenProduct> products) {
RakutenPageProcessor(List<RakutenProduct> products, ErrorReporter errorReporter) {
this.products = products;
this.errorReporter = errorReporter;
}
@Override
@@ -83,6 +88,11 @@ public class RakutenScrapingServiceImpl implements RakutenScrapingService {
product.setOriginalShopName(shopName); // 设置原始店铺名称
product.setImgUrl(imageUrls.get(i));
String title = titles.get(i).trim();
// 检查并上报空数据
if (title == null || title.trim().isEmpty()) errorReporter.reportDataEmpty("rakuten", productUrl, title);
if (prices.get(i) == null || prices.get(i).replaceAll("[^0-9]", "").isEmpty()) errorReporter.reportDataEmpty("rakuten", productUrl, prices.get(i));
if (shopName == null || shopName.isEmpty()) errorReporter.reportDataEmpty("rakuten", productUrl, shopName);
product.setProductTitle(title);
product.setProductName(title);
product.setPrice(prices.get(i).replaceAll("[^0-9]", ""));

View File

@@ -97,6 +97,14 @@ public class ErrorReporter {
ex.printStackTrace(pw);
return sw.toString();
}
/**
* 上报数据为空异常
*/
public void reportDataEmpty(String serviceName, String dataId, Object data) {
String message = String.format("数据为空 - ID: %s, 数据: %s", dataId, data);
reportError("DATA_EMPTY", serviceName + " - " + message, new RuntimeException(message));
}
/**
* 设置全局异常处理器
*/