|
@@ -0,0 +1,413 @@
|
|
|
|
|
+package com.jeeplus.assess.materialPrice.service;
|
|
|
|
|
+
|
|
|
|
|
+import cn.hutool.core.date.DateUtil;
|
|
|
|
|
+import cn.hutool.core.util.IdUtil;
|
|
|
|
|
+import cn.hutool.core.util.StrUtil;
|
|
|
|
|
+import com.alibaba.fastjson.JSON;
|
|
|
|
|
+import com.jeeplus.assess.materialPrice.client.MaterialPriceScraperClient;
|
|
|
|
|
+import com.jeeplus.assess.materialPrice.client.dto.MaterialPriceItemDTO;
|
|
|
|
|
+import com.jeeplus.assess.materialPrice.client.dto.MaterialPriceScrapeResponse;
|
|
|
|
|
+import com.jeeplus.assess.materialPrice.client.dto.MaterialPriceScraperResultDTO;
|
|
|
|
|
+import com.jeeplus.assess.materialPrice.domain.MaterialPriceRecord;
|
|
|
|
|
+import com.jeeplus.assess.materialPrice.domain.MaterialPriceSyncLog;
|
|
|
|
|
+import com.jeeplus.assess.materialPrice.enums.MaterialPriceSyncStatus;
|
|
|
|
|
+import com.jeeplus.assess.materialPrice.mapper.MaterialPriceSyncLogMapper;
|
|
|
|
|
+import com.jeeplus.common.SecurityUtils;
|
|
|
|
|
+import com.jeeplus.common.constant.CommonConstants;
|
|
|
|
|
+import com.jeeplus.common.redis.RedisUtils;
|
|
|
|
|
+import com.jeeplus.sys.service.dto.UserDTO;
|
|
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
|
|
+import org.apache.commons.lang3.StringUtils;
|
|
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
|
|
+
|
|
|
|
|
+import javax.annotation.Resource;
|
|
|
|
|
+import java.math.BigDecimal;
|
|
|
|
|
+import java.time.LocalDate;
|
|
|
|
|
+import java.time.ZoneId;
|
|
|
|
|
+import java.time.format.DateTimeParseException;
|
|
|
|
|
+import java.time.temporal.ChronoUnit;
|
|
|
|
|
+import java.util.ArrayList;
|
|
|
|
|
+import java.util.Collections;
|
|
|
|
|
+import java.util.Date;
|
|
|
|
|
+import java.util.LinkedHashMap;
|
|
|
|
|
+import java.util.List;
|
|
|
|
|
+import java.util.Map;
|
|
|
|
|
+import java.util.concurrent.atomic.AtomicBoolean;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 材料价格同步服务:Java 侧唯一调用 Python 抓取服务的入口。
|
|
|
|
|
+ *
|
|
|
|
|
+ * XXL 定时任务和管理员「立即采集」共用同一套逻辑:
|
|
|
|
|
+ * 1. 校验日期、加锁(原子标记 + Redis 锁,防止并发重复采集);
|
|
|
|
|
+ * 2. 先插入 RUNNING 日志,再调用 Python;
|
|
|
|
|
+ * 3. 清洗数据(必填校验、价格互换、日期解析、幂等键)后批量 upsert;
|
|
|
|
|
+ * 4. 根据各抓取器状态判定整批状态,回写日志并返回给调用方。
|
|
|
|
|
+ */
|
|
|
|
|
+@Slf4j
|
|
|
|
|
+@Service
|
|
|
|
|
+public class MaterialPriceSyncService {
|
|
|
|
|
+
|
|
|
|
|
+ public static final String TRIGGER_XXL = "XXL";
|
|
|
|
|
+ public static final String TRIGGER_MANUAL = "MANUAL";
|
|
|
|
|
+
|
|
|
|
|
+ /** Redis 锁 key,防止多个实例同时采集 */
|
|
|
|
|
+ private static final String LOCK_KEY = "material-price:sync";
|
|
|
|
|
+ /** 锁过期时间(秒),必须大于单次采集最大耗时 */
|
|
|
|
|
+ private static final long LOCK_SECONDS = 600;
|
|
|
|
|
+ private static final ZoneId CN_ZONE = ZoneId.of("Asia/Shanghai");
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private MaterialPriceRecordService recordService;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private MaterialPriceSyncLogMapper syncLogMapper;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private MaterialPriceScraperClient scraperClient;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private RedisUtils redisUtils;
|
|
|
|
|
+
|
|
|
|
|
+ /** 单实例内的并发标记,Redis 不可用时也能防止同一进程内重复执行 */
|
|
|
|
|
+ private final AtomicBoolean running = new AtomicBoolean(false);
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 执行一次采集。
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param dateStr 目标日期 yyyy-MM-dd,为空表示取各站点最新数据
|
|
|
|
|
+ * @param triggerType XXL / MANUAL
|
|
|
|
|
+ * @return 统一结果结构:code=200 成功(含部分失败)、code=400 日期非法、code=409 正在采集、code=500 失败
|
|
|
|
|
+ */
|
|
|
|
|
+ public Map<String, Object> sync(String dateStr, String triggerType) {
|
|
|
|
|
+ LocalDate requestDate;
|
|
|
|
|
+ try {
|
|
|
|
|
+ requestDate = StringUtils.isBlank(dateStr) ? null : LocalDate.parse(dateStr.trim());
|
|
|
|
|
+ } catch (DateTimeParseException e) {
|
|
|
|
|
+ return result(400, "date 格式必须为 yyyy-MM-dd", null);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (!running.compareAndSet(false, true)) {
|
|
|
|
|
+ return result(409, "采集正在进行中,请稍后再试", null);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ String batchNo = IdUtil.fastSimpleUUID();
|
|
|
|
|
+ boolean locked = false;
|
|
|
|
|
+ try {
|
|
|
|
|
+ // 多实例互斥:拿不到 Redis 锁说明别的节点正在采集
|
|
|
|
|
+ locked = tryLock(batchNo);
|
|
|
|
|
+ if (!locked) {
|
|
|
|
|
+ return result(409, "采集正在进行中,请稍后再试", null);
|
|
|
|
|
+ }
|
|
|
|
|
+ return doSync(batchNo, requestDate, triggerType);
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ if (locked) {
|
|
|
|
|
+ releaseLock();
|
|
|
|
|
+ }
|
|
|
|
|
+ running.set(false);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 真正的采集流程(已持有锁)。任何一步失败都会把日志置为 FAILED 并保留原因。
|
|
|
|
|
+ */
|
|
|
|
|
+ private Map<String, Object> doSync(String batchNo, LocalDate requestDate, String triggerType) {
|
|
|
|
|
+ String operator = currentOperator();
|
|
|
|
|
+ MaterialPriceSyncLog logEntity = newSyncLog(batchNo, requestDate, triggerType, operator);
|
|
|
|
|
+ syncLogMapper.insert(logEntity);
|
|
|
|
|
+
|
|
|
|
|
+ MaterialPriceScrapeResponse response;
|
|
|
|
|
+ try {
|
|
|
|
|
+ response = scraperClient.scrape(requestDate);
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.error("材料价格采集失败 batchNo={}", batchNo, e);
|
|
|
|
|
+ finishLog(logEntity, MaterialPriceSyncStatus.FAILED, 0, 0, 0, null, null,
|
|
|
|
|
+ "调用抓取服务失败: " + e.getMessage(), operator);
|
|
|
|
|
+ Map<String, Object> result = result(500, "采集失败: " + e.getMessage(), batchNo);
|
|
|
|
|
+ result.put("status", MaterialPriceSyncStatus.FAILED.name());
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 逐条清洗;清洗不通过的数据计入 failedCount,不影响其他记录入库
|
|
|
|
|
+ List<MaterialPriceRecord> records = new ArrayList<>();
|
|
|
|
|
+ int failedCount = 0;
|
|
|
|
|
+ for (MaterialPriceItemDTO item : safe(response.getItems())) {
|
|
|
|
|
+ MaterialPriceRecord record = convert(item, batchNo, operator);
|
|
|
|
|
+ if (record == null) {
|
|
|
|
|
+ failedCount++;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ records.add(record);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ int saved;
|
|
|
|
|
+ try {
|
|
|
|
|
+ saved = recordService.upsertBatch(records);
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.error("材料价格入库失败 batchNo={}", batchNo, e);
|
|
|
|
|
+ finishLog(logEntity, MaterialPriceSyncStatus.FAILED,
|
|
|
|
|
+ response.getTotal() == null ? 0 : response.getTotal(), 0, failedCount,
|
|
|
|
|
+ null, JSON.toJSONString(response.getScrapers()), "入库失败: " + e.getMessage(), operator);
|
|
|
|
|
+ Map<String, Object> result = result(500, "入库失败: " + e.getMessage(), batchNo);
|
|
|
|
|
+ result.put("status", MaterialPriceSyncStatus.FAILED.name());
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 根据各抓取器状态判定整批结果
|
|
|
|
|
+ List<MaterialPriceScraperResultDTO> scrapers = safe(response.getScrapers());
|
|
|
|
|
+ long successSites = scrapers.stream()
|
|
|
|
|
+ .filter(s -> MaterialPriceSyncStatus.SUCCESS.name().equals(s.getStatus()))
|
|
|
|
|
+ .count();
|
|
|
|
|
+ long okSites = scrapers.stream()
|
|
|
|
|
+ .filter(s -> MaterialPriceSyncStatus.SUCCESS.name().equals(s.getStatus())
|
|
|
|
|
+ || MaterialPriceSyncStatus.NO_DATA.name().equals(s.getStatus()))
|
|
|
|
|
+ .count();
|
|
|
|
|
+ long failedSites = scrapers.stream()
|
|
|
|
|
+ .filter(s -> MaterialPriceSyncStatus.FAILED.name().equals(s.getStatus()))
|
|
|
|
|
+ .count();
|
|
|
|
|
+
|
|
|
|
|
+ MaterialPriceSyncStatus status;
|
|
|
|
|
+ if (scrapers.isEmpty() || okSites == 0) {
|
|
|
|
|
+ // 没有任何站点正常完成(包括抓取器列表为空)都视为整体失败
|
|
|
|
|
+ status = MaterialPriceSyncStatus.FAILED;
|
|
|
|
|
+ } else if (failedSites > 0) {
|
|
|
|
|
+ status = MaterialPriceSyncStatus.PARTIAL;
|
|
|
|
|
+ } else if (successSites == 0) {
|
|
|
|
|
+ status = MaterialPriceSyncStatus.NO_DATA;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ status = MaterialPriceSyncStatus.SUCCESS;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 数据滞后:数据日期与目标日期(未指定则今天)的差值
|
|
|
|
|
+ LocalDate dataDate = maxDataDate(scrapers);
|
|
|
|
|
+ LocalDate lagBase = requestDate == null ? LocalDate.now(CN_ZONE) : requestDate;
|
|
|
|
|
+ Long lagDays = dataDate == null ? null : Math.max(0, ChronoUnit.DAYS.between(dataDate, lagBase));
|
|
|
|
|
+ if (lagDays != null && lagDays > 3) {
|
|
|
|
|
+ log.warn("材料价格数据滞后 {} 天, batchNo={}, dataDate={}", lagDays, batchNo, dataDate);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ int total = response.getTotal() == null ? records.size() + failedCount : response.getTotal();
|
|
|
|
|
+ String errorMsg = status == MaterialPriceSyncStatus.FAILED ? "全部抓取器失败" : null;
|
|
|
|
|
+ finishLog(logEntity, status, total, saved, failedCount, dataDate,
|
|
|
|
|
+ JSON.toJSONString(scrapers), errorMsg, operator);
|
|
|
|
|
+
|
|
|
|
|
+ // 部分失败仍返回 code=200,让 XXL 不重试;用 partial 字段提示上层
|
|
|
|
|
+ Map<String, Object> result = result(
|
|
|
|
|
+ status == MaterialPriceSyncStatus.FAILED ? 500 : 200,
|
|
|
|
|
+ status == MaterialPriceSyncStatus.FAILED ? "全部抓取器失败" : "ok",
|
|
|
|
|
+ batchNo);
|
|
|
|
|
+ result.put("requestDate", requestDate == null ? null : requestDate.toString());
|
|
|
|
|
+ result.put("dataDate", dataDate == null ? null : dataDate.toString());
|
|
|
|
|
+ result.put("lagDays", lagDays);
|
|
|
|
|
+ result.put("total", total);
|
|
|
|
|
+ result.put("saved", saved);
|
|
|
|
|
+ result.put("failed", failedCount);
|
|
|
|
|
+ result.put("partial", status == MaterialPriceSyncStatus.PARTIAL);
|
|
|
|
|
+ result.put("status", status.name());
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 把 Python 返回的一条数据转成实体,返回 null 表示这条数据不可用。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 校验规则:站点/分类/物料/省份/日期必填;城市允许为空(站点没有城市信息);
|
|
|
|
|
+ * price 与 low+high 至少要有一组;最低价与最高价写反时自动互换。
|
|
|
|
|
+ */
|
|
|
|
|
+ private MaterialPriceRecord convert(MaterialPriceItemDTO item, String batchNo, String operator) {
|
|
|
|
|
+ if (item == null
|
|
|
|
|
+ || StringUtils.isBlank(item.getSiteCode())
|
|
|
|
|
+ || StringUtils.isBlank(item.getCategory())
|
|
|
|
|
+ || StringUtils.isBlank(item.getMaterialName())
|
|
|
|
|
+ || StringUtils.isBlank(item.getProvince())
|
|
|
|
|
+ || StringUtils.isBlank(item.getPriceDate())) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ BigDecimal price = parseDecimal(item.getPrice());
|
|
|
|
|
+ BigDecimal priceLow = parseDecimal(item.getPriceLow());
|
|
|
|
|
+ BigDecimal priceHigh = parseDecimal(item.getPriceHigh());
|
|
|
|
|
+ if (price == null && (priceLow == null || priceHigh == null)) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (priceLow != null && priceHigh != null && priceHigh.compareTo(priceLow) < 0) {
|
|
|
|
|
+ BigDecimal tmp = priceLow;
|
|
|
|
|
+ priceLow = priceHigh;
|
|
|
|
|
+ priceHigh = tmp;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ LocalDate priceDate;
|
|
|
|
|
+ try {
|
|
|
|
|
+ priceDate = LocalDate.parse(item.getPriceDate().trim());
|
|
|
|
|
+ } catch (DateTimeParseException e) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 审计字段全部预填:XXL 线程里没有登录态,交给 MyMetaObjectHandler 会去查用户而报错
|
|
|
|
|
+ Date now = new Date();
|
|
|
|
|
+ MaterialPriceRecord record = new MaterialPriceRecord();
|
|
|
|
|
+ record.setId(IdUtil.fastSimpleUUID());
|
|
|
|
|
+ record.setSiteCode(item.getSiteCode().trim());
|
|
|
|
|
+ record.setSiteName(StringUtils.isBlank(item.getSiteName()) ? item.getSiteCode().trim() : item.getSiteName().trim());
|
|
|
|
|
+ record.setCategory(item.getCategory().trim());
|
|
|
|
|
+ record.setProvince(item.getProvince().trim());
|
|
|
|
|
+ // 城市可以缺失,统一存空串,避免 null 影响筛选与幂等键
|
|
|
|
|
+ record.setCity(StrUtil.nullToEmpty(item.getCity()).trim());
|
|
|
|
|
+ record.setMaterialName(item.getMaterialName().trim());
|
|
|
|
|
+ record.setSpec(StrUtil.nullToEmpty(item.getSpec()).trim());
|
|
|
|
|
+ record.setPrice(price);
|
|
|
|
|
+ record.setPriceLow(priceLow);
|
|
|
|
|
+ record.setPriceHigh(priceHigh);
|
|
|
|
|
+ record.setPriceUnit(StrUtil.nullToEmpty(item.getPriceUnit()).trim());
|
|
|
|
|
+ // Python 的 Boolean 转成数据库的 1/0,null 保持 null(未声明)
|
|
|
|
|
+ record.setTaxIncluded(item.getTaxIncluded() == null ? null : (item.getTaxIncluded() ? 1 : 0));
|
|
|
|
|
+ record.setRemark(StrUtil.nullToEmpty(item.getRemark()).trim());
|
|
|
|
|
+ record.setPriceDate(priceDate);
|
|
|
|
|
+ record.setSourceUrl(StrUtil.nullToEmpty(item.getSourceUrl()));
|
|
|
|
|
+ record.setBatchNo(batchNo);
|
|
|
|
|
+ record.setScrapedAt(parseDateTime(item.getScrapedAt(), now));
|
|
|
|
|
+ record.setDedupeKey(MaterialPriceRecordService.buildDedupeKey(record));
|
|
|
|
|
+ record.setCreateById(operator);
|
|
|
|
|
+ record.setCreateTime(now);
|
|
|
|
|
+ record.setUpdateById(operator);
|
|
|
|
|
+ record.setUpdateTime(now);
|
|
|
|
|
+ record.setDelFlag(0);
|
|
|
|
|
+ record.setTenantId(CommonConstants.DEFAULT_TENANT_ID);
|
|
|
|
|
+ return record;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 新建一条 RUNNING 状态的采集日志。 */
|
|
|
|
|
+ private MaterialPriceSyncLog newSyncLog(String batchNo, LocalDate requestDate,
|
|
|
|
|
+ String triggerType, String operator) {
|
|
|
|
|
+ Date now = new Date();
|
|
|
|
|
+ MaterialPriceSyncLog logEntity = new MaterialPriceSyncLog();
|
|
|
|
|
+ logEntity.setId(IdUtil.fastSimpleUUID());
|
|
|
|
|
+ logEntity.setBatchNo(batchNo);
|
|
|
|
|
+ logEntity.setRequestDate(requestDate);
|
|
|
|
|
+ logEntity.setTriggerType(triggerType);
|
|
|
|
|
+ logEntity.setStatus(MaterialPriceSyncStatus.RUNNING.name());
|
|
|
|
|
+ logEntity.setTotalCount(0);
|
|
|
|
|
+ logEntity.setSavedCount(0);
|
|
|
|
|
+ logEntity.setFailedCount(0);
|
|
|
|
|
+ logEntity.setStartTime(now);
|
|
|
|
|
+ logEntity.setCreateById(operator);
|
|
|
|
|
+ logEntity.setCreateTime(now);
|
|
|
|
|
+ logEntity.setUpdateById(operator);
|
|
|
|
|
+ logEntity.setUpdateTime(now);
|
|
|
|
|
+ logEntity.setDelFlag(0);
|
|
|
|
|
+ logEntity.setTenantId(CommonConstants.DEFAULT_TENANT_ID);
|
|
|
|
|
+ return logEntity;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 采集结束(成功或失败)时回写日志。 */
|
|
|
|
|
+ private void finishLog(MaterialPriceSyncLog logEntity, MaterialPriceSyncStatus status,
|
|
|
|
|
+ Integer total, Integer saved, Integer failed, LocalDate dataDate,
|
|
|
|
|
+ String siteResult, String errorMsg, String operator) {
|
|
|
|
|
+ logEntity.setStatus(status.name());
|
|
|
|
|
+ logEntity.setTotalCount(total);
|
|
|
|
|
+ logEntity.setSavedCount(saved);
|
|
|
|
|
+ logEntity.setFailedCount(failed);
|
|
|
|
|
+ logEntity.setPriceDate(dataDate);
|
|
|
|
|
+ logEntity.setSiteResult(siteResult);
|
|
|
|
|
+ logEntity.setErrorMsg(errorMsg);
|
|
|
|
|
+ logEntity.setEndTime(new Date());
|
|
|
|
|
+ logEntity.setUpdateById(operator);
|
|
|
|
|
+ logEntity.setUpdateTime(new Date());
|
|
|
|
|
+ syncLogMapper.updateById(logEntity);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 尝试获取 Redis 锁。Redis 不可用时退化为单实例防重(返回 true),
|
|
|
|
|
+ * 保证功能可用性优先于多实例互斥的严格性。
|
|
|
|
|
+ */
|
|
|
|
|
+ private boolean tryLock(String batchNo) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ boolean locked = redisUtils.setIfAbsent(LOCK_KEY, batchNo);
|
|
|
|
|
+ if (locked) {
|
|
|
|
|
+ redisUtils.expire(LOCK_KEY, LOCK_SECONDS);
|
|
|
|
|
+ }
|
|
|
|
|
+ return locked;
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("Redis 锁不可用,退化为单实例防重: {}", e.getMessage());
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void releaseLock() {
|
|
|
|
|
+ try {
|
|
|
|
|
+ redisUtils.delete(LOCK_KEY);
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("释放采集锁失败: {}", e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 当前登录用户 id;XXL 触发时没有登录态,记为 system。 */
|
|
|
|
|
+ private String currentOperator() {
|
|
|
|
|
+ try {
|
|
|
|
|
+ UserDTO user = SecurityUtils.getCurrentUserDTO();
|
|
|
|
|
+ if (user != null && StringUtils.isNotBlank(user.getId())) {
|
|
|
|
|
+ return user.getId();
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("获取当前用户失败,使用 system 记录审计字段: {}", e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ return "system";
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 所有成功抓取器中最新的数据日期。 */
|
|
|
|
|
+ private static LocalDate maxDataDate(List<MaterialPriceScraperResultDTO> scrapers) {
|
|
|
|
|
+ LocalDate max = null;
|
|
|
|
|
+ for (MaterialPriceScraperResultDTO scraper : scrapers) {
|
|
|
|
|
+ if (!MaterialPriceSyncStatus.SUCCESS.name().equals(scraper.getStatus())
|
|
|
|
|
+ || StringUtils.isBlank(scraper.getDataDate())) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ LocalDate date = LocalDate.parse(scraper.getDataDate().trim());
|
|
|
|
|
+ if (max == null || date.isAfter(max)) {
|
|
|
|
|
+ max = date;
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (DateTimeParseException ignored) {
|
|
|
|
|
+ // 抓取器返回了非法日期时跳过,不影响其他站点
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return max;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 价格字符串转 BigDecimal,空值或非法值返回 null(由调用方判定该条是否可用)。 */
|
|
|
|
|
+ private static BigDecimal parseDecimal(String text) {
|
|
|
|
|
+ if (StringUtils.isBlank(text)) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ return new BigDecimal(text.trim());
|
|
|
|
|
+ } catch (NumberFormatException e) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 解析采集时间,解析失败时退回任务开始时间。 */
|
|
|
|
|
+ private static Date parseDateTime(String text, Date fallback) {
|
|
|
|
|
+ if (StringUtils.isBlank(text)) {
|
|
|
|
|
+ return fallback;
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ return DateUtil.parse(text.trim());
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ return fallback;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static <T> List<T> safe(List<T> list) {
|
|
|
|
|
+ return list == null ? Collections.emptyList() : list;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 统一返回结构,前端/XXL 都按 code 判断成败。 */
|
|
|
|
|
+ private static Map<String, Object> result(int code, String msg, String batchNo) {
|
|
|
|
|
+ Map<String, Object> result = new LinkedHashMap<>();
|
|
|
|
|
+ result.put("code", code);
|
|
|
|
|
+ result.put("msg", msg);
|
|
|
|
|
+ if (batchNo != null) {
|
|
|
|
|
+ result.put("batchNo", batchNo);
|
|
|
|
|
+ }
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|