Преглед изворни кода

评估-抓取历史物料价格功能

Alex пре 3 дана
родитељ
комит
5e4ec77751
19 измењених фајлова са 1229 додато и 69005 уклоњено
  1. 44 0
      docs/sql/README.md
  2. 0 68930
      docs/sql/addsql/20261105.sql
  3. 4 4
      docs/sql/addsql/20261109.sql
  4. 35 0
      docs/sql/addsql/20261109_02.sql
  5. 8 0
      docs/sql/addsql/20261109_03.sql
  6. 6 2
      docs/sql/fullsql/20261104.sql
  7. 48 13
      jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/client/MaterialPriceScraperClient.java
  8. 0 2
      jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/client/dto/MaterialPriceItemDTO.java
  9. 9 0
      jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/client/dto/MaterialPriceScrapeRequest.java
  10. 60 0
      jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/config/MaterialPriceAsyncConfig.java
  11. 33 10
      jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/controller/MaterialPriceController.java
  12. 2 1
      jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/controller/MaterialPriceFeignController.java
  13. 0 3
      jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/domain/MaterialPriceRecord.java
  14. 17 0
      jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/domain/MaterialPriceSyncLog.java
  15. 2 3
      jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/mapper/xml/MaterialPriceRecordMapper.xml
  16. 1 2
      jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/service/MaterialPriceRecordService.java
  17. 945 35
      jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/service/MaterialPriceSyncService.java
  18. 8 0
      jeeplus-modules/jeeplus-assess/src/main/resources/application-assess-development.yml
  19. 7 0
      jeeplus-modules/jeeplus-assess/src/main/resources/application-assess-production.yml

+ 44 - 0
docs/sql/README.md

@@ -0,0 +1,44 @@
+# SQL 脚本目录说明
+
+本目录存放数据库变更脚本,按用途分两个子目录:
+
+```
+docs/sql/
+├── addsql/    增量脚本:在已有数据库上执行的变更
+└── fullsql/   全量脚本:从空库一次性初始化到当前版本
+```
+
+## addsql —— 增量脚本
+
+日常开发、迭代产生的数据库变更都放这里,例如:
+
+- 新建表、新增/修改字段、索引
+- 字典、菜单、权限等初始化数据
+- 数据修复、历史数据补录(如材料价格补采的批量 INSERT)
+
+约定:
+
+1. 文件名用变更日期:`YYYYMMDD.sql`;同一天多次提交用 `YYYYMMDD_02.sql` 递增。
+2. 只写本次变更的内容,不要重复写已存在的结构。
+3. 优先写成可重复执行的语句(`CREATE TABLE IF NOT EXISTS` 等)。MySQL 的
+   `ADD/DROP COLUMN` 不支持 `IF NOT EXISTS`,这类脚本直接写静态 DDL 即可,
+   但必须在文件头注明「仅执行一次」,并写清停服/部署顺序。
+4. 上线时按文件名日期顺序依次执行,不要跳序;同日多个脚本用 `_02`、`_03` 递增。
+5. 大文件(批量数据导入)也放这里,文件头注明数据范围与来源。
+6. 清库、重置类脚本(`TRUNCATE` / `DELETE` 全表)会让每次执行都丢数据,
+   不要和结构变更混在同一个脚本里,单独成文件并在文件头显著标注影响范围。
+
+## fullsql —— 全量脚本
+
+用于新环境初始化、测试库重建、交付部署。约定:
+
+1. 一个脚本从空库直接建到与当前代码匹配的完整结构(表、索引、基础数据)。
+2. 文件名同样用日期:`YYYYMMDD.sql`。
+3. 每次结构有变更,除了在 `addsql` 增加量脚本外,要同步更新 `fullsql`,
+   保证新环境不需要再逐个补历史增量。
+
+## 通用要求
+
+- 每个脚本文件头写清楚:用途、关联需求/工单、目标库(如 `xg_saas`)。
+- 生产环境执行前先备份,执行后核对表结构与数据量。
+- 不要把本地调试数据、带真实账号密码的配置写进脚本。

Разлика између датотеке није приказан због своје велике величине
+ 0 - 68930
docs/sql/addsql/20261105.sql


+ 4 - 4
docs/sql/addsql/20261109.sql

@@ -44,15 +44,15 @@ ALTER TABLE `finance_invoice_base`
 -- 用派生表而非同表子查询:MySQL 不允许在 UPDATE 的子查询里引用被更新的同一张表。
 -- 用派生表而非同表子查询:MySQL 不允许在 UPDATE 的子查询里引用被更新的同一张表。
 -- WHERE b.account IS NULL 保证脚本可重复执行。
 -- WHERE b.account IS NULL 保证脚本可重复执行。
 UPDATE `finance_invoice_base` b
 UPDATE `finance_invoice_base` b
-JOIN (
+    JOIN (
     SELECT `invoice_id`
     SELECT `invoice_id`
     FROM `finance_invoice_base`
     FROM `finance_invoice_base`
     WHERE `del_flag` = 0
     WHERE `del_flag` = 0
     GROUP BY `invoice_id`
     GROUP BY `invoice_id`
     HAVING COUNT(*) = 1
     HAVING COUNT(*) = 1
-) single ON single.`invoice_id` = b.`invoice_id`
-JOIN `finance_invoice` f ON f.`id` = b.`invoice_id` AND f.`del_flag` = 0
-SET b.`account` = f.`account`
+    ) single ON single.`invoice_id` = b.`invoice_id`
+    JOIN `finance_invoice` f ON f.`id` = b.`invoice_id` AND f.`del_flag` = 0
+    SET b.`account` = f.`account`
 WHERE b.`del_flag` = 0
 WHERE b.`del_flag` = 0
   AND b.`account` IS NULL
   AND b.`account` IS NULL
   AND f.`account` IS NOT NULL
   AND f.`account` IS NOT NULL

+ 35 - 0
docs/sql/addsql/20261109_02.sql

@@ -0,0 +1,35 @@
+-- 用途:材料价格采集新增「管理员手动区间采集」所需的表结构,并移除站点备注列
+-- 关联需求:历史区间采集(异步提交 + 进度轮询);material_price_record 删除 remark
+-- 目标库:xg_saas
+-- 说明:仅执行一次。静态 DDL 重复执行会报 Duplicate column / Can't DROP,
+--       后续由迁移台账保证同一环境只跑一次。
+--
+
+-- ----------------------------
+-- 1. 采集日志:新增区间与进度字段
+-- ----------------------------
+ALTER TABLE `material_price_sync_log`
+    ADD COLUMN `range_start` date DEFAULT NULL
+        COMMENT '区间采集开始日;普通定时采集为空' AFTER `request_date`,
+    ADD COLUMN `range_end` date DEFAULT NULL
+        COMMENT '区间采集结束日;普通定时采集为空' AFTER `range_start`,
+    ADD COLUMN `progress_done` int NOT NULL DEFAULT 0
+        COMMENT '区间采集已完成分片数' AFTER `failed_count`,
+    ADD COLUMN `progress_total` int NOT NULL DEFAULT 0
+        COMMENT '区间采集总分片数' AFTER `progress_done`,
+    ADD COLUMN `current_stage` varchar(128) DEFAULT NULL
+        COMMENT '当前阶段,如 hss 2026-06-01~2026-06-07' AFTER `progress_total`;
+
+-- ----------------------------
+-- 2. 采集日志:触发方式注释补充 MANUAL_RANGE
+-- ----------------------------
+ALTER TABLE `material_price_sync_log`
+    MODIFY COLUMN `trigger_type` varchar(16) NOT NULL DEFAULT 'XXL'
+        COMMENT 'XXL/MANUAL_RANGE';
+
+-- ----------------------------
+-- 3. 采集记录:删除 remark 列(站点备注不再入库)
+-- ----------------------------
+ALTER TABLE `material_price_record`
+    DROP COLUMN `remark`;
+

+ 8 - 0
docs/sql/addsql/20261109_03.sql

@@ -0,0 +1,8 @@
+
+-- 用途:清空材料价格的采集记录表与采集日志表
+-- 关联需求:材料价格数据重置(重新采集前清库)
+-- 目标库:xg_saas
+--
+
+TRUNCATE TABLE `material_price_record`;
+TRUNCATE TABLE `material_price_sync_log`;

+ 6 - 2
docs/sql/fullsql/20261104.sql

@@ -16,7 +16,6 @@ CREATE TABLE IF NOT EXISTS `material_price_record` (
   `price_high`    decimal(18,4) DEFAULT NULL COMMENT '最高价',
   `price_high`    decimal(18,4) DEFAULT NULL COMMENT '最高价',
   `price_unit`    varchar(32)  NOT NULL DEFAULT '' COMMENT '单位',
   `price_unit`    varchar(32)  NOT NULL DEFAULT '' COMMENT '单位',
   `tax_included`  tinyint(1)   DEFAULT NULL COMMENT '1含税 0不含税 NULL未声明',
   `tax_included`  tinyint(1)   DEFAULT NULL COMMENT '1含税 0不含税 NULL未声明',
-  `remark`        varchar(255) NOT NULL DEFAULT '' COMMENT '站点备注/其他说明原文',
   `price_date`    date         NOT NULL COMMENT '数据日期',
   `price_date`    date         NOT NULL COMMENT '数据日期',
   `source_url`    varchar(512) NOT NULL DEFAULT '' COMMENT '来源地址',
   `source_url`    varchar(512) NOT NULL DEFAULT '' COMMENT '来源地址',
   `batch_no`      varchar(64)  NOT NULL DEFAULT '' COMMENT '采集批次号',
   `batch_no`      varchar(64)  NOT NULL DEFAULT '' COMMENT '采集批次号',
@@ -39,12 +38,17 @@ CREATE TABLE IF NOT EXISTS `material_price_sync_log` (
   `id`           varchar(64) NOT NULL,
   `id`           varchar(64) NOT NULL,
   `batch_no`     varchar(64) NOT NULL,
   `batch_no`     varchar(64) NOT NULL,
   `request_date` date        DEFAULT NULL COMMENT '任务目标日期;空表示取最新',
   `request_date` date        DEFAULT NULL COMMENT '任务目标日期;空表示取最新',
+  `range_start`  date        DEFAULT NULL COMMENT '区间采集开始日;普通定时采集为空',
+  `range_end`    date        DEFAULT NULL COMMENT '区间采集结束日;普通定时采集为空',
   `price_date`   date        DEFAULT NULL COMMENT '实际数据日期(各抓取器 dataDate 最大值)',
   `price_date`   date        DEFAULT NULL COMMENT '实际数据日期(各抓取器 dataDate 最大值)',
-  `trigger_type` varchar(16) NOT NULL DEFAULT 'XXL' COMMENT 'XXL/MANUAL',
+  `trigger_type` varchar(16) NOT NULL DEFAULT 'XXL' COMMENT 'XXL/MANUAL_RANGE',
   `status`       varchar(16) NOT NULL DEFAULT 'RUNNING' COMMENT 'RUNNING/SUCCESS/PARTIAL/NO_DATA/FAILED',
   `status`       varchar(16) NOT NULL DEFAULT 'RUNNING' COMMENT 'RUNNING/SUCCESS/PARTIAL/NO_DATA/FAILED',
   `total_count`  int         DEFAULT 0,
   `total_count`  int         DEFAULT 0,
   `saved_count`  int         DEFAULT 0,
   `saved_count`  int         DEFAULT 0,
   `failed_count` int         DEFAULT 0,
   `failed_count` int         DEFAULT 0,
+  `progress_done`  int       NOT NULL DEFAULT 0 COMMENT '区间采集已完成分片数',
+  `progress_total` int       NOT NULL DEFAULT 0 COMMENT '区间采集总分片数',
+  `current_stage`  varchar(128) DEFAULT NULL COMMENT '当前阶段,如 hss 2026-06-01~2026-06-07',
   `site_result`  text        DEFAULT NULL COMMENT  '各抓取器结果 JSON',
   `site_result`  text        DEFAULT NULL COMMENT  '各抓取器结果 JSON',
   `error_msg`    text,
   `error_msg`    text,
   `start_time`   datetime    DEFAULT NULL,
   `start_time`   datetime    DEFAULT NULL,

+ 48 - 13
jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/client/MaterialPriceScraperClient.java

@@ -11,12 +11,13 @@ import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Component;
 import org.springframework.stereotype.Component;
 
 
 import java.time.LocalDate;
 import java.time.LocalDate;
+import java.util.List;
 
 
 /**
 /**
  * 调用 Python 抓取服务(xg_material_scraper)的 HTTP 客户端。
  * 调用 Python 抓取服务(xg_material_scraper)的 HTTP 客户端。
  *
  *
  * 前端永远不直连 Python:只有 Java 通过内网调用本客户端,
  * 前端永远不直连 Python:只有 Java 通过内网调用本客户端,
- * XXL 定时任务与管理员「立即采集」共用同一条链路。
+ * XXL 定时任务(最新数据)与管理员区间采集(补历史)共用同一条链路。
  * 配置项在 Nacos 的 material-price.scraper.* 下,未配置时使用本地默认值。
  * 配置项在 Nacos 的 material-price.scraper.* 下,未配置时使用本地默认值。
  */
  */
 @Slf4j
 @Slf4j
@@ -31,13 +32,8 @@ public class MaterialPriceScraperClient {
     @Value("${material-price.scraper.api-key:dev-key-change-me}")
     @Value("${material-price.scraper.api-key:dev-key-change-me}")
     private String apiKey;
     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;
+    /** HTTP 读超时,必须大于 Python 侧整批超时 */
+    private int timeoutMs=600000;
 
 
     /**
     /**
      * 触发一次抓取。
      * 触发一次抓取。
@@ -46,14 +42,46 @@ public class MaterialPriceScraperClient {
      * @return 抓取服务响应;网络错误、HTTP 非 200、内容无法解析时抛 IllegalStateException
      * @return 抓取服务响应;网络错误、HTTP 非 200、内容无法解析时抛 IllegalStateException
      */
      */
     public MaterialPriceScrapeResponse scrape(LocalDate date) {
     public MaterialPriceScrapeResponse scrape(LocalDate date) {
-        if (!enabled) {
-            throw new IllegalStateException("material-price.scraper.enabled=false");
-        }
-
         MaterialPriceScrapeRequest request = new MaterialPriceScrapeRequest();
         MaterialPriceScrapeRequest request = new MaterialPriceScrapeRequest();
         request.setDate(date == null ? null : date.toString());
         request.setDate(date == null ? null : date.toString());
         // Python 侧整批超时略小于 Java 读超时,保证 Java 先收到结构化响应而不是先超时
         // Python 侧整批超时略小于 Java 读超时,保证 Java 先收到结构化响应而不是先超时
         request.setTimeoutSeconds(Math.max(60, timeoutMs / 1000));
         request.setTimeoutSeconds(Math.max(60, timeoutMs / 1000));
+        return execute(request, timeoutMs, "single");
+    }
+
+    /**
+     * 触发一次历史区间抓取(区间采集专用)。
+     *
+     * @param start        区间开始日
+     * @param end          区间结束日
+     * @param scraperKeys  只抓指定抓取器;区间采集按站点分批调用
+     * @param readTimeoutMs 本次调用的读超时,不同站点耗时差异很大,由调用方指定
+     * @param sliceIndex   分片下标(从 0 开始),不分片传 0
+     * @param sliceCount   分片总数,不分片传 1
+     */
+    public MaterialPriceScrapeResponse scrapeRange(LocalDate start, LocalDate end,
+                                                   List<String> scraperKeys,
+                                                   int readTimeoutMs,
+                                                   int sliceIndex, int sliceCount) {
+        MaterialPriceScrapeRequest request = new MaterialPriceScrapeRequest();
+        // Python 侧约定:date 是区间结束日,startDate 是区间开始日
+        request.setDate(end == null ? null : end.toString());
+        request.setStartDate(start == null ? null : start.toString());
+        request.setScraperKeys(scraperKeys);
+        // 废废网按「指数 id 取模」分片,Python 只抓本片指数,避免单次响应过大
+        request.setSliceIndex(sliceIndex);
+        request.setSliceCount(sliceCount);
+        // Python 侧整批超时上限 3600s(废废网分片后每片约 200 个指数),
+        // 且必须小于 Java 读超时,留 10s 余量
+        request.setTimeoutSeconds(Math.max(60, Math.min(3600, readTimeoutMs / 1000 - 10)));
+        return execute(request, readTimeoutMs, "range");
+    }
+
+    private MaterialPriceScrapeResponse execute(MaterialPriceScrapeRequest request,
+                                                int readTimeoutMs, String scene) {
+        long begin = System.currentTimeMillis();
+        log.info("调用抓取服务 scene={} date={} startDate={} keys={} timeoutMs={}",
+                scene, request.getDate(), request.getStartDate(), request.getScraperKeys(), readTimeoutMs);
 
 
         String url = StrUtil.removeSuffix(baseUrl, "/") + "/api/v1/scrape";
         String url = StrUtil.removeSuffix(baseUrl, "/") + "/api/v1/scrape";
         HttpResponse response;
         HttpResponse response;
@@ -63,13 +91,17 @@ public class MaterialPriceScraperClient {
                     .header("Content-Type", "application/json")
                     .header("Content-Type", "application/json")
                     .body(JSON.toJSONString(request), "application/json")
                     .body(JSON.toJSONString(request), "application/json")
                     .setConnectionTimeout(5000)
                     .setConnectionTimeout(5000)
-                    .setReadTimeout(timeoutMs)
+                    .setReadTimeout(readTimeoutMs)
                     .execute();
                     .execute();
         } catch (Exception e) {
         } catch (Exception e) {
+            log.error("调用抓取服务失败 scene={} elapsedMs={}", scene, System.currentTimeMillis() - begin, e);
             throw new IllegalStateException("调用抓取服务失败: " + e.getMessage(), e);
             throw new IllegalStateException("调用抓取服务失败: " + e.getMessage(), e);
         }
         }
 
 
+        long elapsed = System.currentTimeMillis() - begin;
         if (!response.isOk()) {
         if (!response.isOk()) {
+            log.error("抓取服务返回异常 scene={} status={} elapsedMs={} body={}",
+                    scene, response.getStatus(), elapsed, StrUtil.maxLength(response.body(), 500));
             throw new IllegalStateException("抓取服务返回 HTTP " + response.getStatus());
             throw new IllegalStateException("抓取服务返回 HTTP " + response.getStatus());
         }
         }
         try {
         try {
@@ -77,10 +109,13 @@ public class MaterialPriceScraperClient {
             if (result == null) {
             if (result == null) {
                 throw new IllegalStateException("抓取服务返回空内容");
                 throw new IllegalStateException("抓取服务返回空内容");
             }
             }
+            log.info("抓取服务返回 scene={} total={} partial={} elapsedMs={}",
+                    scene, result.getTotal(), result.getPartial(), elapsed);
             return result;
             return result;
         } catch (IllegalStateException e) {
         } catch (IllegalStateException e) {
             throw e;
             throw e;
         } catch (Exception e) {
         } catch (Exception e) {
+            log.error("抓取服务返回内容无法解析 scene={} elapsedMs={}", scene, elapsed, e);
             throw new IllegalStateException("抓取服务返回内容无法解析", e);
             throw new IllegalStateException("抓取服务返回内容无法解析", e);
         }
         }
     }
     }

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

@@ -39,8 +39,6 @@ public class MaterialPriceItemDTO implements Serializable {
     private String priceUnit;
     private String priceUnit;
     /** true 含税 / false 不含税 / null 未声明 */
     /** true 含税 / false 不含税 / null 未声明 */
     private Boolean taxIncluded;
     private Boolean taxIncluded;
-    /** 站点「备注/其他说明」原文,只展示 */
-    private String remark;
     /** yyyy-MM-dd */
     /** yyyy-MM-dd */
     private String priceDate;
     private String priceDate;
     private String sourceUrl;
     private String sourceUrl;

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

@@ -16,6 +16,15 @@ public class MaterialPriceScrapeRequest implements Serializable {
     /** 目标日期 yyyy-MM-dd;为空表示取各站点最新一天 */
     /** 目标日期 yyyy-MM-dd;为空表示取各站点最新一天 */
     private String date;
     private String date;
 
 
+    /** 历史区间开始日 yyyy-MM-dd;与 date 同时给出表示区间采集 */
+    private String startDate;
+
+    /** 区间采集分片下标(从 0 开始):仅废废网这类数据量大的站点使用 */
+    private Integer sliceIndex;
+
+    /** 区间采集分片总数;1 表示不分片 */
+    private Integer sliceCount;
+
     /** 只抓指定抓取器;为空表示全部 */
     /** 只抓指定抓取器;为空表示全部 */
     private List<String> scraperKeys;
     private List<String> scraperKeys;
 
 

+ 60 - 0
jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/config/MaterialPriceAsyncConfig.java

@@ -0,0 +1,60 @@
+package com.jeeplus.assess.materialPrice.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+
+/**
+ * 区间采集使用的线程池。
+ *
+ * 区间采集是异步任务:接口只负责提交,真正抓取在后台线程完成,
+ * 前端通过采集日志轮询进度,因此不会受网关/axios 超时限制。
+ *
+ * - rangeExecutor:整个区间任务只跑一个,配合 Redis 锁防止重复提交;
+ * - siteExecutor:5 个站点并行,站点内部仍然串行分片,避免同一站点被限流;
+ * - lockScheduler:定时给 Redis 锁续期,防止长任务跑到一半锁过期。
+ */
+@Configuration
+public class MaterialPriceAsyncConfig {
+
+    /** 区间任务线程池:单线程,任务内部再按站点并行 */
+    @Bean("materialPriceRangeExecutor")
+    public Executor materialPriceRangeExecutor() {
+        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+        executor.setCorePoolSize(1);
+        executor.setMaxPoolSize(1);
+        // 同一时间只允许一个区间任务,队列只用于极端情况下短暂排队
+        executor.setQueueCapacity(8);
+        executor.setThreadNamePrefix("material-price-range-");
+        executor.setWaitForTasksToCompleteOnShutdown(false);
+        executor.initialize();
+        return executor;
+    }
+
+    /** 站点线程池:5 个站点同时抓取,每个站点内部按分片串行 */
+    @Bean("materialPriceSiteExecutor")
+    public Executor materialPriceSiteExecutor() {
+        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+        executor.setCorePoolSize(5);
+        executor.setMaxPoolSize(5);
+        executor.setQueueCapacity(32);
+        executor.setThreadNamePrefix("material-price-site-");
+        executor.setWaitForTasksToCompleteOnShutdown(false);
+        executor.initialize();
+        return executor;
+    }
+
+    /** 锁续期调度器:守护线程,不阻止 JVM 退出 */
+    @Bean(destroyMethod = "shutdownNow")
+    public ScheduledExecutorService materialPriceLockScheduler() {
+        return Executors.newSingleThreadScheduledExecutor(runnable -> {
+            Thread thread = new Thread(runnable, "material-price-lock");
+            thread.setDaemon(true);
+            return thread;
+        });
+    }
+}

+ 33 - 10
jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/controller/MaterialPriceController.java

@@ -75,19 +75,42 @@ public class MaterialPriceController {
     }
     }
 
 
     /**
     /**
-     * 管理员手动采集。
+     * 提交历史区间采集(管理员)。
      *
      *
-     * 返回 HTTP 200 + 业务 code:code=200 表示采集完成(可能 partial=true),
-     * code=409 表示已有采集在执行,code=500 表示失败,前端按 code 提示。
+     * 只做校验和入队,立即返回批次号;真正的抓取在后台线程执行,
+     * 前端通过 /syncStatus 轮询进度,因此不受网关与 axios 超时限制。
      *
      *
-     * @param date 可选,yyyy-MM-dd;为空表示取各站点最新数据
+     * @param startDate 开始日期 yyyy-MM-dd(必填)
+     * @param endDate   结束日期 yyyy-MM-dd(为空表示今天)
      */
      */
-    @ApiOperation(value = "立即采集材料价格")
-    @ApiLog(value = "手动采集材料价格", type = LogTypeEnum.SAVE)
+    @ApiOperation(value = "提交历史区间采集")
+    @ApiLog(value = "提交材料价格历史区间采集", type = LogTypeEnum.SAVE)
     @PreAuthorize("hasAuthority('program:materialPrice:sync')")
     @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));
+    @PostMapping("/syncRange")
+    public ResponseEntity<Map<String, Object>> syncRange(
+            @RequestParam(value = "startDate") String startDate,
+            @RequestParam(value = "endDate", required = false) String endDate) {
+        return ResponseEntity.ok(syncService.submitRangeSync(startDate, endDate));
+    }
+
+    /**
+     * 查询最近一次区间采集的状态与进度(前端每 3 秒轮询一次)。
+     * 返回 running=true 时前端继续轮询;日志字段放在 log 里。
+     */
+    @ApiOperation(value = "查询区间采集状态")
+    @PreAuthorize("hasAuthority('program:materialPrice:list')")
+    @GetMapping("/syncStatus")
+    public ResponseEntity<Map<String, Object>> syncStatus() {
+        return ResponseEntity.ok(syncService.rangeStatus());
+    }
+
+    /**
+     * 查询当前是否有区间采集在跑,用于页面打开时决定是否需要继续轮询。
+     */
+    @ApiOperation(value = "查询区间采集是否在执行")
+    @PreAuthorize("hasAuthority('program:materialPrice:list')")
+    @GetMapping("/syncRunning")
+    public ResponseEntity<Map<String, Object>> syncRunning() {
+        return ResponseEntity.ok(syncService.runningRangeStatus());
     }
     }
 }
 }

+ 2 - 1
jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/controller/MaterialPriceFeignController.java

@@ -13,7 +13,8 @@ import java.util.Map;
  * 内部 Feign 接口:供 XXL 定时任务调用。
  * 内部 Feign 接口:供 XXL 定时任务调用。
  *
  *
  * 不走 Gateway、不加 @PreAuthorize(服务间调用没有登录态),
  * 不走 Gateway、不加 @PreAuthorize(服务间调用没有登录态),
- * 只依赖内网隔离;返回结构与前端手动采集保持一致,便于 job 统一判断 code。
+ * 只依赖内网隔离;返回结构统一带 code,便于 job 判断是否需要告警。
+ * 历史区间采集只开放给管理员页面,不在这里暴露。
  */
  */
 @RestController
 @RestController
 @RequestMapping("/feign/assess/materialPrice")
 @RequestMapping("/feign/assess/materialPrice")

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

@@ -48,9 +48,6 @@ public class MaterialPriceRecord extends BaseEntity {
     /** 1 含税 / 0 不含税 / null 未声明 */
     /** 1 含税 / 0 不含税 / null 未声明 */
     private Integer taxIncluded;
     private Integer taxIncluded;
 
 
-    /** 站点「备注/其他说明」原文,只展示、不参与去重 */
-    private String remark;
-
     /** 数据日期,来自文章日期 / 列表行日期,不是任务执行日期 */
     /** 数据日期,来自文章日期 / 列表行日期,不是任务执行日期 */
     @JsonFormat(pattern = "yyyy-MM-dd")
     @JsonFormat(pattern = "yyyy-MM-dd")
     private LocalDate priceDate;
     private LocalDate priceDate;

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

@@ -27,6 +27,14 @@ public class MaterialPriceSyncLog extends BaseEntity {
     @JsonFormat(pattern = "yyyy-MM-dd")
     @JsonFormat(pattern = "yyyy-MM-dd")
     private LocalDate requestDate;
     private LocalDate requestDate;
 
 
+    /** 区间采集开始日;普通定时/单日采集为空 */
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    private LocalDate rangeStart;
+
+    /** 区间采集结束日;普通定时/单日采集为空 */
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    private LocalDate rangeEnd;
+
     /** 实际数据日期:各抓取器 dataDate 的最大值 */
     /** 实际数据日期:各抓取器 dataDate 的最大值 */
     @JsonFormat(pattern = "yyyy-MM-dd")
     @JsonFormat(pattern = "yyyy-MM-dd")
     private LocalDate priceDate;
     private LocalDate priceDate;
@@ -41,6 +49,15 @@ public class MaterialPriceSyncLog extends BaseEntity {
     private Integer savedCount;
     private Integer savedCount;
     private Integer failedCount;
     private Integer failedCount;
 
 
+    /** 区间采集已完成的分片数,用于前端进度条 */
+    private Integer progressDone;
+
+    /** 区间采集总分片数 */
+    private Integer progressTotal;
+
+    /** 当前正在抓取的站点与分片,如「hss 2026-06-01~2026-06-07」 */
+    private String currentStage;
+
     /** 各抓取器执行结果的 JSON 原文,便于回溯单个站点 */
     /** 各抓取器执行结果的 JSON 原文,便于回溯单个站点 */
     private String siteResult;
     private String siteResult;
 
 

+ 2 - 3
jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/mapper/xml/MaterialPriceRecordMapper.xml

@@ -13,14 +13,14 @@
     <insert id="upsertBatch">
     <insert id="upsertBatch">
         INSERT INTO material_price_record
         INSERT INTO material_price_record
         (id, site_code, site_name, category, province, city, material_name, spec,
         (id, site_code, site_name, category, province, city, material_name, spec,
-         price, price_low, price_high, price_unit, tax_included, remark, price_date,
+         price, price_low, price_high, price_unit, tax_included, price_date,
          source_url, batch_no, scraped_at, dedupe_key,
          source_url, batch_no, scraped_at, dedupe_key,
          create_time, create_by_id, update_time, update_by_id, del_flag, tenant_id)
          create_time, create_by_id, update_time, update_by_id, del_flag, tenant_id)
         VALUES
         VALUES
         <foreach collection="list" item="it" separator=",">
         <foreach collection="list" item="it" separator=",">
             (#{it.id}, #{it.siteCode}, #{it.siteName}, #{it.category}, #{it.province}, #{it.city},
             (#{it.id}, #{it.siteCode}, #{it.siteName}, #{it.category}, #{it.province}, #{it.city},
              #{it.materialName}, #{it.spec}, #{it.price}, #{it.priceLow}, #{it.priceHigh},
              #{it.materialName}, #{it.spec}, #{it.price}, #{it.priceLow}, #{it.priceHigh},
-             #{it.priceUnit}, #{it.taxIncluded}, #{it.remark}, #{it.priceDate},
+             #{it.priceUnit}, #{it.taxIncluded}, #{it.priceDate},
              #{it.sourceUrl}, #{it.batchNo}, #{it.scrapedAt}, #{it.dedupeKey},
              #{it.sourceUrl}, #{it.batchNo}, #{it.scrapedAt}, #{it.dedupeKey},
              #{it.createTime}, #{it.createById}, #{it.updateTime}, #{it.updateById},
              #{it.createTime}, #{it.createById}, #{it.updateTime}, #{it.updateById},
              #{it.delFlag}, #{it.tenantId})
              #{it.delFlag}, #{it.tenantId})
@@ -31,7 +31,6 @@
             price_high = VALUES(price_high),
             price_high = VALUES(price_high),
             price_unit = VALUES(price_unit),
             price_unit = VALUES(price_unit),
             tax_included = VALUES(tax_included),
             tax_included = VALUES(tax_included),
-            remark = VALUES(remark),
             site_name = VALUES(site_name),
             site_name = VALUES(site_name),
             source_url = VALUES(source_url),
             source_url = VALUES(source_url),
             batch_no = VALUES(batch_no),
             batch_no = VALUES(batch_no),

+ 1 - 2
jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/service/MaterialPriceRecordService.java

@@ -121,8 +121,7 @@ public class MaterialPriceRecordService extends ServiceImpl<MaterialPriceRecordM
     /**
     /**
      * 生成幂等键:md5(站点|分类|物料|规格|省份|城市|含税|日期),统一转小写。
      * 生成幂等键:md5(站点|分类|物料|规格|省份|城市|含税|日期),统一转小写。
      *
      *
-     * tax 为 null 时用 "n" 占位,保证「未声明」也能稳定匹配;
-     * remark 不进幂等键(同名物料的备注可能随站点文案变化)。
+     * tax 为 null 时用 "n" 占位,保证「未声明」也能稳定匹配。
      */
      */
     public static String buildDedupeKey(MaterialPriceRecord record) {
     public static String buildDedupeKey(MaterialPriceRecord record) {
         String tax = record.getTaxIncluded() == null ? "n" : String.valueOf(record.getTaxIncluded());
         String tax = record.getTaxIncluded() == null ? "n" : String.valueOf(record.getTaxIncluded());

Разлика између датотеке није приказан због своје велике величине
+ 945 - 35
jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/service/MaterialPriceSyncService.java


+ 8 - 0
jeeplus-modules/jeeplus-assess/src/main/resources/application-assess-development.yml

@@ -67,3 +67,11 @@ CCPM_TASK:  13,102,39
 INQUIRE_STATUS: ccpm
 INQUIRE_STATUS: ccpm
 
 
 CAS_PATH: http://www.casserver.com:8443
 CAS_PATH: http://www.casserver.com:8443
+
+# ==================== 物料价格采集 ====================
+material-price:
+  scraper:
+    # 抓取服务地址
+    base-url: http://127.0.0.1:8100
+    # 与 Python 的 SCRAPER_API_KEY 保持一致
+    api-key: dev-key-change-me

+ 7 - 0
jeeplus-modules/jeeplus-assess/src/main/resources/application-assess-production.yml

@@ -56,3 +56,10 @@ signature_daily_office_work: 3206161416121062057
 #评估-报告签章流程id
 #评估-报告签章流程id
 signature_report_caregory_id: 3206161481749336766
 signature_report_caregory_id: 3206161481749336766
 
 
+# ==================== 物料价格采集 ====================
+material-price:
+  scraper:
+    # 抓取服务地址
+    base-url: http://127.0.0.1:8100
+    # 与 Python 的 SCRAPER_API_KEY 保持一致
+    api-key: dev-key-change-me