瀏覽代碼

评估-物料价格页面查询接口优化性能

Alex 2 天之前
父節點
當前提交
88ded77044

+ 8 - 0
docs/sql/addsql/20261111.sql

@@ -0,0 +1,8 @@
+-- 材料价格筛选框下拉优化(手工在目标库执行,仓库无迁移框架)
+-- /materialPrice/record/options 现在用一条
+--   SELECT DISTINCT category, province, city FROM material_price_record
+-- 取全量组合,本索引让这条 SQL 走覆盖索引,避免 全表扫描 + 临时表。
+--
+
+ALTER TABLE `material_price_record`
+  ADD INDEX `idx_mpr_option` (`category`, `province`, `city`);

+ 10 - 0
docs/sql/addsql/20261111_02.sql

@@ -0,0 +1,10 @@
+-- 材料价格列表接口(/materialPrice/record/list)查询优化(手工在目标库执行)
+--
+-- 说明:del_flag 放在最前面是因为所有查询都带 del_flag = 0,等值命中最划算;
+--       province 索引把 price_date/scraped_at 放在后面,省内查询可直接按索引顺序取页,
+--       city 放最后作为过滤列,不再需要回表。
+--
+
+ALTER TABLE `material_price_record`
+  ADD INDEX `idx_mpr_list` (`del_flag`, `price_date`, `scraped_at`),
+  ADD INDEX `idx_mpr_province_list` (`del_flag`, `province`, `price_date`, `scraped_at`, `city`);

+ 7 - 8
jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/controller/MaterialPriceController.java

@@ -40,17 +40,16 @@ public class MaterialPriceController {
     private MaterialPriceSyncService syncService;
 
     /**
-     * 站点/分类/省份/城市/物料下拉选项。
-     * siteCode、category、province 用于级联收窄,均可为空。
+     * 筛选框下拉选项:分类、省份、省份 → 城市,以及级联收窄需要的三张关系表。
+     *
+     * 不再接收任何筛选参数:整份选项一次查全并缓存,
+     * 前端切换分类/省份/城市时在本地做级联过滤,不再回后端。
      */
-    @ApiOperation(value = "站点/分类/省份/城市/物料下拉选项")
+    @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));
+    public ResponseEntity<MaterialPriceOptionsDTO> options() {
+        return ResponseEntity.ok(recordService.options());
     }
 
     /**

+ 226 - 43
jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/service/MaterialPriceRecordService.java

@@ -11,17 +11,22 @@ 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 com.jeeplus.common.redis.RedisUtils;
+import lombok.extern.slf4j.Slf4j;
 import org.apache.commons.lang3.StringUtils;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
+import javax.annotation.Resource;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
-import java.util.Objects;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.TreeSet;
 import java.util.stream.Collectors;
 
 /**
@@ -30,6 +35,7 @@ import java.util.stream.Collectors;
  * 查询侧提供分页列表、走势图和下拉选项;写入侧只做幂等 upsert,
  * 业务数据由同步服务(MaterialPriceSyncService)准备。
  */
+@Slf4j
 @Service
 public class MaterialPriceRecordService extends ServiceImpl<MaterialPriceRecordMapper, MaterialPriceRecord> {
 
@@ -37,11 +43,34 @@ public class MaterialPriceRecordService extends ServiceImpl<MaterialPriceRecordM
     private static final int BATCH_SIZE = 200;
 
     /**
+     * 下拉选项缓存 key:v2 增加了级联维度。
+     * 这里不设过期时间——选项只在采集到新分类/地区时才变,
+     * 由采集结束后的 refreshQueryCache 主动覆盖,避免过期后第一次进入页面回源查库。
+     */
+    private static final String OPTIONS_CACHE_KEY = "material-price:options:v2";
+
+    /** 无筛选时的总行数缓存 key:翻页不必每次都 count 全表,采集入库后主动清除 */
+    private static final String COUNT_CACHE_KEY = "material-price:record-count";
+    private static final long COUNT_CACHE_SECONDS = 60L;
+
+    @Resource
+    private RedisUtils redisUtils;
+
+    /**
      * 分页查询。排序固定为「数据日期倒序 + 采集时间倒序」,最新的数据排在最前。
+     *
+     * 无任何筛选条件时总数走缓存:这种查询每次都要统计全表 60 多万行,
+     * 而数据一天只变几次,没必要每翻一页都统计一遍。
      */
     public IPage<MaterialPriceRecord> pageQuery(Page<MaterialPriceRecord> page, MaterialPriceQueryDTO query) {
         QueryWrapper<MaterialPriceRecord> wrapper = buildWrapper(query, true);
         wrapper.orderByDesc("price_date").orderByDesc("scraped_at");
+        if (!isFiltered(query)) {
+            page.setSearchCount(false);
+            IPage<MaterialPriceRecord> result = this.page(page, wrapper);
+            result.setTotal(countTotal());
+            return result;
+        }
         return this.page(page, wrapper);
     }
 
@@ -75,22 +104,173 @@ public class MaterialPriceRecordService extends ServiceImpl<MaterialPriceRecordM
     }
 
     /**
-     * 筛选框下拉选项。
+     * 筛选框下拉选项:分类、省份、省份 → 城市。
      *
-     * sites 始终返回全部站点(否则选中站点后站点下拉会只剩一项);
-     * categories 按站点过滤,provinces/materials 再按分类收窄,
-     * cities 还要再按省份收窄,保证只列出当前省份下的城市。
+     * 一条 DISTINCT 查询取全量组合(当前约 800 条),在 Java 里分组;
+     * 只有第一次查询(或 Redis 被清空后)才回源查库,结果常驻 Redis,
+     * 之后由每天采集结束时的 refreshQueryCache 覆盖,前端打开页面不再等这条慢查询。
      */
-    public MaterialPriceOptionsDTO options(String siteCode, String category, String province) {
+    public MaterialPriceOptionsDTO options() {
+        MaterialPriceOptionsDTO cached = readOptionsCache();
+        if (cached != null) {
+            return cached;
+        }
+        MaterialPriceOptionsDTO dto = loadOptions();
+        writeOptionsCache(dto);
+        return dto;
+    }
+
+    /**
+     * 采集入库后刷新查询缓存:总行数直接失效,下拉选项立即重建并覆盖旧值。
+     *
+     * 下拉选项不删除而是重建:采集结束恰好是选项最可能变化的时刻,
+     * 先查库再覆盖能让第二天第一个打开页面的人直接命中新数据;
+     * 查库失败时保留旧缓存,页面仍可正常使用。
+     */
+    public void refreshQueryCache() {
+        deleteQuietly(COUNT_CACHE_KEY);
+        MaterialPriceOptionsDTO dto;
+        try {
+            dto = loadOptions();
+        } catch (Exception e) {
+            log.warn("采集结束后重建下拉选项缓存失败,保留旧缓存: {}", e.getMessage());
+            return;
+        }
+        writeOptionsCache(dto);
+    }
+
+    private void deleteQuietly(String key) {
+        try {
+            redisUtils.delete(key);
+        } catch (Exception e) {
+            log.warn("清除缓存 {} 失败: {}", key, e.getMessage());
+        }
+    }
+
+    /** 判断查询是否带了筛选条件;条件为空或只有一半日期时都不算。 */
+    private static boolean isFiltered(MaterialPriceQueryDTO query) {
+        if (StringUtils.isNotBlank(query.getSiteCode())
+                || StringUtils.isNotBlank(query.getCategory())
+                || StringUtils.isNotBlank(query.getProvince())
+                || StringUtils.isNotBlank(query.getCity())
+                || StringUtils.isNotBlank(query.getMaterialName())
+                || StringUtils.isNotBlank(query.getSpec())) {
+            return true;
+        }
+        if (query.getTaxIncluded() != null || Boolean.TRUE.equals(query.getTaxIncludedNull())) {
+            return true;
+        }
+        String[] dates = query.getPriceDates();
+        return dates != null && dates.length == 2
+                && StringUtils.isNotBlank(dates[0]) && StringUtils.isNotBlank(dates[1]);
+    }
+
+    /** 全表总行数(逻辑删除条件由 MyBatis-Plus 自动拼上),带 60 秒缓存。 */
+    private long countTotal() {
+        try {
+            Object cached = redisUtils.get(COUNT_CACHE_KEY);
+            if (cached instanceof Number) {
+                return ((Number) cached).longValue();
+            }
+        } catch (Exception e) {
+            log.warn("读取总行数缓存失败,改为直接统计: {}", e.getMessage());
+        }
+        long total = this.count();
+        try {
+            redisUtils.set(COUNT_CACHE_KEY, total, COUNT_CACHE_SECONDS);
+        } catch (Exception e) {
+            log.warn("写入总行数缓存失败: {}", e.getMessage());
+        }
+        return total;
+    }
+
+    private MaterialPriceOptionsDTO loadOptions() {
+        QueryWrapper<MaterialPriceRecord> wrapper = new QueryWrapper<>();
+        // 只取三列并去重,配合 idx_mpr_option(category, province, city) 走覆盖索引;
+        // 级联需要的所有映射都由这一批行在内存里分组,不再额外查库
+        wrapper.select("DISTINCT category, province, city");
+        List<Map<String, Object>> rows = baseMapper.selectMaps(wrapper);
+
+        Set<String> categories = new TreeSet<>();
+        Set<String> provinces = new TreeSet<>();
+        Map<String, Set<String>> citySets = new TreeMap<>();
+        Map<String, Set<String>> provinceSetsByCategory = new TreeMap<>();
+        Map<String, Set<String>> categorySetsByProvince = new TreeMap<>();
+        Map<String, Map<String, Set<String>>> citySetsByCategoryProvince = new TreeMap<>();
+
+        for (Map<String, Object> row : rows) {
+            String category = stringValue(row, "category").trim();
+            String province = stringValue(row, "province").trim();
+            String city = stringValue(row, "city").trim();
+            if (StringUtils.isNotBlank(category)) {
+                categories.add(category);
+            }
+            // 省份为空的数据(站点没有地区信息)不进下拉,也不参与级联
+            if (StringUtils.isBlank(province)) {
+                continue;
+            }
+            provinces.add(province);
+            // 分类为空的行只能通过「省份」进入,不参与分类维度的收窄
+            if (StringUtils.isNotBlank(category)) {
+                provinceSetsByCategory.computeIfAbsent(category, key -> new TreeSet<>()).add(province);
+                categorySetsByProvince.computeIfAbsent(province, key -> new TreeSet<>()).add(category);
+            }
+            if (StringUtils.isNotBlank(city)) {
+                citySets.computeIfAbsent(province, key -> new TreeSet<>()).add(city);
+                if (StringUtils.isNotBlank(category)) {
+                    citySetsByCategoryProvince
+                            .computeIfAbsent(category, key -> new TreeMap<>())
+                            .computeIfAbsent(province, key -> new TreeSet<>())
+                            .add(city);
+                }
+            }
+        }
+
         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));
+        dto.setCategories(new ArrayList<>(categories));
+        dto.setProvinces(new ArrayList<>(provinces));
+        dto.setCitiesByProvince(toListMap(citySets));
+        dto.setProvincesByCategory(toListMap(provinceSetsByCategory));
+        dto.setCategoriesByProvince(toListMap(categorySetsByProvince));
+
+        Map<String, Map<String, List<String>>> citiesByCategoryProvince = new TreeMap<>();
+        citySetsByCategoryProvince.forEach((category, byProvince) ->
+                citiesByCategoryProvince.put(category, toListMap(byProvince)));
+        dto.setCitiesByCategoryProvince(citiesByCategoryProvince);
         return dto;
     }
 
+    /** 把「key -> 有序 Set」统一转成「key -> List」,保持 TreeMap/TreeSet 的排序结果。 */
+    private static Map<String, List<String>> toListMap(Map<String, Set<String>> source) {
+        Map<String, List<String>> target = new TreeMap<>();
+        source.forEach((key, values) -> target.put(key, new ArrayList<>(values)));
+        return target;
+    }
+
+    private MaterialPriceOptionsDTO readOptionsCache() {
+        try {
+            Object value = redisUtils.get(OPTIONS_CACHE_KEY);
+            if (value instanceof MaterialPriceOptionsDTO) {
+                return (MaterialPriceOptionsDTO) value;
+            }
+            return null;
+        } catch (Exception e) {
+            // 缓存里可能是旧版本 DTO,反序列化失败时清掉并回源
+            log.warn("读取下拉选项缓存失败,改为直接查询: {}", e.getMessage());
+            deleteQuietly(OPTIONS_CACHE_KEY);
+            return null;
+        }
+    }
+
+    /** 写入下拉选项缓存,不设过期时间:下次覆盖只发生在采集结束时。 */
+    private void writeOptionsCache(MaterialPriceOptionsDTO dto) {
+        try {
+            redisUtils.set(OPTIONS_CACHE_KEY, dto);
+        } catch (Exception e) {
+            log.warn("写入下拉选项缓存失败: {}", e.getMessage());
+        }
+    }
+
     /**
      * 批量幂等写入。
      *
@@ -146,14 +326,33 @@ public class MaterialPriceRecordService extends ServiceImpl<MaterialPriceRecordM
     /**
      * 组装查询条件。空字符串一律忽略(前端清空下拉后可能传空串)。
      *
+     * 站点/分类/省份/城市支持逗号分隔的多个值,走 IN;物料名多个值之间是「或」。
+     *
      * @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());
+        List<String> siteCodes = splitValues(query.getSiteCode());
+        wrapper.in(!siteCodes.isEmpty(), "site_code", siteCodes);
+        List<String> categories = splitValues(query.getCategory());
+        wrapper.in(!categories.isEmpty(), "category", categories);
+        List<String> provinces = splitValues(query.getProvince());
+        wrapper.in(!provinces.isEmpty(), "province", provinces);
+        List<String> cities = splitValues(query.getCity());
+        wrapper.in(!cities.isEmpty(), "city", cities);
+
+        List<String> names = splitValues(query.getMaterialName());
+        if (!names.isEmpty()) {
+            // 多个物料名是「或」关系,包一层括号避免破坏其他 AND 条件
+            wrapper.and(w -> {
+                for (int i = 0; i < names.size(); i++) {
+                    if (i > 0) {
+                        w.or();
+                    }
+                    w.like("material_name", names.get(i));
+                }
+            });
+        }
+
         if (StringUtils.isNotBlank(query.getSpec())) {
             if (fuzzySpec) {
                 wrapper.like("spec", query.getSpec());
@@ -174,34 +373,18 @@ public class MaterialPriceRecordService extends ServiceImpl<MaterialPriceRecordM
         }
     }
 
-    /** 站点下拉:按 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 过滤。
-     * 列名来自内部常量,不接受外部输入,因此可以字符串拼接。
+     * 把查询参数拆成多个值:空值返回空列表,多个值用逗号分隔。
+     * 同名参数重复传时 Spring 会自动拼成 "A,B",这里统一处理。
      */
-    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)
+    private static List<String> splitValues(String raw) {
+        if (StringUtils.isBlank(raw)) {
+            return Collections.emptyList();
+        }
+        return Arrays.stream(raw.split(","))
+                .map(String::trim)
+                .filter(StringUtils::isNotBlank)
+                .distinct()
                 .collect(Collectors.toList());
     }
 

+ 77 - 205
jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/service/MaterialPriceSyncService.java

@@ -13,6 +13,9 @@ 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.assess.materialPrice.service.support.RangeChunk;
+import com.jeeplus.assess.materialPrice.service.support.RangeProgress;
+import com.jeeplus.assess.materialPrice.service.support.SiteSummary;
 import com.jeeplus.common.SecurityUtils;
 import com.jeeplus.common.constant.CommonConstants;
 import com.jeeplus.common.redis.RedisUtils;
@@ -34,17 +37,14 @@ import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Date;
 import java.util.LinkedHashMap;
-import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
-import java.util.Set;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.Executor;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.ScheduledFuture;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
-import java.util.concurrent.atomic.AtomicInteger;
 
 /**
  * 材料价格同步服务:Java 侧唯一调用 Python 抓取服务的入口。
@@ -74,10 +74,7 @@ public class MaterialPriceSyncService {
     /** 区间采集锁:与 XXL 锁分开,两种采集可以同时进行(写入幂等,不会冲突) */
     private static final String RANGE_LOCK_KEY = "material-price:sync:range";
 
-    /** 单个站点在日志里保留的提示条数上限,避免异常时 site_result 字段过大 */
-    private static final int NOTES_LIMIT = 100;
-
-    /** 单个区间分片最多覆盖的天数:约 6 天 × 6000 个指数 ≈ 3.7 万条,单次响应可控 */
+    /** 单个区间分片最多覆盖的天数:约 6 天 × 数千个指数,单次响应可控 */
     private static final int FFHSW_DAYS_PER_SLICE = 6;
 
     /** 区间分片入库时每批转换/写入的条数 */
@@ -96,10 +93,8 @@ public class MaterialPriceSyncService {
     /** 每次重试前的等待秒数,给站点限流一点恢复时间 */
     private int rangeRetryBackoffSeconds=30;
 
-    /**
-     * 单个分片调 Python 的读超时(毫秒)。
-     */
-    private int rangeTimeoutMs=300000;
+    /**单个分片调 Python 的读超时(毫秒)。*/
+    private int rangeTimeoutMs=600000;
 
     /** 整个区间任务的最长执行时间(秒),超时后剩余分片直接记失败,避免无限重试 */
     private int rangeMaxSeconds=7200;
@@ -115,38 +110,29 @@ public class MaterialPriceSyncService {
     /**
      * 废废网按指数切片的总片数上限,调大可以让单片更轻、片数更多。
      *
-     * 全站约 6000 个指数,180 天约 90 万条记录,一次返回会把 Python/Java 内存打爆,
+     * 目标省份合计数千个指数,180 天可达数十万条记录,一次返回会把 Python/Java 内存打爆,
      * 因此按「指数 id 取模」切成多片逐片调用;实际片数 = ceil(天数 / 6),且不超过本值。
      * 例:180 天 30 片、151 天 26 片、3 天 1 片。
      */
     private int ffhswSlices = 30;
 
-    /**
-     * 回收商网分片天数
-     */
+    /** 回收商网分片天数*/
     private int chunkDaysHss = 10;
 
-    /**
-     * 废钢资讯网分片天数
-     */
+    /** 废钢资讯网分片天数*/
     private int chunkDaysFeigang = 50;
 
-    /**
-     * 保太集团分片天数
-     */
+    /** 保太集团分片天数*/
     private int chunkDaysBaotai = 50;
 
-    /**
-     * 中果废品网分片天数它只抓首页最近十几篇文章
-     */
+    /** 中果废品网分片天数它只抓首页最近十几篇文章*/
     private int chunkDaysZgfp = 300;
 
     /** 区间采集锁的过期时间(秒) */
     private long rangeLockSeconds=300;
 
     /** 区间采集锁的续期间隔(秒),必须小于锁过期时间 */
-    @Value("${material-price.range.lock-renew-seconds:60}")
-    private long lockRenewSeconds;
+    private long lockRenewSeconds=60;
 
     // ==================== 依赖注入 ====================
 
@@ -451,7 +437,7 @@ public class MaterialPriceSyncService {
             logEntity.setCurrentStage("排队中");
             syncLogMapper.insert(logEntity);
 
-            RangeProgress progress = new RangeProgress(logEntity.getId(), operator, totalChunks);
+            RangeProgress progress = new RangeProgress(syncLogMapper, logEntity.getId(), operator, totalChunks);
             try {
                 rangeExecutor.execute(() -> runRangeJob(
                         batchNo, logEntity.getId(), start, end, plan, totalChunks, operator, progress));
@@ -520,15 +506,15 @@ public class MaterialPriceSyncService {
                     SiteSummary summary = entry.getValue().get();
                     summaries.add(summary);
                     log.info("区间采集站点结束 batchNo={} site={} chunks={} success={} noData={} failed={} saved={} dataDate={}",
-                            batchNo, siteKey, summary.chunks, summary.successChunks,
-                            summary.noDataChunks, summary.failedChunks, summary.saved, summary.dataDate);
+                            batchNo, siteKey, summary.getChunks(), summary.getSuccessChunks(),
+                            summary.getNoDataChunks(), summary.getFailedChunks(), summary.getSaved(), summary.getDataDate());
                 } catch (Exception e) {
                     // CompletableFuture 里的异常到这里才拿得到,补一条失败摘要保证统计完整
                     log.error("区间采集站点任务异常 batchNo={} site={}", batchNo, siteKey, e);
                     SiteSummary summary = new SiteSummary(siteKey, siteName(siteKey));
-                    summary.chunks = plan.get(siteKey).size();
-                    summary.failedChunks = summary.chunks;
-                    summary.error = "站点任务异常: " + e.getMessage();
+                    summary.setChunks(plan.get(siteKey).size());
+                    summary.addFailedChunks(summary.getChunks());
+                    summary.setError("站点任务异常: " + e.getMessage());
                     summaries.add(summary);
                 }
             }
@@ -542,14 +528,14 @@ public class MaterialPriceSyncService {
             int failedChunks = 0;
             LocalDate dataDate = null;
             for (SiteSummary summary : summaries) {
-                totalCount += summary.total;
-                saved += summary.saved;
-                invalid += summary.invalid;
-                successChunks += summary.successChunks;
-                noDataChunks += summary.noDataChunks;
-                failedChunks += summary.failedChunks;
-                if (summary.dataDate != null && (dataDate == null || summary.dataDate.isAfter(dataDate))) {
-                    dataDate = summary.dataDate;
+                totalCount += summary.getTotal();
+                saved += summary.getSaved();
+                invalid += summary.getInvalid();
+                successChunks += summary.getSuccessChunks();
+                noDataChunks += summary.getNoDataChunks();
+                failedChunks += summary.getFailedChunks();
+                if (summary.getDataDate() != null && (dataDate == null || summary.getDataDate().isAfter(dataDate))) {
+                    dataDate = summary.getDataDate();
                 }
             }
 
@@ -591,13 +577,13 @@ public class MaterialPriceSyncService {
     private SiteSummary runSite(String batchNo, String siteKey, List<RangeChunk> chunks,
                                 String operator, RangeProgress progress, long deadline) {
         SiteSummary summary = new SiteSummary(siteKey, siteName(siteKey));
-        summary.chunks = chunks.size();
+        summary.setChunks(chunks.size());
         for (RangeChunk chunk : chunks) {
             if (System.currentTimeMillis() > deadline) {
-                int remain = chunks.size() - summary.successChunks - summary.noDataChunks - summary.failedChunks;
+                int remain = chunks.size() - summary.getSuccessChunks() - summary.getNoDataChunks() - summary.getFailedChunks();
                 log.warn("区间采集超过总时长上限 batchNo={} site={} 剩余 {} 个分片记为失败", batchNo, siteKey, remain);
-                summary.failedChunks += remain;
-                summary.notes.add("超过区间任务总时长上限(" + rangeMaxSeconds + "s),剩余 " + remain + " 个分片未执行");
+                summary.addFailedChunks(remain);
+                summary.addNote("超过区间任务总时长上限(" + rangeMaxSeconds + "s),剩余 " + remain + " 个分片未执行");
                 break;
             }
 
@@ -632,29 +618,29 @@ public class MaterialPriceSyncService {
             }
 
             if (response == null) {
-                summary.failedChunks++;
-                summary.notes.add(chunk.label() + " 抓取失败: " + lastError);
+                summary.addFailedChunk();
+                summary.addNote(chunk.label() + " 抓取失败: " + lastError);
                 progress.chunkDone();
                 continue;
             }
 
-            int savedBefore = summary.saved;
-            int invalidBefore = summary.invalid;
+            int savedBefore = summary.getSaved();
+            int invalidBefore = summary.getInvalid();
             try {
                 applyChunkResponse(summary, batchNo, operator, chunk, response, chunkBegin);
             } catch (Exception e) {
                 // 入库或清洗异常只影响当前分片
                 log.error("区间采集分片处理异常 batchNo={} site={} chunk={}", batchNo, siteKey, chunk.label(), e);
-                summary.failedChunks++;
-                summary.notes.add(chunk.label() + " 处理失败: " + e.getMessage());
+                summary.addFailedChunk();
+                summary.addNote(chunk.label() + " 处理失败: " + e.getMessage());
             }
             // 只把本分片的增量写进进度,避免多站点并发覆盖彼此的数字
-            progress.addCounts(summary.saved - savedBefore, summary.invalid - invalidBefore);
+            progress.addCounts(summary.getSaved() - savedBefore, summary.getInvalid() - invalidBefore);
             progress.chunkDone();
         }
         log.info("区间采集站点完成 batchNo={} site={} chunks={} success={} noData={} failed={} total={} saved={} invalid={}",
-                batchNo, siteKey, summary.chunks, summary.successChunks, summary.noDataChunks,
-                summary.failedChunks, summary.total, summary.saved, summary.invalid);
+                batchNo, siteKey, summary.getChunks(), summary.getSuccessChunks(), summary.getNoDataChunks(),
+                summary.getFailedChunks(), summary.getTotal(), summary.getSaved(), summary.getInvalid());
         return summary;
     }
 
@@ -680,9 +666,9 @@ public class MaterialPriceSyncService {
             saved += recordService.upsertBatch(records);
         }
 
-        summary.total += response.getTotal() == null ? items.size() : response.getTotal();
-        summary.saved += saved;
-        summary.invalid += invalid;
+        summary.addTotal(response.getTotal() == null ? items.size() : response.getTotal());
+        summary.addSaved(saved);
+        summary.addInvalid(invalid);
 
         // 解析 Python 返回的抓取器状态,决定这个分片算成功 / 无数据 / 失败
         List<MaterialPriceScraperResultDTO> scrapers = safe(response.getScrapers());
@@ -691,32 +677,29 @@ public class MaterialPriceSyncService {
         for (MaterialPriceScraperResultDTO scraper : scrapers) {
             if (MaterialPriceSyncStatus.SUCCESS.name().equals(scraper.getStatus())) {
                 hasSuccess = true;
-                LocalDate dataDate = parseLocalDate(scraper.getDataDate());
-                if (dataDate != null && (summary.dataDate == null || dataDate.isAfter(summary.dataDate))) {
-                    summary.dataDate = dataDate;
-                }
+                summary.updateDataDate(parseLocalDate(scraper.getDataDate()));
             } else if (MaterialPriceSyncStatus.FAILED.name().equals(scraper.getStatus())) {
                 hasFailed = true;
             }
             // notes 是站点内部非致命提示(如部分分类失败),error 是失败原因;
-            // 单个站点最多保留 100 条,防止异常时 site_result 字段被撑爆
+            // 条数上限由 SiteSummary 内部控制,防止异常时 site_result 字段被撑爆
             for (String note : safe(scraper.getNotes())) {
-                if (StringUtils.isNotBlank(note) && summary.notes.size() < NOTES_LIMIT) {
-                    summary.notes.add(scraper.getScraperKey() + ": " + note);
+                if (StringUtils.isNotBlank(note)) {
+                    summary.addNote(scraper.getScraperKey() + ": " + note);
                 }
             }
-            if (StringUtils.isNotBlank(scraper.getError()) && summary.notes.size() < NOTES_LIMIT) {
-                summary.notes.add(scraper.getScraperKey() + " 失败: " + scraper.getError());
+            if (StringUtils.isNotBlank(scraper.getError())) {
+                summary.addNote(scraper.getScraperKey() + " 失败: " + scraper.getError());
             }
         }
 
         if (hasFailed && !hasSuccess) {
-            summary.failedChunks++;
+            summary.addFailedChunk();
         } else if (hasSuccess) {
-            summary.successChunks++;
+            summary.addSuccessChunk();
         } else {
             // Python 对「没有该日期数据」「站点不支持历史」都返回 NO_DATA,属于正常跳过
-            summary.noDataChunks++;
+            summary.addNoDataChunk();
         }
 
         log.info("区间采集分片结束 batchNo={} chunk={} elapsedMs={} items={} saved={} invalid={} "
@@ -753,18 +736,18 @@ public class MaterialPriceSyncService {
         List<Map<String, Object>> results = new ArrayList<>(summaries.size());
         for (SiteSummary summary : summaries) {
             Map<String, Object> item = new LinkedHashMap<>();
-            item.put("siteKey", summary.siteKey);
-            item.put("siteName", summary.siteName);
-            item.put("chunks", summary.chunks);
-            item.put("successChunks", summary.successChunks);
-            item.put("noDataChunks", summary.noDataChunks);
-            item.put("failedChunks", summary.failedChunks);
-            item.put("total", summary.total);
-            item.put("saved", summary.saved);
-            item.put("invalid", summary.invalid);
-            item.put("dataDate", summary.dataDate == null ? null : summary.dataDate.toString());
-            item.put("notes", new ArrayList<>(summary.notes));
-            item.put("error", summary.error);
+            item.put("siteKey", summary.getSiteKey());
+            item.put("siteName", summary.getSiteName());
+            item.put("chunks", summary.getChunks());
+            item.put("successChunks", summary.getSuccessChunks());
+            item.put("noDataChunks", summary.getNoDataChunks());
+            item.put("failedChunks", summary.getFailedChunks());
+            item.put("total", summary.getTotal());
+            item.put("saved", summary.getSaved());
+            item.put("invalid", summary.getInvalid());
+            item.put("dataDate", summary.getDataDate() == null ? null : summary.getDataDate().toString());
+            item.put("notes", new ArrayList<>(summary.getNotes()));
+            item.put("error", summary.getError());
             results.add(item);
         }
         return results;
@@ -774,11 +757,11 @@ public class MaterialPriceSyncService {
     private String failureMessage(List<SiteSummary> summaries) {
         StringBuilder builder = new StringBuilder("全部站点/分片失败");
         for (SiteSummary summary : summaries) {
-            if (summary.notes.isEmpty()) {
+            if (summary.getNotes().isEmpty()) {
                 continue;
             }
-            builder.append(";").append(summary.siteKey).append(": ")
-                    .append(StringUtils.abbreviate(String.join(" | ", summary.notes), 200));
+            builder.append(";").append(summary.getSiteKey()).append(": ")
+                    .append(StringUtils.abbreviate(String.join(" | ", summary.getNotes()), 200));
         }
         return StringUtils.abbreviate(builder.toString(), 500);
     }
@@ -844,7 +827,7 @@ public class MaterialPriceSyncService {
      */
     private LinkedHashMap<String, List<RangeChunk>> buildRangePlan(LocalDate start, LocalDate end) {
         LinkedHashMap<String, List<RangeChunk>> plan = new LinkedHashMap<>();
-        // 废废网指数多(约 6000 个),日期不切,整段按指数 id 取模切成多片,
+        // 废废网指数多(数千个),日期不切,整段按指数 id 取模切成多片,
         // 每片只抓约 1/N 的指数,避免单次响应过大把 Java/Python 内存打爆;
         // 其余站点只按天数切,天数常量见类顶部的 chunkDaysXxx
         addSiteChunks(plan, "ffhsw", start, end, chunkDaysFfhsw, Math.max(1, ffhswSlices));
@@ -930,6 +913,11 @@ public class MaterialPriceSyncService {
         update.setUpdateById(operator);
         update.setUpdateTime(new Date());
         syncLogMapper.updateById(update);
+        // 有新数据入库才需要刷新下拉选项/总行数缓存;失败/无数据时保持原缓存。
+        // 手动区间采集和 XXL 定时采集都走这里,保证采集一结束缓存就是最新的
+        if (saved != null && saved > 0) {
+            recordService.refreshQueryCache();
+        }
     }
 
     /** 提交失败(线程池拒绝等)时的重载,日志对象已在内存中,可直接回写。 */
@@ -1053,6 +1041,10 @@ public class MaterialPriceSyncService {
         logEntity.setUpdateById(operator);
         logEntity.setUpdateTime(new Date());
         syncLogMapper.updateById(logEntity);
+        // 手动「立即采集」与 XXL 每日任务共用这个方法,采集结束后统一重建下拉选项缓存
+        if (saved != null && saved > 0) {
+            recordService.refreshQueryCache();
+        }
     }
 
     /**
@@ -1199,125 +1191,5 @@ public class MaterialPriceSyncService {
         }
         return result;
     }
-
-    /** 区间任务的一个分片(站点 + 起止日期 + 片内下标)。 */
-    private static class RangeChunk {
-        private final String siteKey;
-        private final LocalDate start;
-        private final LocalDate end;
-        // 分片下标/总数:废废网按指数取模切片,其他站点固定 0/1
-        private final int sliceIndex;
-        private final int sliceCount;
-
-        RangeChunk(String siteKey, LocalDate start, LocalDate end,
-                   int sliceIndex, int sliceCount) {
-            this.siteKey = siteKey;
-            this.start = start;
-            this.end = end;
-            this.sliceIndex = sliceIndex;
-            this.sliceCount = sliceCount;
-        }
-
-        LocalDate getStart() {
-            return start;
-        }
-
-        LocalDate getEnd() {
-            return end;
-        }
-
-        int getSliceIndex() {
-            return sliceIndex;
-        }
-
-        int getSliceCount() {
-            return sliceCount;
-        }
-
-        String label() {
-            String base = siteKey + " " + start + "~" + end;
-            if (sliceCount <= 1) {
-                return base;
-            }
-            return base + " 第" + (sliceIndex + 1) + "/" + sliceCount + "片";
-        }
-    }
-
-    /**
-     * 区间采集进度:多个站点线程同时完成分片,用 synchronized 保证
-     * 「计数 + 写库」不会互相覆盖。
-     */
-    private class RangeProgress {
-        private final String logId;
-        private final String operator;
-        private final int total;
-        private final AtomicInteger done = new AtomicInteger(0);
-        // 运行中累计的入库/无效条数,前端据此展示「已入库 X 条,跳过 Y 条」
-        private final AtomicInteger saved = new AtomicInteger(0);
-        private final AtomicInteger invalid = new AtomicInteger(0);
-        private volatile String stage = "";
-
-        RangeProgress(String logId, String operator, int total) {
-            this.logId = logId;
-            this.operator = operator;
-            this.total = total;
-        }
-
-        /** 分片开始时记录当前阶段。 */
-        void stage(String value) {
-            this.stage = value;
-            flush();
-        }
-
-        /** 分片结束后累加入库/无效条数(传增量,避免多站点并发时互相覆盖)。 */
-        synchronized void addCounts(int savedDelta, int invalidDelta) {
-            saved.addAndGet(savedDelta);
-            invalid.addAndGet(invalidDelta);
-        }
-
-        /** 分片结束(无论成功失败)时进度 +1。 */
-        synchronized void chunkDone() {
-            done.incrementAndGet();
-            flush();
-        }
-
-        private void flush() {
-            try {
-                MaterialPriceSyncLog update = new MaterialPriceSyncLog();
-                update.setId(logId);
-                update.setProgressDone(done.get());
-                update.setProgressTotal(total);
-                update.setSavedCount(saved.get());
-                update.setFailedCount(invalid.get());
-                update.setCurrentStage(stage);
-                update.setUpdateById(operator);
-                update.setUpdateTime(new Date());
-                syncLogMapper.updateById(update);
-            } catch (Exception e) {
-                // 进度写库失败不影响抓取,前端下一次轮询会拿到旧值
-                log.warn("更新区间采集进度失败 logId={} done={}/{}: {}", logId, done.get(), total, e.getMessage());
-            }
-        }
-    }
-
-    /** 单个站点的区间抓取结果,用于汇总状态和写 site_result。 */
-    private static class SiteSummary {
-        private final String siteKey;
-        private final String siteName;
-        private int chunks;
-        private int successChunks;
-        private int noDataChunks;
-        private int failedChunks;
-        private int total;
-        private int saved;
-        private int invalid;
-        private LocalDate dataDate;
-        private final Set<String> notes = new LinkedHashSet<>();
-        private String error;
-
-        SiteSummary(String siteKey, String siteName) {
-            this.siteKey = siteKey;
-            this.siteName = siteName;
-        }
-    }
 }
+

+ 22 - 7
jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/service/dto/MaterialPriceOptionsDTO.java

@@ -4,22 +4,37 @@ import lombok.Data;
 
 import java.io.Serializable;
 import java.util.List;
+import java.util.Map;
 
 /**
  * 前端筛选框的下拉选项。
  *
- * sites 始终返回全部站点(避免选中站点后下拉只剩一个);
- * 其余选项按 siteCode / category 级联过滤,cities 还要再按 province 收窄。
+ * 选项在站点/分类/省份之间的取值基本固定,一次查全量的「分类 + 省市」组合后
+ * 由 Java 分组即可,前端切换条件时在本地做级联收窄,不再回后端查询。
+ *
+ * 级联规则:分类与省份双向收窄,城市只能在已选省份下选择,
+ * 选了省份+城市后再反向收窄分类。
  */
 @Data
 public class MaterialPriceOptionsDTO implements Serializable {
 
-    private static final long serialVersionUID = 1L;
+    private static final long serialVersionUID = 3L;
 
-    private List<MaterialPriceSiteDTO> sites;
+    /** 分类(去重、排序) */
     private List<String> categories;
+
+    /** 省份(去重、排序) */
     private List<String> provinces;
-    /** 城市:按 siteCode/category/province 级联收窄 */
-    private List<String> cities;
-    private List<String> materials;
+
+    /** 省份 -> 该省下的城市列表;没有城市信息的省份不出现 */
+    private Map<String, List<String>> citiesByProvince;
+
+    /** 分类 -> 该分类下出现过的省份,用于「选了分类收窄省份」 */
+    private Map<String, List<String>> provincesByCategory;
+
+    /** 省份 -> 该省下出现过的分类,用于「选了省份收窄分类」 */
+    private Map<String, List<String>> categoriesByProvince;
+
+    /** 分类 -> 省份 -> 城市,用于「分类+省份收窄城市」;没有城市的分组不出现 */
+    private Map<String, Map<String, List<String>>> citiesByCategoryProvince;
 }

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

@@ -7,6 +7,9 @@ import java.io.Serializable;
 /**
  * 材料价格查询条件,列表、走势、下拉选项共用。
  *
+ * 所有筛选字段都可为空;需要多个值时用逗号分隔(同名参数重复传也会被 Spring 拼成 A,B),
+ * 例如 category=ABS,PP 等价于 category IN ('ABS','PP')。
+ *
  * priceDates 由前端 daterange 序列化成 priceDates[0]/priceDates[1],
  * 两个值都存在时才按区间过滤。
  */

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

@@ -1,19 +0,0 @@
-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;
-}

+ 52 - 0
jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/service/support/RangeChunk.java

@@ -0,0 +1,52 @@
+package com.jeeplus.assess.materialPrice.service.support;
+
+import java.time.LocalDate;
+
+/**
+ * 区间任务的一个分片(站点 + 起止日期 + 片内下标)。
+ *
+ * 下标用于废废网按指数取模切片,其他站点固定 0/1,label() 直接展示到进度里。
+ */
+public class RangeChunk {
+
+    private final String siteKey;
+    private final LocalDate start;
+    private final LocalDate end;
+    // 分片下标/总数:废废网按指数取模切片,其他站点固定 0/1
+    private final int sliceIndex;
+    private final int sliceCount;
+
+    public RangeChunk(String siteKey, LocalDate start, LocalDate end,
+                      int sliceIndex, int sliceCount) {
+        this.siteKey = siteKey;
+        this.start = start;
+        this.end = end;
+        this.sliceIndex = sliceIndex;
+        this.sliceCount = sliceCount;
+    }
+
+    public LocalDate getStart() {
+        return start;
+    }
+
+    public LocalDate getEnd() {
+        return end;
+    }
+
+    public int getSliceIndex() {
+        return sliceIndex;
+    }
+
+    public int getSliceCount() {
+        return sliceCount;
+    }
+
+    /** 进度条上展示的分片描述,例如「ffhsw 2026-07-01~2026-11-10 第4/23片」。 */
+    public String label() {
+        String base = siteKey + " " + start + "~" + end;
+        if (sliceCount <= 1) {
+            return base;
+        }
+        return base + " 第" + (sliceIndex + 1) + "/" + sliceCount + "片";
+    }
+}

+ 71 - 0
jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/service/support/RangeProgress.java

@@ -0,0 +1,71 @@
+package com.jeeplus.assess.materialPrice.service.support;
+
+import com.jeeplus.assess.materialPrice.domain.MaterialPriceSyncLog;
+import com.jeeplus.assess.materialPrice.mapper.MaterialPriceSyncLogMapper;
+import lombok.extern.slf4j.Slf4j;
+
+import java.util.Date;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * 区间采集进度:多个站点线程同时完成分片,用 synchronized 保证
+ * 「计数 + 写库」不会互相覆盖。
+ *
+ * 进度只写 material_price_sync_log 的进度字段,前端每 3 秒轮询一次。
+ */
+@Slf4j
+public class RangeProgress {
+
+    private final MaterialPriceSyncLogMapper syncLogMapper;
+    private final String logId;
+    private final String operator;
+    private final int total;
+    private final AtomicInteger done = new AtomicInteger(0);
+    // 运行中累计的入库/无效条数,前端据此展示「已入库 X 条,跳过 Y 条」
+    private final AtomicInteger saved = new AtomicInteger(0);
+    private final AtomicInteger invalid = new AtomicInteger(0);
+    private volatile String stage = "";
+
+    public RangeProgress(MaterialPriceSyncLogMapper syncLogMapper, String logId, String operator, int total) {
+        this.syncLogMapper = syncLogMapper;
+        this.logId = logId;
+        this.operator = operator;
+        this.total = total;
+    }
+
+    /** 分片开始时记录当前阶段。 */
+    public void stage(String value) {
+        this.stage = value;
+        flush();
+    }
+
+    /** 分片结束后累加入库/无效条数(传增量,避免多站点并发时互相覆盖)。 */
+    public synchronized void addCounts(int savedDelta, int invalidDelta) {
+        saved.addAndGet(savedDelta);
+        invalid.addAndGet(invalidDelta);
+    }
+
+    /** 分片结束(无论成功失败)时进度 +1。 */
+    public synchronized void chunkDone() {
+        done.incrementAndGet();
+        flush();
+    }
+
+    private void flush() {
+        try {
+            MaterialPriceSyncLog update = new MaterialPriceSyncLog();
+            update.setId(logId);
+            update.setProgressDone(done.get());
+            update.setProgressTotal(total);
+            update.setSavedCount(saved.get());
+            update.setFailedCount(invalid.get());
+            update.setCurrentStage(stage);
+            update.setUpdateById(operator);
+            update.setUpdateTime(new Date());
+            syncLogMapper.updateById(update);
+        } catch (Exception e) {
+            // 进度写库失败不影响抓取,前端下一次轮询会拿到旧值
+            log.warn("更新区间采集进度失败 logId={} done={}/{}: {}", logId, done.get(), total, e.getMessage());
+        }
+    }
+}

+ 133 - 0
jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/materialPrice/service/support/SiteSummary.java

@@ -0,0 +1,133 @@
+package com.jeeplus.assess.materialPrice.service.support;
+
+import java.time.LocalDate;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+/**
+ * 单个站点的区间抓取结果,用于汇总整批状态和写日志里的 site_result。
+ *
+ * 计数只提供累加方法(而不是 setter),避免调用方把并发累加写错成覆盖。
+ */
+public class SiteSummary {
+
+    /** 单个站点保留的提示条数上限,防止异常时 site_result 字段过大 */
+    private static final int NOTE_LIMIT = 100;
+
+    private final String siteKey;
+    private final String siteName;
+    private int chunks;
+    private int successChunks;
+    private int noDataChunks;
+    private int failedChunks;
+    private int total;
+    private int saved;
+    private int invalid;
+    private LocalDate dataDate;
+    private final Set<String> notes = new LinkedHashSet<>();
+    private String error;
+
+    public SiteSummary(String siteKey, String siteName) {
+        this.siteKey = siteKey;
+        this.siteName = siteName;
+    }
+
+    public String getSiteKey() {
+        return siteKey;
+    }
+
+    public String getSiteName() {
+        return siteName;
+    }
+
+    public int getChunks() {
+        return chunks;
+    }
+
+    public void setChunks(int chunks) {
+        this.chunks = chunks;
+    }
+
+    public int getSuccessChunks() {
+        return successChunks;
+    }
+
+    public void addSuccessChunk() {
+        this.successChunks++;
+    }
+
+    public int getNoDataChunks() {
+        return noDataChunks;
+    }
+
+    public void addNoDataChunk() {
+        this.noDataChunks++;
+    }
+
+    public int getFailedChunks() {
+        return failedChunks;
+    }
+
+    public void addFailedChunk() {
+        this.failedChunks++;
+    }
+
+    public void addFailedChunks(int delta) {
+        this.failedChunks += delta;
+    }
+
+    public int getTotal() {
+        return total;
+    }
+
+    public void addTotal(int delta) {
+        this.total += delta;
+    }
+
+    public int getSaved() {
+        return saved;
+    }
+
+    public void addSaved(int delta) {
+        this.saved += delta;
+    }
+
+    public int getInvalid() {
+        return invalid;
+    }
+
+    public void addInvalid(int delta) {
+        this.invalid += delta;
+    }
+
+    public LocalDate getDataDate() {
+        return dataDate;
+    }
+
+    /** 只保留最新的数据日期,多个抓取器返回不同日期时取最大值。 */
+    public void updateDataDate(LocalDate date) {
+        if (date != null && (dataDate == null || date.isAfter(dataDate))) {
+            this.dataDate = date;
+        }
+    }
+
+    /** 只读视图,写提示统一走 {@link #addNote(String)} 以控制条数上限。 */
+    public Set<String> getNotes() {
+        return Collections.unmodifiableSet(notes);
+    }
+
+    public void addNote(String note) {
+        if (note != null && notes.size() < NOTE_LIMIT) {
+            notes.add(note);
+        }
+    }
+
+    public String getError() {
+        return error;
+    }
+
+    public void setError(String error) {
+        this.error = error;
+    }
+}