longz
2026-08-04 77a9f19e6532fef315d22f52008ef9c73dc69bc8
fix: 大屏充电用户数
5个文件已修改
160 ■■■■■ 已修改文件
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/mapper/TChargingOrderMapper.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TChargingOrderServiceImpl.java 85 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/util/task/LargeScreenStatJob.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/resources/mapper/order/TChargingOrderMapper.xml 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/large_screen_stat.sql 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/mapper/TChargingOrderMapper.java
@@ -210,6 +210,21 @@
    // 任务用: 按月切片聚合(与 getLargeScreenSummary 相同口径, 按站点分组)
    List<Map<String, Object>> monthSliceAgg(@Param("startTime") String startTime, @Param("endTime") String endTime);
    // 任务用: 按月切片统计充电用户数(工作台口径: 所有订单的去重用户, 不区分状态/删除标记)
    List<Map<String, Object>> monthSliceUserAgg(@Param("startTime") String startTime, @Param("endTime") String endTime);
    // 任务用: 按时间切片取 (site_id, app_user_id) 对(所有订单)
    List<Map<String, Object>> userSitePairsAgg(@Param("startTime") String startTime, @Param("endTime") String endTime);
    // 任务用: 批量写入用户-站点对(幂等)
    int insertUserSitePairs(@Param("rows") List<Map<String, Object>> rows);
    // 接口用: 充电用户数精确去重(对表)
    Long userCountFromStat(@Param("siteIds") List<Integer> siteIds);
    // 任务用: 对表行数(启动回填判断)
    Long userSiteTableCount();
    // 任务用: 按日切片聚合(与 getLargeScreenChargingPower 相同口径, 按站点分组)
    List<Map<String, Object>> daySliceAgg(@Param("startTime") String startTime, @Param("endTime") String endTime);
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TChargingOrderServiceImpl.java
@@ -6711,6 +6711,14 @@
            if (summary != null) {
                result.putAll(summary);
            }
            // 充电用户数精确去重(用户-站点对表, 跨站不重复); 对表未回填时回退到按站点求和
            Long pairCount = this.baseMapper.userSiteTableCount();
            if (pairCount != null && pairCount > 0) {
                Long exactUserCount = this.baseMapper.userCountFromStat(siteIds);
                if (exactUserCount != null) {
                    result.put("chargingUserCount", exactUserCount);
                }
            }
            Long registered = this.baseMapper.registeredFromStat();
            result.put("registeredUserCount", registered == null ? 0L : registered);
        } catch (Exception e) {
@@ -6836,6 +6844,7 @@
        LocalDate currentMonth = LocalDate.now().withDayOfMonth(1);
        while (!month.isAfter(currentMonth)) {
            writeMonthSlice(month, month.plusMonths(1));
            writeUserSitePairs(month, month.plusMonths(1));
            month = month.plusMonths(1);
        }
        // 2. 近90天日切片
@@ -6856,21 +6865,61 @@
        LocalDate currentMonth = LocalDate.now().withDayOfMonth(1);
        writeMonthSlice(currentMonth, currentMonth.plusMonths(1));
        writeDaySlice(LocalDate.now());
        writeUserSitePairs(LocalDate.now(), LocalDate.now().plusDays(1));
        writeGlobal();
        log.info("大屏预聚合表刷新完成, 耗时 {}ms", System.currentTimeMillis() - start);
    }
    /**
     * 写入一个月度切片聚合(M 行)
     * 写入时间切片内的 (site_id, app_user_id) 对(充电用户精确去重用, INSERT IGNORE 幂等)
     */
    private void writeMonthSlice(LocalDate monthStart, LocalDate monthEnd) {
        List<Map<String, Object>> rows = this.baseMapper.monthSliceAgg(
                monthStart.atStartOfDay().format(LARGE_SCREEN_SQL_FMT),
                monthEnd.atStartOfDay().format(LARGE_SCREEN_SQL_FMT));
        if (rows == null || rows.isEmpty()) {
    private void writeUserSitePairs(LocalDate sliceStart, LocalDate sliceEnd) {
        List<Map<String, Object>> pairs = this.baseMapper.userSitePairsAgg(
                sliceStart.atStartOfDay().format(LARGE_SCREEN_SQL_FMT),
                sliceEnd.atStartOfDay().format(LARGE_SCREEN_SQL_FMT));
        if (pairs == null || pairs.isEmpty()) {
            return;
        }
        List<Map<String, Object>> rows = new ArrayList<>();
        for (Map<String, Object> pair : pairs) {
            if (pair == null || pair.get("site_id") == null || pair.get("app_user_id") == null) {
                continue;
            }
            Map<String, Object> row = new HashMap<>();
            row.put("siteId", pair.get("site_id"));
            row.put("appUserId", pair.get("app_user_id"));
            rows.add(row);
        }
        if (!rows.isEmpty()) {
            this.baseMapper.insertUserSitePairs(rows);
        }
    }
    /**
     * 写入一个月度切片聚合(M 行)
     * 金额/订单数取 monthSliceAgg(已结束+已支付口径);
     * 充电用户数取 monthSliceUserAgg(工作台口径: 该站点所有订单的去重用户, 不区分状态/删除标记)
     */
    private void writeMonthSlice(LocalDate monthStart, LocalDate monthEnd) {
        String startTime = monthStart.atStartOfDay().format(LARGE_SCREEN_SQL_FMT);
        String endTime = monthEnd.atStartOfDay().format(LARGE_SCREEN_SQL_FMT);
        List<Map<String, Object>> rows = this.baseMapper.monthSliceAgg(startTime, endTime);
        List<Map<String, Object>> userRows = this.baseMapper.monthSliceUserAgg(startTime, endTime);
        if ((rows == null || rows.isEmpty()) && (userRows == null || userRows.isEmpty())) {
            return;
        }
        Map<Object, BigDecimal> userCountBySite = new HashMap<>();
        if (userRows != null) {
            for (Map<String, Object> userRow : userRows) {
                if (userRow != null && userRow.get("site_id") != null) {
                    userCountBySite.put(userRow.get("site_id"), decimalValue(userRow, "user_count"));
                }
            }
        }
        String period = monthStart.format(LARGE_SCREEN_MONTH_FMT);
        List<Map<String, Object>> statRows = new ArrayList<>();
        Map<Object, Boolean> written = new HashMap<>();
        if (rows != null) {
        for (Map<String, Object> row : rows) {
            if (row == null || row.get("site_id") == null) {
                continue;
@@ -6879,10 +6928,10 @@
            BigDecimal serviceFee = decimalValue(row, "service_fee");
            Map<String, Object> stat = new HashMap<>();
            stat.put("statType", "M");
            stat.put("period", monthStart.format(LARGE_SCREEN_MONTH_FMT));
                stat.put("period", period);
            stat.put("siteId", row.get("site_id"));
            stat.put("orderCount", decimalValue(row, "order_count"));
            stat.put("userCount", decimalValue(row, "user_count"));
                stat.put("userCount", userCountBySite.getOrDefault(row.get("site_id"), BigDecimal.ZERO));
            stat.put("capacity", decimalValue(row, "capacity"));
            stat.put("electricFee", electricFee);
            stat.put("serviceFee", serviceFee);
@@ -6890,6 +6939,26 @@
            stat.put("totalPower", BigDecimal.ZERO);
            stat.put("registeredUserCount", 0L);
            statRows.add(stat);
                written.put(row.get("site_id"), Boolean.TRUE);
            }
        }
        // 当月仅有充电用户、暂无已结束订单的站点, 补一行保用户数(金额为0)
        for (Map.Entry<Object, BigDecimal> entry : userCountBySite.entrySet()) {
            if (!written.containsKey(entry.getKey())) {
                Map<String, Object> stat = new HashMap<>();
                stat.put("statType", "M");
                stat.put("period", period);
                stat.put("siteId", entry.getKey());
                stat.put("orderCount", BigDecimal.ZERO);
                stat.put("userCount", entry.getValue());
                stat.put("capacity", BigDecimal.ZERO);
                stat.put("electricFee", BigDecimal.ZERO);
                stat.put("serviceFee", BigDecimal.ZERO);
                stat.put("income", BigDecimal.ZERO);
                stat.put("totalPower", BigDecimal.ZERO);
                stat.put("registeredUserCount", 0L);
                statRows.add(stat);
            }
        }
        if (!statRows.isEmpty()) {
            this.baseMapper.replaceSiteStatBatch(statRows);
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/util/task/LargeScreenStatJob.java
@@ -73,10 +73,11 @@
        }
        try {
            Long count = chargingOrderMapper.statTableCount();
            if (count != null && count > 0) {
            Long userSiteCount = chargingOrderMapper.userSiteTableCount();
            if (count != null && count > 0 && userSiteCount != null && userSiteCount > 0) {
                return;
            }
            log.info("大屏预聚合表为空, 启动异步全量回填");
            log.info("大屏预聚合表或用户-站点对表为空(stat={}, userSite={}), 启动异步全量回填", count, userSiteCount);
            Thread backfill = new Thread(() -> {
                try {
                    chargingOrderService.rebuildLargeScreenStat();
ruoyi-service/ruoyi-order/src/main/resources/mapper/order/TChargingOrderMapper.xml
@@ -3143,6 +3143,50 @@
        GROUP BY charging_order.site_id
    </select>
    <!-- 任务用: 按月切片统计充电用户数(工作台口径: 所有订单的去重用户, 不区分状态/删除标记,
         与 countAllUserData 同定义; 按站点求和会有跨站重复计数) -->
    <select id="monthSliceUserAgg" resultType="java.util.Map">
        SELECT site_id AS site_id,
               COUNT(DISTINCT app_user_id) AS user_count
        FROM t_charging_order
        WHERE create_time &gt;= #{startTime}
          AND create_time &lt; #{endTime}
        GROUP BY site_id
    </select>
    <!-- 任务用: 按时间切片取 (site_id, app_user_id) 对(所有订单, 与工作台 countAllUserData 同口径) -->
    <select id="userSitePairsAgg" resultType="java.util.Map">
        SELECT DISTINCT site_id AS site_id, app_user_id AS app_user_id
        FROM t_charging_order
        WHERE create_time &gt;= #{startTime}
          AND create_time &lt; #{endTime}
          AND app_user_id IS NOT NULL
    </select>
    <!-- 任务用: 批量写入用户-站点对(幂等) -->
    <insert id="insertUserSitePairs">
        INSERT IGNORE INTO t_large_screen_user_site (site_id, app_user_id)
        VALUES
        <foreach collection="rows" item="row" separator=",">
            (#{row.siteId}, #{row.appUserId})
        </foreach>
    </insert>
    <!-- 接口用: 充电用户数精确去重(对表, 选中站点全局去重) -->
    <select id="userCountFromStat" resultType="java.lang.Long">
        SELECT COUNT(DISTINCT app_user_id)
        FROM t_large_screen_user_site
        WHERE site_id IN
        <foreach collection="siteIds" item="siteId" open="(" separator="," close=")">
            #{siteId}
        </foreach>
    </select>
    <!-- 任务用: 对表行数(启动回填判断) -->
    <select id="userSiteTableCount" resultType="java.lang.Long">
        SELECT COUNT(*) FROM t_large_screen_user_site
    </select>
    <!-- 任务用: 按日切片聚合, 口径与 getLargeScreenChargingPower 一致(无 status 过滤) -->
    <select id="daySliceAgg" resultType="java.util.Map">
        SELECT site_id AS site_id,
sql/large_screen_stat.sql
@@ -66,6 +66,17 @@
  AND index_name = 'idx_create_time'
ORDER BY table_name;
-- 1.2 用户-站点对表(充电用户数精确去重)
-- 背景: 预聚合表的 user_count 按站点求和, 跨站充电用户会重复计数。
--       本表记录"每个站点出现过哪些用户"(所有订单口径, 与工作台 countAllUserData 一致),
--       接口对选中站点做 COUNT(DISTINCT app_user_id) 精确去重。
CREATE TABLE IF NOT EXISTS `charging_pile_order`.`t_large_screen_user_site` (
  `site_id` int NOT NULL COMMENT '站点ID',
  `app_user_id` bigint NOT NULL COMMENT '用户ID',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`site_id`,`app_user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='大屏充电用户-站点对(精确去重)';
-- ============================================================================
-- 上线后数据一致性对比(只读,可在线执行;对比新老口径差值,应≈0)
-- 老口径: 现接口 getLargeScreenSummary 的原始 SQL(挑少量站点对比,避免生产压力)