Bläddra i källkod

物料信息抓取相关代码

Alex 1 vecka sedan
förälder
incheckning
872a69e7c5
23 ändrade filer med 1412 tillägg och 0 borttagningar
  1. 14 0
      jeeplus-api/jeeplus-system-api/src/main/java/com/jeeplus/flowable/factory/AssessApiFallbackFactory.java
  2. 12 0
      jeeplus-api/jeeplus-system-api/src/main/java/com/jeeplus/flowable/feign/IAssessApi.java
  3. 5 0
      jeeplus-common/jeeplus-common-core/pom.xml
  4. 87 0
      jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/client/MaterialPriceScraperClient.java
  5. 49 0
      jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/client/dto/MaterialPriceItemDTO.java
  6. 24 0
      jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/client/dto/MaterialPriceScrapeRequest.java
  7. 27 0
      jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/client/dto/MaterialPriceScrapeResponse.java
  8. 36 0
      jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/client/dto/MaterialPriceScraperResultDTO.java
  9. 93 0
      jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/controller/MaterialPriceController.java
  10. 29 0
      jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/controller/MaterialPriceFeignController.java
  11. 69 0
      jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/domain/MaterialPriceRecord.java
  12. 54 0
      jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/domain/MaterialPriceSyncLog.java
  13. 18 0
      jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/enums/MaterialPriceSyncStatus.java
  14. 28 0
      jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/mapper/MaterialPriceRecordMapper.java
  15. 14 0
      jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/mapper/MaterialPriceSyncLogMapper.java
  16. 43 0
      jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/mapper/xml/MaterialPriceRecordMapper.xml
  17. 218 0
      jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/service/MaterialPriceRecordService.java
  18. 413 0
      jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/service/MaterialPriceSyncService.java
  19. 25 0
      jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/service/dto/MaterialPriceOptionsDTO.java
  20. 29 0
      jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/service/dto/MaterialPricePointDTO.java
  21. 44 0
      jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/service/dto/MaterialPriceQueryDTO.java
  22. 19 0
      jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/service/dto/MaterialPriceSiteDTO.java
  23. 62 0
      jeeplus-modules/jeeplus-xxl-job-executor-sample/src/main/java/com/xxl/job/executor/service/jobhandler/MaterialPriceSyncJob.java

+ 14 - 0
jeeplus-api/jeeplus-system-api/src/main/java/com/jeeplus/flowable/factory/AssessApiFallbackFactory.java

@@ -5,7 +5,9 @@ import lombok.extern.slf4j.Slf4j;
 import org.springframework.cloud.openfeign.FallbackFactory;
 import org.springframework.stereotype.Component;
 
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 
 /**
  * 财务服务降级处理
@@ -105,6 +107,18 @@ public class AssessApiFallbackFactory implements FallbackFactory<IAssessApi> {
             public void getThreeMonthsUnarchivedContracts() {
 
             }
+
+            /**
+             * assess 服务不可用时的降级:返回 code=500,
+             * XXL 任务会据此判定失败并按配置重试,而不是静默成功。
+             */
+            @Override
+            public Map<String, Object> syncMaterialPrice(String date) {
+                Map<String, Object> result = new HashMap<>();
+                result.put("code", 500);
+                result.put("msg", "assess service unavailable");
+                return result;
+            }
         };
     }
 }

+ 12 - 0
jeeplus-api/jeeplus-system-api/src/main/java/com/jeeplus/flowable/feign/IAssessApi.java

@@ -4,11 +4,13 @@ import com.jeeplus.common.constant.AppNameConstants;
 import com.jeeplus.flowable.factory.AssessApiFallbackFactory;
 import org.springframework.cloud.openfeign.FeignClient;
 import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RequestMethod;
 import org.springframework.web.bind.annotation.RequestParam;
 
 import java.util.List;
+import java.util.Map;
 
 /**
  * @author: 王强
@@ -119,4 +121,14 @@ public interface IAssessApi {
      */
     @GetMapping(value = "/workContract/workContractInfo/getThreeMonthsUnarchivedContracts")
     void getThreeMonthsUnarchivedContracts();
+
+    /** 材料价格内部接口前缀:服务间调用,不经 Gateway,也没有登录态校验 */
+    String MATERIAL_PRICE_BASE_URL = "/feign/assess/materialPrice";
+
+    /**
+     * 同步材料价格,date 为空表示取最新。
+     * 返回值统一为 {code, msg, batchNo, status, partial, ...},XXL 按 code 判定成败。
+     */
+    @PostMapping(value = MATERIAL_PRICE_BASE_URL + "/sync")
+    Map<String, Object> syncMaterialPrice(@RequestParam(value = "date", required = false) String date);
 }

+ 5 - 0
jeeplus-common/jeeplus-common-core/pom.xml

@@ -173,6 +173,11 @@
             <version>${jackson.version}</version>
         </dependency>
         <dependency>
+            <groupId>com.fasterxml.jackson.datatype</groupId>
+            <artifactId>jackson-datatype-jsr310</artifactId>
+            <version>${jackson.version}</version>
+        </dependency>
+        <dependency>
             <groupId>p6spy</groupId>
             <artifactId>p6spy</artifactId>
             <version>${p6spy.version}</version>

+ 87 - 0
jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/client/MaterialPriceScraperClient.java

@@ -0,0 +1,87 @@
+package com.jeeplus.assess.materialPrice.client;
+
+import cn.hutool.core.util.StrUtil;
+import cn.hutool.http.HttpRequest;
+import cn.hutool.http.HttpResponse;
+import com.alibaba.fastjson.JSON;
+import com.jeeplus.assess.materialPrice.client.dto.MaterialPriceScrapeRequest;
+import com.jeeplus.assess.materialPrice.client.dto.MaterialPriceScrapeResponse;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDate;
+
+/**
+ * 调用 Python 抓取服务(xg_material_scraper)的 HTTP 客户端。
+ *
+ * 前端永远不直连 Python:只有 Java 通过内网调用本客户端,
+ * XXL 定时任务与管理员「立即采集」共用同一条链路。
+ * 配置项在 Nacos 的 material-price.scraper.* 下,未配置时使用本地默认值。
+ */
+@Slf4j
+@Component
+public class MaterialPriceScraperClient {
+
+    /** 抓取服务地址,生产环境必须改为实际部署地址 */
+    @Value("${material-price.scraper.base-url:http://127.0.0.1:8100}")
+    private String baseUrl;
+
+    /** 与 Python 的 SCRAPER_API_KEY 保持一致 */
+    @Value("${material-price.scraper.api-key:dev-key-change-me}")
+    private String apiKey;
+
+    /** HTTP 读超时,必须大于 Python 侧整批超时(默认 120s 的请求超时要小于它) */
+    @Value("${material-price.scraper.timeout-ms:50000}")
+    private int timeoutMs;
+
+    /** 开关:关闭后同步服务调用会直接抛错,便于临时停用抓取 */
+    @Value("${material-price.scraper.enabled:true}")
+    private boolean enabled;
+
+    /**
+     * 触发一次抓取。
+     *
+     * @param date 目标日期,null 表示取各站点最新数据
+     * @return 抓取服务响应;网络错误、HTTP 非 200、内容无法解析时抛 IllegalStateException
+     */
+    public MaterialPriceScrapeResponse scrape(LocalDate date) {
+        if (!enabled) {
+            throw new IllegalStateException("material-price.scraper.enabled=false");
+        }
+
+        MaterialPriceScrapeRequest request = new MaterialPriceScrapeRequest();
+        request.setDate(date == null ? null : date.toString());
+        // Python 侧整批超时略小于 Java 读超时,保证 Java 先收到结构化响应而不是先超时
+        request.setTimeoutSeconds(Math.max(60, timeoutMs / 1000));
+
+        String url = StrUtil.removeSuffix(baseUrl, "/") + "/api/v1/scrape";
+        HttpResponse response;
+        try {
+            response = HttpRequest.post(url)
+                    .header("X-API-KEY", apiKey)
+                    .header("Content-Type", "application/json")
+                    .body(JSON.toJSONString(request), "application/json")
+                    .setConnectionTimeout(5000)
+                    .setReadTimeout(timeoutMs)
+                    .execute();
+        } catch (Exception e) {
+            throw new IllegalStateException("调用抓取服务失败: " + e.getMessage(), e);
+        }
+
+        if (!response.isOk()) {
+            throw new IllegalStateException("抓取服务返回 HTTP " + response.getStatus());
+        }
+        try {
+            MaterialPriceScrapeResponse result = JSON.parseObject(response.body(), MaterialPriceScrapeResponse.class);
+            if (result == null) {
+                throw new IllegalStateException("抓取服务返回空内容");
+            }
+            return result;
+        } catch (IllegalStateException e) {
+            throw e;
+        } catch (Exception e) {
+            throw new IllegalStateException("抓取服务返回内容无法解析", e);
+        }
+    }
+}

+ 49 - 0
jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/client/dto/MaterialPriceItemDTO.java

@@ -0,0 +1,49 @@
+package com.jeeplus.assess.materialPrice.client.dto;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * Python 抓取服务返回的单条价格数据。
+ *
+ * 价格字段用 String 接收:Python 侧 Decimal 序列化成字符串,避免浮点精度问题,
+ * 入库前再由 MaterialPriceSyncService 转成 BigDecimal。
+ */
+@Data
+public class MaterialPriceItemDTO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 抓取器标识,用于定位是哪个站点/分类抓到的 */
+    private String scraperKey;
+
+    /** 站点编码:zgfp / ffhsw / hss */
+    private String siteCode;
+    private String siteName;
+    private String category;
+    /** 省份;所有站点都有 */
+    private String province;
+    /** 城市;站点没有城市信息时为空 */
+    private String city;
+    private String materialName;
+    private String spec;
+
+    /** 单值价格(废废网),其余站点为空 */
+    private String price;
+    /** 最低价(中果、回收商网) */
+    private String priceLow;
+    /** 最高价(中果、回收商网) */
+    private String priceHigh;
+
+    private String priceUnit;
+    /** true 含税 / false 不含税 / null 未声明 */
+    private Boolean taxIncluded;
+    /** 站点「备注/其他说明」原文,只展示 */
+    private String remark;
+    /** yyyy-MM-dd */
+    private String priceDate;
+    private String sourceUrl;
+    /** yyyy-MM-dd HH:mm:ss */
+    private String scrapedAt;
+}

+ 24 - 0
jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/client/dto/MaterialPriceScrapeRequest.java

@@ -0,0 +1,24 @@
+package com.jeeplus.assess.materialPrice.client.dto;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.List;
+
+/**
+ * 调用 Python /api/v1/scrape 的请求体。
+ */
+@Data
+public class MaterialPriceScrapeRequest implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 目标日期 yyyy-MM-dd;为空表示取各站点最新一天 */
+    private String date;
+
+    /** 只抓指定抓取器;为空表示全部 */
+    private List<String> scraperKeys;
+
+    /** 整批超时(秒),必须小于 Java 侧 HTTP 读超时 */
+    private Integer timeoutSeconds;
+}

+ 27 - 0
jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/client/dto/MaterialPriceScrapeResponse.java

@@ -0,0 +1,27 @@
+package com.jeeplus.assess.materialPrice.client.dto;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.List;
+
+/**
+ * Python 抓取服务的整批响应。
+ *
+ * success=至少一个抓取器正常完成;partial=部分抓取器失败;
+ * scrapers 里保存每个抓取器的状态,用于生成同步日志。
+ */
+@Data
+public class MaterialPriceScrapeResponse implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private Boolean success;
+    private Boolean partial;
+    private String requestId;
+    private String targetDate;
+    private Integer total;
+    private List<MaterialPriceItemDTO> items;
+    private List<MaterialPriceScraperResultDTO> scrapers;
+    private List<String> errors;
+}

+ 36 - 0
jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/client/dto/MaterialPriceScraperResultDTO.java

@@ -0,0 +1,36 @@
+package com.jeeplus.assess.materialPrice.client.dto;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.List;
+
+/**
+ * 单个抓取器的执行结果,Java 用它判定 SUCCESS / PARTIAL / NO_DATA / FAILED。
+ */
+@Data
+public class MaterialPriceScraperResultDTO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private String scraperKey;
+    private String siteCode;
+    private String siteName;
+
+    /** SUCCESS / NO_DATA / FAILED */
+    private String status;
+
+    /** 本次抓到的数据条数 */
+    private Integer count;
+
+    /** 该抓取器实际拿到的最新数据日期 */
+    private String dataDate;
+    private String sourceUrl;
+    private Long elapsedMs;
+
+    /** 非致命提示,如「山东无数据已回退全国」 */
+    private List<String> notes;
+
+    /** 失败原因(status=FAILED 时) */
+    private String error;
+}

+ 93 - 0
jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/controller/MaterialPriceController.java

@@ -0,0 +1,93 @@
+package com.jeeplus.assess.materialPrice.controller;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.jeeplus.assess.materialPrice.domain.MaterialPriceRecord;
+import com.jeeplus.assess.materialPrice.service.MaterialPriceRecordService;
+import com.jeeplus.assess.materialPrice.service.MaterialPriceSyncService;
+import com.jeeplus.assess.materialPrice.service.dto.MaterialPriceOptionsDTO;
+import com.jeeplus.assess.materialPrice.service.dto.MaterialPricePointDTO;
+import com.jeeplus.assess.materialPrice.service.dto.MaterialPriceQueryDTO;
+import com.jeeplus.logging.annotation.ApiLog;
+import com.jeeplus.logging.constant.enums.LogTypeEnum;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 材料价格对外接口(经 Gateway 的 /assess-server 前缀访问)。
+ *
+ */
+@Api(tags = "材料价格")
+@RestController
+@RequestMapping("/materialPrice/record")
+public class MaterialPriceController {
+
+    @Resource
+    private MaterialPriceRecordService recordService;
+
+    @Resource
+    private MaterialPriceSyncService syncService;
+
+    /**
+     * 站点/分类/省份/城市/物料下拉选项。
+     * siteCode、category、province 用于级联收窄,均可为空。
+     */
+    @ApiOperation(value = "站点/分类/省份/城市/物料下拉选项")
+    @PreAuthorize("hasAuthority('program:materialPrice:list')")
+    @GetMapping("/options")
+    public ResponseEntity<MaterialPriceOptionsDTO> options(
+            @RequestParam(value = "siteCode", required = false) String siteCode,
+            @RequestParam(value = "category", required = false) String category,
+            @RequestParam(value = "province", required = false) String province) {
+        return ResponseEntity.ok(recordService.options(siteCode, category, province));
+    }
+
+    /**
+     * 分页列表。current/size/orders 由 MyBatis-Plus 的 Page 自动绑定。
+     */
+    @ApiOperation(value = "分页查询材料价格")
+    @PreAuthorize("hasAuthority('program:materialPrice:list')")
+    @GetMapping("/list")
+    public ResponseEntity<IPage<MaterialPriceRecord>> list(Page<MaterialPriceRecord> page,
+                                                           MaterialPriceQueryDTO query) {
+        return ResponseEntity.ok(recordService.pageQuery(page, query));
+    }
+
+    /**
+     * 单站点 + 单物料的历史价格点,供走势图使用。
+     */
+    @ApiOperation(value = "查询单站点单物料历史价格")
+    @PreAuthorize("hasAuthority('program:materialPrice:list')")
+    @GetMapping("/history")
+    public ResponseEntity<List<MaterialPricePointDTO>> history(MaterialPriceQueryDTO query) {
+        return ResponseEntity.ok(recordService.history(query));
+    }
+
+    /**
+     * 管理员手动采集。
+     *
+     * 返回 HTTP 200 + 业务 code:code=200 表示采集完成(可能 partial=true),
+     * code=409 表示已有采集在执行,code=500 表示失败,前端按 code 提示。
+     *
+     * @param date 可选,yyyy-MM-dd;为空表示取各站点最新数据
+     */
+    @ApiOperation(value = "立即采集材料价格")
+    @ApiLog(value = "手动采集材料价格", type = LogTypeEnum.SAVE)
+    @PreAuthorize("hasAuthority('program:materialPrice:sync')")
+    @PostMapping("/sync")
+    public ResponseEntity<Map<String, Object>> sync(
+            @RequestParam(value = "date", required = false) String date) {
+        return ResponseEntity.ok(syncService.sync(date, MaterialPriceSyncService.TRIGGER_MANUAL));
+    }
+}

+ 29 - 0
jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/controller/MaterialPriceFeignController.java

@@ -0,0 +1,29 @@
+package com.jeeplus.assess.materialPrice.controller;
+
+import com.jeeplus.assess.materialPrice.service.MaterialPriceSyncService;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.Map;
+
+/**
+ * 内部 Feign 接口:供 XXL 定时任务调用。
+ *
+ * 不走 Gateway、不加 @PreAuthorize(服务间调用没有登录态),
+ * 只依赖内网隔离;返回结构与前端手动采集保持一致,便于 job 统一判断 code。
+ */
+@RestController
+@RequestMapping("/feign/assess/materialPrice")
+public class MaterialPriceFeignController {
+
+    @Resource
+    private MaterialPriceSyncService syncService;
+
+    @PostMapping("/sync")
+    public Map<String, Object> sync(@RequestParam(value = "date", required = false) String date) {
+        return syncService.sync(date, MaterialPriceSyncService.TRIGGER_XXL);
+    }
+}

+ 69 - 0
jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/domain/MaterialPriceRecord.java

@@ -0,0 +1,69 @@
+package com.jeeplus.assess.materialPrice.domain;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.jeeplus.core.domain.BaseEntity;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.Date;
+
+/**
+ * 材料价格记录,对应表 material_price_record。
+ *
+ * 主键沿用 BaseEntity 的 String id(UUID),审计字段由 MyMetaObjectHandler 兜底填充;
+ * 表前缀 material_ 已被租户拦截器忽略,因此数据全局共享。
+ */
+@Data
+@TableName("material_price_record")
+public class MaterialPriceRecord extends BaseEntity {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 站点编码:zgfp / ffhsw / hss */
+    private String siteCode;
+    private String siteName;
+    private String category;
+
+    /** 省份(筛选用),所有站点都有 */
+    private String province;
+
+    /** 城市(筛选用),站点没有城市信息时为空串(如中果废品网只有省份) */
+    private String city;
+    private String materialName;
+
+    /** 规格/牌号,没有则为空串 */
+    private String spec;
+
+    /** 单值价格:废废网有值,其余站点为 null */
+    private BigDecimal price;
+
+    /** 最低价/最高价:中果、回收商网有值,废废网为 null */
+    private BigDecimal priceLow;
+    private BigDecimal priceHigh;
+
+    private String priceUnit;
+
+    /** 1 含税 / 0 不含税 / null 未声明 */
+    private Integer taxIncluded;
+
+    /** 站点「备注/其他说明」原文,只展示、不参与去重 */
+    private String remark;
+
+    /** 数据日期,来自文章日期 / 列表行日期,不是任务执行日期 */
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    private LocalDate priceDate;
+
+    private String sourceUrl;
+
+    /** 采集批次号,与 material_price_sync_log.batch_no 对应 */
+    private String batchNo;
+
+    /** 实际采集时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date scrapedAt;
+
+    /** 幂等键:md5(站点|分类|物料|规格|省份|城市|含税|日期),唯一索引去重 */
+    private String dedupeKey;
+}

+ 54 - 0
jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/domain/MaterialPriceSyncLog.java

@@ -0,0 +1,54 @@
+package com.jeeplus.assess.materialPrice.domain;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.jeeplus.core.domain.BaseEntity;
+import lombok.Data;
+
+import java.time.LocalDate;
+import java.util.Date;
+
+/**
+ * 材料价格采集日志。
+ *
+ * 每次同步(XXL 定时或管理员手动)插入一条 RUNNING 记录,结束后更新为最终状态,
+ * 用于排查「某天为什么没数据」以及统计各抓取器的结果。
+ */
+@Data
+@TableName("material_price_sync_log")
+public class MaterialPriceSyncLog extends BaseEntity {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 采集批次号,与 material_price_record.batch_no 对应 */
+    private String batchNo;
+
+    /** 任务目标日期;为空表示取各站点最新数据 */
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    private LocalDate requestDate;
+
+    /** 实际数据日期:各抓取器 dataDate 的最大值 */
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    private LocalDate priceDate;
+
+    /** 触发方式:XXL / MANUAL */
+    private String triggerType;
+
+    /** 运行状态:RUNNING / SUCCESS / PARTIAL / NO_DATA / FAILED */
+    private String status;
+
+    private Integer totalCount;
+    private Integer savedCount;
+    private Integer failedCount;
+
+    /** 各抓取器执行结果的 JSON 原文,便于回溯单个站点 */
+    private String siteResult;
+
+    private String errorMsg;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date startTime;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date endTime;
+}

+ 18 - 0
jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/enums/MaterialPriceSyncStatus.java

@@ -0,0 +1,18 @@
+package com.jeeplus.assess.materialPrice.enums;
+
+/**
+ * 采集状态,同时用于同步日志和单个抓取器的结果。
+ *
+ * RUNNING  进行中(日志初始状态)
+ * SUCCESS  至少一个抓取器成功,且没有失败
+ * PARTIAL  部分抓取器失败(有成功也有失败)
+ * NO_DATA  所有抓取器都正常执行,但目标日期没有数据
+ * FAILED   全部抓取器失败,或调用抓取服务/入库异常
+ */
+public enum MaterialPriceSyncStatus {
+    RUNNING,
+    SUCCESS,
+    PARTIAL,
+    NO_DATA,
+    FAILED
+}

+ 28 - 0
jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/mapper/MaterialPriceRecordMapper.java

@@ -0,0 +1,28 @@
+package com.jeeplus.assess.materialPrice.mapper;
+
+import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.jeeplus.assess.materialPrice.domain.MaterialPriceRecord;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 材料价格记录 Mapper。
+ *
+ * 表名前缀 material_ 已被租户拦截器忽略,这里再加 @InterceptorIgnore 双保险,
+ * 避免后续拦截器调整后给 SQL 拼上 tenant_id 条件。
+ */
+@Mapper
+@InterceptorIgnore(tenantLine = "true")
+public interface MaterialPriceRecordMapper extends BaseMapper<MaterialPriceRecord> {
+
+    /**
+     * 批量幂等写入:靠 dedupe_key 唯一索引 + ON DUPLICATE KEY UPDATE 实现「同一天重复抓取只更新」。
+     *
+     * @param list 同一批次内 dedupe_key 已去重的记录(调用方保证)
+     * @return 受影响行数(MySQL 的新增计 1、更新计 2,仅用于监控,不作为业务条数)
+     */
+    int upsertBatch(@Param("list") List<MaterialPriceRecord> list);
+}

+ 14 - 0
jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/mapper/MaterialPriceSyncLogMapper.java

@@ -0,0 +1,14 @@
+package com.jeeplus.assess.materialPrice.mapper;
+
+import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.jeeplus.assess.materialPrice.domain.MaterialPriceSyncLog;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 采集日志 Mapper,只有基础 CRUD(插入 RUNNING、结束时 updateById)。
+ */
+@Mapper
+@InterceptorIgnore(tenantLine = "true")
+public interface MaterialPriceSyncLogMapper extends BaseMapper<MaterialPriceSyncLog> {
+}

+ 43 - 0
jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/mapper/xml/MaterialPriceRecordMapper.xml

@@ -0,0 +1,43 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<!--
+  材料价格批量幂等写入。
+  依赖唯一索引 uk_mpr_dedupe(dedupe_key):
+    - 新记录的 dedupe_key 不冲突 -> INSERT;
+    - 同一天重复抓取 -> ON DUPLICATE KEY UPDATE 只刷新价格/批次/采集时间等可变字段;
+    - 被逻辑删除的旧记录命中唯一键时也会被重新激活(del_flag = 0)。
+  注意:id 不更新,避免已经关联的主键变化。
+-->
+<mapper namespace="com.jeeplus.assess.materialPrice.mapper.MaterialPriceRecordMapper">
+
+    <insert id="upsertBatch">
+        INSERT INTO material_price_record
+        (id, site_code, site_name, category, province, city, material_name, spec,
+         price, price_low, price_high, price_unit, tax_included, remark, price_date,
+         source_url, batch_no, scraped_at, dedupe_key,
+         create_time, create_by_id, update_time, update_by_id, del_flag, tenant_id)
+        VALUES
+        <foreach collection="list" item="it" separator=",">
+            (#{it.id}, #{it.siteCode}, #{it.siteName}, #{it.category}, #{it.province}, #{it.city},
+             #{it.materialName}, #{it.spec}, #{it.price}, #{it.priceLow}, #{it.priceHigh},
+             #{it.priceUnit}, #{it.taxIncluded}, #{it.remark}, #{it.priceDate},
+             #{it.sourceUrl}, #{it.batchNo}, #{it.scrapedAt}, #{it.dedupeKey},
+             #{it.createTime}, #{it.createById}, #{it.updateTime}, #{it.updateById},
+             #{it.delFlag}, #{it.tenantId})
+        </foreach>
+        ON DUPLICATE KEY UPDATE
+            price = VALUES(price),
+            price_low = VALUES(price_low),
+            price_high = VALUES(price_high),
+            price_unit = VALUES(price_unit),
+            tax_included = VALUES(tax_included),
+            remark = VALUES(remark),
+            site_name = VALUES(site_name),
+            source_url = VALUES(source_url),
+            batch_no = VALUES(batch_no),
+            scraped_at = VALUES(scraped_at),
+            update_time = NOW(),
+            update_by_id = VALUES(update_by_id),
+            del_flag = 0
+    </insert>
+</mapper>

+ 218 - 0
jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/service/MaterialPriceRecordService.java

@@ -0,0 +1,218 @@
+package com.jeeplus.assess.materialPrice.service;
+
+import cn.hutool.core.util.StrUtil;
+import cn.hutool.crypto.digest.DigestUtil;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.jeeplus.assess.materialPrice.domain.MaterialPriceRecord;
+import com.jeeplus.assess.materialPrice.mapper.MaterialPriceRecordMapper;
+import com.jeeplus.assess.materialPrice.service.dto.MaterialPriceOptionsDTO;
+import com.jeeplus.assess.materialPrice.service.dto.MaterialPricePointDTO;
+import com.jeeplus.assess.materialPrice.service.dto.MaterialPriceQueryDTO;
+import com.jeeplus.assess.materialPrice.service.dto.MaterialPriceSiteDTO;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+/**
+ * 材料价格的查询与写入服务。
+ *
+ * 查询侧提供分页列表、走势图和下拉选项;写入侧只做幂等 upsert,
+ * 业务数据由同步服务(MaterialPriceSyncService)准备。
+ */
+@Service
+public class MaterialPriceRecordService extends ServiceImpl<MaterialPriceRecordMapper, MaterialPriceRecord> {
+
+    /** 单条 SQL 的最大行数,避免 packets 过大 */
+    private static final int BATCH_SIZE = 200;
+
+    /**
+     * 分页查询。排序固定为「数据日期倒序 + 采集时间倒序」,最新的数据排在最前。
+     */
+    public IPage<MaterialPriceRecord> pageQuery(Page<MaterialPriceRecord> page, MaterialPriceQueryDTO query) {
+        QueryWrapper<MaterialPriceRecord> wrapper = buildWrapper(query, true);
+        wrapper.orderByDesc("price_date").orderByDesc("scraped_at");
+        return this.page(page, wrapper);
+    }
+
+    /**
+     * 查询某个站点 + 物料的走势数据点。
+     *
+     * siteCode 与 materialName 必填,其余条件(地区/分类/含税/规格/日期)由前端
+     * 按当前行带入,避免同一天不同地区的数据点混在一起。
+     * 最多返回 400 个点,防止异常查询把图表撑爆。
+     */
+    public List<MaterialPricePointDTO> history(MaterialPriceQueryDTO query) {
+        if (StringUtils.isBlank(query.getSiteCode()) || StringUtils.isBlank(query.getMaterialName())) {
+            return Collections.emptyList();
+        }
+        QueryWrapper<MaterialPriceRecord> wrapper = new QueryWrapper<>();
+        wrapper.select("price_date", "price", "price_low", "price_high", "price_unit", "tax_included");
+        // 走势图里规格用精确匹配,列表页才是模糊匹配
+        buildWrapper(query, false, wrapper);
+        wrapper.orderByAsc("price_date").orderByDesc("scraped_at").last("LIMIT 400");
+
+        return this.list(wrapper).stream().map(record -> {
+            MaterialPricePointDTO point = new MaterialPricePointDTO();
+            point.setPriceDate(record.getPriceDate());
+            point.setPrice(record.getPrice());
+            point.setPriceLow(record.getPriceLow());
+            point.setPriceHigh(record.getPriceHigh());
+            point.setPriceUnit(record.getPriceUnit());
+            point.setTaxIncluded(record.getTaxIncluded());
+            return point;
+        }).collect(Collectors.toList());
+    }
+
+    /**
+     * 筛选框下拉选项。
+     *
+     * sites 始终返回全部站点(否则选中站点后站点下拉会只剩一项);
+     * categories 按站点过滤,provinces/materials 再按分类收窄,
+     * cities 还要再按省份收窄,保证只列出当前省份下的城市。
+     */
+    public MaterialPriceOptionsDTO options(String siteCode, String category, String province) {
+        MaterialPriceOptionsDTO dto = new MaterialPriceOptionsDTO();
+        dto.setSites(listSites());
+        dto.setCategories(distinctColumn("category", siteCode, null, null));
+        dto.setProvinces(distinctColumn("province", siteCode, category, null));
+        dto.setCities(distinctColumn("city", siteCode, category, province));
+        dto.setMaterials(distinctColumn("material_name", siteCode, category, null));
+        return dto;
+    }
+
+    /**
+     * 批量幂等写入。
+     *
+     * 1. 先按 dedupe_key 在内存里去重(同一次抓取里可能有多条完全相同的数据);
+     * 2. 分片写入,每片 200 条;
+     * 3. 返回的是「写入的记录数」,不是数据库受影响行数。
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public int upsertBatch(List<MaterialPriceRecord> records) {
+        if (records == null || records.isEmpty()) {
+            return 0;
+        }
+        Map<String, MaterialPriceRecord> unique = new LinkedHashMap<>();
+        for (MaterialPriceRecord record : records) {
+            if (StringUtils.isBlank(record.getDedupeKey())) {
+                record.setDedupeKey(buildDedupeKey(record));
+            }
+            unique.putIfAbsent(record.getDedupeKey(), record);
+        }
+        List<MaterialPriceRecord> list = new ArrayList<>(unique.values());
+        for (int from = 0; from < list.size(); from += BATCH_SIZE) {
+            int to = Math.min(from + BATCH_SIZE, list.size());
+            baseMapper.upsertBatch(list.subList(from, to));
+        }
+        return list.size();
+    }
+
+    /**
+     * 生成幂等键:md5(站点|分类|物料|规格|省份|城市|含税|日期),统一转小写。
+     *
+     * tax 为 null 时用 "n" 占位,保证「未声明」也能稳定匹配;
+     * remark 不进幂等键(同名物料的备注可能随站点文案变化)。
+     */
+    public static String buildDedupeKey(MaterialPriceRecord record) {
+        String tax = record.getTaxIncluded() == null ? "n" : String.valueOf(record.getTaxIncluded());
+        String raw = String.join("|",
+                StrUtil.nullToEmpty(record.getSiteCode()),
+                StrUtil.nullToEmpty(record.getCategory()),
+                StrUtil.nullToEmpty(record.getMaterialName()),
+                StrUtil.nullToEmpty(record.getSpec()),
+                StrUtil.nullToEmpty(record.getProvince()),
+                StrUtil.nullToEmpty(record.getCity()),
+                tax,
+                record.getPriceDate() == null ? "" : record.getPriceDate().toString());
+        return DigestUtil.md5Hex(raw.toLowerCase());
+    }
+
+    private QueryWrapper<MaterialPriceRecord> buildWrapper(MaterialPriceQueryDTO query, boolean fuzzySpec) {
+        QueryWrapper<MaterialPriceRecord> wrapper = new QueryWrapper<>();
+        buildWrapper(query, fuzzySpec, wrapper);
+        return wrapper;
+    }
+
+    /**
+     * 组装查询条件。空字符串一律忽略(前端清空下拉后可能传空串)。
+     *
+     * @param fuzzySpec true=规格模糊匹配(列表页);false=精确匹配(走势图)
+     */
+    private void buildWrapper(MaterialPriceQueryDTO query, boolean fuzzySpec, QueryWrapper<MaterialPriceRecord> wrapper) {
+        wrapper.eq(StringUtils.isNotBlank(query.getSiteCode()), "site_code", query.getSiteCode());
+        wrapper.eq(StringUtils.isNotBlank(query.getCategory()), "category", query.getCategory());
+        wrapper.eq(StringUtils.isNotBlank(query.getProvince()), "province", query.getProvince());
+        wrapper.eq(StringUtils.isNotBlank(query.getCity()), "city", query.getCity());
+        wrapper.like(StringUtils.isNotBlank(query.getMaterialName()), "material_name", query.getMaterialName());
+        if (StringUtils.isNotBlank(query.getSpec())) {
+            if (fuzzySpec) {
+                wrapper.like("spec", query.getSpec());
+            } else {
+                wrapper.eq("spec", query.getSpec());
+            }
+        }
+        // 含税三态:不传=不限;taxIncludedNull=true 时只查未声明
+        wrapper.eq(query.getTaxIncluded() != null, "tax_included", query.getTaxIncluded());
+        if (Boolean.TRUE.equals(query.getTaxIncludedNull())) {
+            wrapper.isNull("tax_included");
+        }
+        // priceDates 必须成对出现才按区间过滤
+        String[] dates = query.getPriceDates();
+        if (dates != null && dates.length == 2
+                && StringUtils.isNotBlank(dates[0]) && StringUtils.isNotBlank(dates[1])) {
+            wrapper.between("price_date", dates[0], dates[1]);
+        }
+    }
+
+    /** 站点下拉:按 site_code 分组,避免同站点多行重复。 */
+    private List<MaterialPriceSiteDTO> listSites() {
+        QueryWrapper<MaterialPriceRecord> wrapper = new QueryWrapper<>();
+        wrapper.select("site_code", "site_name")
+                .groupBy("site_code", "site_name")
+                .orderByAsc("site_code");
+        return baseMapper.selectMaps(wrapper).stream()
+                .map(row -> new MaterialPriceSiteDTO(stringValue(row, "site_code"), stringValue(row, "site_name")))
+                .filter(site -> StringUtils.isNotBlank(site.getSiteCode()))
+                .collect(Collectors.toList());
+    }
+
+    /**
+     * 某列的去重值,可选按 siteCode / category / province 过滤。
+     * 列名来自内部常量,不接受外部输入,因此可以字符串拼接。
+     */
+    private List<String> distinctColumn(String column, String siteCode, String category, String province) {
+        QueryWrapper<MaterialPriceRecord> wrapper = new QueryWrapper<>();
+        wrapper.select("DISTINCT " + column)
+                .eq(StringUtils.isNotBlank(siteCode), "site_code", siteCode)
+                .eq(StringUtils.isNotBlank(category), "category", category)
+                .eq(StringUtils.isNotBlank(province), "province", province)
+                .isNotNull(column)
+                .ne(column, "")
+                .orderByAsc(column);
+        return baseMapper.selectObjs(wrapper).stream()
+                .filter(Objects::nonNull)
+                .map(String::valueOf)
+                .collect(Collectors.toList());
+    }
+
+    /** selectMaps 的 key 大小写依赖驱动,这里做不区分大小写的取值。 */
+    private static String stringValue(Map<String, Object> row, String column) {
+        for (Map.Entry<String, Object> entry : row.entrySet()) {
+            if (column.equalsIgnoreCase(entry.getKey())) {
+                return entry.getValue() == null ? "" : String.valueOf(entry.getValue());
+            }
+        }
+        return "";
+    }
+}

+ 413 - 0
jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/service/MaterialPriceSyncService.java

@@ -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;
+    }
+}

+ 25 - 0
jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/service/dto/MaterialPriceOptionsDTO.java

@@ -0,0 +1,25 @@
+package com.jeeplus.assess.materialPrice.service.dto;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.List;
+
+/**
+ * 前端筛选框的下拉选项。
+ *
+ * sites 始终返回全部站点(避免选中站点后下拉只剩一个);
+ * 其余选项按 siteCode / category 级联过滤,cities 还要再按 province 收窄。
+ */
+@Data
+public class MaterialPriceOptionsDTO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private List<MaterialPriceSiteDTO> sites;
+    private List<String> categories;
+    private List<String> provinces;
+    /** 城市:按 siteCode/category/province 级联收窄 */
+    private List<String> cities;
+    private List<String> materials;
+}

+ 29 - 0
jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/service/dto/MaterialPricePointDTO.java

@@ -0,0 +1,29 @@
+package com.jeeplus.assess.materialPrice.service.dto;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.time.LocalDate;
+
+/**
+ * 走势图上的一个价格点,只返回画图需要的字段。
+ *
+ * price 与 priceLow/priceHigh 按站点不同:废废网只有 price,
+ * 中果与回收商网只有最低/最高价,前端按实际非空字段动态出线。
+ */
+@Data
+public class MaterialPricePointDTO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    private LocalDate priceDate;
+
+    private BigDecimal price;
+    private BigDecimal priceLow;
+    private BigDecimal priceHigh;
+    private String priceUnit;
+    private Integer taxIncluded;
+}

+ 44 - 0
jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/service/dto/MaterialPriceQueryDTO.java

@@ -0,0 +1,44 @@
+package com.jeeplus.assess.materialPrice.service.dto;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 材料价格查询条件,列表、走势、下拉选项共用。
+ *
+ * priceDates 由前端 daterange 序列化成 priceDates[0]/priceDates[1],
+ * 两个值都存在时才按区间过滤。
+ */
+@Data
+public class MaterialPriceQueryDTO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 站点编码:zgfp / ffhsw / hss */
+    private String siteCode;
+
+    /** 分类:废铁 / 废电表 / ... */
+    private String category;
+
+    /** 省份(筛选用) */
+    private String province;
+
+    /** 城市(筛选用),站点没有城市信息的数据该字段为空 */
+    private String city;
+
+    /** 物料名称,列表页用模糊匹配,走势图用精确匹配 */
+    private String materialName;
+
+    /** 规格,列表页模糊匹配,走势图精确匹配 */
+    private String spec;
+
+    /** 是否含税:1 含税 / 0 不含税 / null 不限制 */
+    private Integer taxIncluded;
+
+    /** 为 true 时只查「未声明」含税状态的数据 */
+    private Boolean taxIncludedNull;
+
+    /** 数据日期区间 [开始, 结束] */
+    private String[] priceDates;
+}

+ 19 - 0
jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/service/dto/MaterialPriceSiteDTO.java

@@ -0,0 +1,19 @@
+package com.jeeplus.assess.materialPrice.service.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 站点下拉项:value 用 siteCode,label 用 siteName。
+ */
+@Data
+@AllArgsConstructor
+public class MaterialPriceSiteDTO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private String siteCode;
+    private String siteName;
+}

+ 62 - 0
jeeplus-modules/jeeplus-xxl-job-executor-sample/src/main/java/com/xxl/job/executor/service/jobhandler/MaterialPriceSyncJob.java

@@ -0,0 +1,62 @@
+package com.xxl.job.executor.service.jobhandler;
+
+import cn.hutool.extra.spring.SpringUtil;
+import com.alibaba.fastjson.JSON;
+import com.xxl.job.core.context.XxlJobHelper;
+import com.xxl.job.core.handler.annotation.XxlJob;
+import com.xxl.job.executor.feign.AssessFeignApi;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+import java.util.Map;
+
+/**
+ * 材料价格定时采集任务。
+ *
+ * 任务参数(可选):目标日期 yyyy-MM-dd;留空表示抓各站点最新数据。
+ * 失败语义:
+ * - code != 200(全部抓取器失败 / 抓取服务不可用)→ handleFail,由 XXL 按配置重试;
+ * - code = 200 但 partial=true → 记录 WARN,不重试,避免一个坏站点阻塞每日任务;
+ * - NO_DATA 或数据滞后超过 3 天 → 只打 WARN 日志,方便在调度日志里发现站点改版。
+ */
+@Component
+public class MaterialPriceSyncJob {
+
+    private static final Logger logger = LoggerFactory.getLogger(MaterialPriceSyncJob.class);
+
+    @XxlJob("syncMaterialPriceJob")
+    public void syncMaterialPriceJob() {
+        String date = StringUtils.trimToNull(XxlJobHelper.getJobParam());
+        XxlJobHelper.log("开始同步材料价格, date={}", date);
+        try {
+            // 通过 Feign 调用 assess 模块的内部接口,前端与 XXL 共用同一套采集逻辑
+            Map<String, Object> result = SpringUtil.getBean(AssessFeignApi.class).syncMaterialPrice(date);
+            XxlJobHelper.log("同步结果: {}", JSON.toJSONString(result));
+            if (result == null) {
+                XxlJobHelper.handleFail("assess 无响应");
+                return;
+            }
+            if (!"200".equals(String.valueOf(result.get("code")))) {
+                XxlJobHelper.handleFail("同步失败: " + JSON.toJSONString(result));
+                return;
+            }
+            if (Boolean.TRUE.equals(result.get("partial"))) {
+                XxlJobHelper.log("WARN 部分抓取器失败, batchNo={}", result.get("batchNo"));
+                return;
+            }
+            if ("NO_DATA".equals(result.get("status"))) {
+                XxlJobHelper.log("WARN 目标日期无数据, requestDate={}", result.get("requestDate"));
+            }
+            Object lag = result.get("lagDays");
+            if (lag instanceof Number && ((Number) lag).longValue() > 3) {
+                XxlJobHelper.log("WARN 数据滞后 {} 天, dataDate={}", lag, result.get("dataDate"));
+            }
+        } catch (Exception e) {
+            logger.error("材料价格同步任务异常", e);
+            XxlJobHelper.log(e);
+            XxlJobHelper.handleFail("同步异常: " + e.getMessage());
+        }
+    }
+}