longz
2026-08-04 5b434085676fd5c701a4a9ed86047b2143cae2a1
fix: 退款列表查询优化(MyCat 分片下推/逆反查/时间筛选)

- 列表/COUNT 改分支内 ORDER BY+LIMIT 下推(branchLimit=offset+pageSize),避免 90 张分片表全量拉回代理层排序
- 手工分页绕过分页拦截器对 UNION 的全量 COUNT,count 拆三分支求和
- serialNumber/siteIds 筛选改逆反查(订单 id 先查后按分片键 IN 路由),去掉跨分片 LEFT JOIN
- ChargingRefundDto 增加 refundTime1/refundTime2,过滤条件内联到各 UNION 分支
- 逆反查加 ORDER BY id 保证截断确定性,达上限告警
- 同步更新 PayOrderQueryPerformanceMapperTest 锚点并新增分支下推守卫

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5个文件已修改
768 ■■■■■ 已修改文件
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/dto/ChargingRefundDto.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/mapper/TChargingOrderMapper.java 65 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TChargingOrderServiceImpl.java 108 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/resources/mapper/order/TChargingOrderMapper.xml 500 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/mapper/PayOrderQueryPerformanceMapperTest.java 91 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/dto/ChargingRefundDto.java
@@ -18,4 +18,8 @@
    private Boolean specialRefund;
    @ApiModelProperty(value = "可查看站点ID", hidden = true)
    private List<Integer> siteIds;
    @ApiModelProperty("退款开始时间 yyyy-MM-dd HH:mm:ss")
    private String refundTime1;
    @ApiModelProperty("退款结束时间 yyyy-MM-dd HH:mm:ss")
    private String refundTime2;
}
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/mapper/TChargingOrderMapper.java
@@ -110,10 +110,69 @@
    List<Map<String, Object>> enterpriseChargingPeriodCounts(@Param("orderIds") List<Long> orderIds);
    List<TChargingOrderRefund> getRefundList(@Param("pageInfo") PageInfo<TChargingOrderRefund> pageInfo, @Param("data") ChargingRefundDto chargingRefundDto);
    /**
     * 退款列表(分片下推版)。不带 IPage 参数,避免分页拦截器对 UNION 全量 COUNT。
     * 每个 UNION 分支内联 ORDER BY refund_time DESC LIMIT branchLimit(=offset+pageSize),MyCat 可下推到各分片表,
     * 代理层只归并 3×branchLimit 行再按外层 LIMIT offset,pageSize 取页。
     *
     * @param offset      跳过行数(pageCurr-1)×pageSize
     * @param limit       每页行数 pageSize
     * @param branchLimit 每个 UNION 分支的最大取数 offset+pageSize
     */
    List<TChargingOrderRefund> getRefundListPage(@Param("data") ChargingRefundDto chargingRefundDto,
                                                 @Param("offset") long offset, @Param("limit") int limit,
                                                 @Param("branchLimit") int branchLimit);
    List<TChargingOrderRefund> getRefundListWithoutOrderJoin(@Param("pageInfo") PageInfo<TChargingOrderRefund> pageInfo,
                                                             @Param("data") ChargingRefundDto chargingRefundDto);
    /**
     * 退款总数(分片下推版)。三分支分别 COUNT 再 SUM,每个 COUNT 可下推到各分片表。
     */
    long countRefundList(@Param("data") ChargingRefundDto chargingRefundDto);
    /**
     * 退款列表(筛选逆反查版)。serialNumber/siteIds 筛选时先按条件查订单 id(可路由/走索引),
     * 再按各类型订单 id IN 过滤退款表(分片键路由),替代跨分片 LEFT JOIN。
     * type1/type2/type3 订单 id 列表:null = 该类型不过滤;空列表 = 该类型无匹配(返回空)。
     *
     * @param branchLimit 每个 UNION 分支的最大取数 offset+pageSize
     */
    List<TChargingOrderRefund> getRefundListByOrderIds(@Param("data") ChargingRefundDto chargingRefundDto,
                                                       @Param("type1OrderIds") List<Long> type1OrderIds,
                                                       @Param("type2OrderIds") List<Long> type2OrderIds,
                                                       @Param("type3OrderIds") List<Long> type3OrderIds,
                                                       @Param("offset") long offset, @Param("limit") int limit,
                                                       @Param("branchLimit") int branchLimit);
    /**
     * 退款总数(筛选逆反查版),与 getRefundListByOrderIds 同构。
     */
    long countRefundListByOrderIds(@Param("data") ChargingRefundDto chargingRefundDto,
                                   @Param("type1OrderIds") List<Long> type1OrderIds,
                                   @Param("type2OrderIds") List<Long> type2OrderIds,
                                   @Param("type3OrderIds") List<Long> type3OrderIds);
    /**
     * 按支付流水号模糊匹配充电订单 id(t_charging_order 按 app_user_id 分片,recharge_serial_number 走新增索引)。
     */
    List<Long> listChargingOrderIdsBySerialNumber(@Param("serialNumber") String serialNumber,
                                                  @Param("limit") int limit);
    /**
     * 按支付流水号模糊匹配商城订单 id。
     */
    List<Long> listShoppingOrderIdsBySerialNumber(@Param("serialNumber") String serialNumber,
                                                  @Param("limit") int limit);
    /**
     * 按支付流水号模糊匹配会员订单 id。
     */
    List<Long> listVipOrderIdsBySerialNumber(@Param("serialNumber") String serialNumber,
                                             @Param("limit") int limit);
    /**
     * 按站点 id 匹配充电订单 id(t_charging_order 的 site_id 走新增索引)。
     */
    List<Long> listChargingOrderIdsBySiteIds(@Param("siteIds") List<Integer> siteIds,
                                             @Param("limit") int limit);
    List<ChargingOrderGroup> chargingOrderGroup(@Param("chargingPercentProvinceDto") ChargingPercentProvinceDto chargingPercentProvinceDto);
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TChargingOrderServiceImpl.java
@@ -3347,17 +3347,40 @@
                .divide(BigDecimal.valueOf(3600), 2, RoundingMode.HALF_UP);
    }
    private static final int SERIAL_ORDER_ID_SEARCH_LIMIT = 5000;
    private static final int SITE_ORDER_ID_SEARCH_LIMIT = 50000;
    @Override
    public R<PageInfo<TChargingOrderRefund>> getRefundList(ChargingRefundDto chargingRefundDto) {
        PageInfo<TChargingOrderRefund> pageInfo = new PageInfo<>(chargingRefundDto.getPageCurr(), chargingRefundDto.getPageSize());
        boolean withoutOrderJoin = !StringUtils.hasLength(chargingRefundDto.getSerialNumber())
                && (chargingRefundDto.getSiteIds() == null || chargingRefundDto.getSiteIds().isEmpty());
        List<TChargingOrderRefund> list = withoutOrderJoin
                ? this.baseMapper.getRefundListWithoutOrderJoin(pageInfo, chargingRefundDto)
                : this.baseMapper.getRefundList(pageInfo, chargingRefundDto);
        if (withoutOrderJoin) {
            enrichRefundOrderInfo(list);
        int pageCurr = chargingRefundDto.getPageCurr() == null ? 1 : chargingRefundDto.getPageCurr();
        int pageSize = chargingRefundDto.getPageSize() == null ? 50 : chargingRefundDto.getPageSize();
        PageInfo<TChargingOrderRefund> pageInfo = new PageInfo<>(pageCurr, pageSize);
        long offset = (long) (pageCurr - 1) * pageSize;
        // 每个 UNION 分支须取 offset+pageSize 行,外层再按 offset,pageSize 切页,否则翻页时池子不足导致丢数据
        int branchLimit = (int) Math.min(offset + pageSize, Integer.MAX_VALUE);
        boolean filterBySerial = StringUtils.hasLength(chargingRefundDto.getSerialNumber());
        boolean filterBySite = chargingRefundDto.getSiteIds() != null && !chargingRefundDto.getSiteIds().isEmpty();
        List<TChargingOrderRefund> list;
        if (!filterBySerial && !filterBySite) {
            // 无筛选:手工分页。分页拦截器会对 UNION 查询生成全量 COUNT 且无法把 ORDER BY/LIMIT 下推到分片,
            // 这里改为分支内下推(getRefundListPage)加分支 COUNT 求和(countRefundList)
            pageInfo.setTotal(this.baseMapper.countRefundList(chargingRefundDto));
            list = this.baseMapper.getRefundListPage(chargingRefundDto, offset, pageSize, branchLimit);
        } else {
            // 筛选:逆反查。先按条件查订单 id(t_charging_order 等可下推/走索引),
            // 再按退款表分片键 IN 过滤(可路由),替代跨分片 LEFT JOIN
            RefundOrderIds ids = resolveRefundOrderIds(chargingRefundDto);
            if (ids.isEmpty()) {
                pageInfo.setRecords(Collections.emptyList());
                return R.ok(pageInfo);
        }
            pageInfo.setTotal(this.baseMapper.countRefundListByOrderIds(chargingRefundDto,
                    ids.type1OrderIds, ids.type2OrderIds, ids.type3OrderIds));
            list = this.baseMapper.getRefundListByOrderIds(chargingRefundDto,
                    ids.type1OrderIds, ids.type2OrderIds, ids.type3OrderIds, offset, pageSize, branchLimit);
        }
        enrichRefundOrderInfo(list);
        for (TChargingOrderRefund refund : list) {
            refund.setUid(refund.getType() + "-" + refund.getId());
        }
@@ -3366,6 +3389,75 @@
        return R.ok(pageInfo);
    }
    /**
     * 逆反查订单 id:serialNumber 匹配充电/商城/会员订单 id(各自 LIMIT 防爆),
     * siteIds 匹配充电订单 id;两者同时存在时取交集。
     * 返回约定:null = 该类型不过滤;空列表 = 该类型无匹配(SQL 侧 AND 1=0)。
     */
    private RefundOrderIds resolveRefundOrderIds(ChargingRefundDto chargingRefundDto) {
        List<Long> type1OrderIds = null;
        List<Long> type2OrderIds = null;
        List<Long> type3OrderIds = null;
        if (StringUtils.hasLength(chargingRefundDto.getSerialNumber())) {
            // 模糊匹配结果可能很大(短流水号),截断上限防 IN 列表爆炸;匹配不到则列表为空
            type1OrderIds = safeIds(this.baseMapper.listChargingOrderIdsBySerialNumber(
                    chargingRefundDto.getSerialNumber(), SERIAL_ORDER_ID_SEARCH_LIMIT));
            type2OrderIds = safeIds(this.baseMapper.listShoppingOrderIdsBySerialNumber(
                    chargingRefundDto.getSerialNumber(), SERIAL_ORDER_ID_SEARCH_LIMIT));
            type3OrderIds = safeIds(this.baseMapper.listVipOrderIdsBySerialNumber(
                    chargingRefundDto.getSerialNumber(), SERIAL_ORDER_ID_SEARCH_LIMIT));
            warnIfTruncated(chargingRefundDto.getSerialNumber(), type1OrderIds,
                    SERIAL_ORDER_ID_SEARCH_LIMIT, "充电订单");
            warnIfTruncated(chargingRefundDto.getSerialNumber(), type2OrderIds,
                    SERIAL_ORDER_ID_SEARCH_LIMIT, "商城订单");
            warnIfTruncated(chargingRefundDto.getSerialNumber(), type3OrderIds,
                    SERIAL_ORDER_ID_SEARCH_LIMIT, "会员订单");
        }
        if (chargingRefundDto.getSiteIds() != null && !chargingRefundDto.getSiteIds().isEmpty()) {
            List<Long> siteMatched = safeIds(this.baseMapper.listChargingOrderIdsBySiteIds(
                    chargingRefundDto.getSiteIds(), SITE_ORDER_ID_SEARCH_LIMIT));
            warnIfTruncated(String.valueOf(chargingRefundDto.getSiteIds()), siteMatched,
                    SITE_ORDER_ID_SEARCH_LIMIT, "站点订单");
            if (type1OrderIds == null) {
                type1OrderIds = siteMatched;
            } else {
                type1OrderIds.retainAll(new HashSet<>(siteMatched));
            }
        }
        return new RefundOrderIds(type1OrderIds, type2OrderIds, type3OrderIds);
    }
    /** 反查结果达到上限说明可能被截断,告警便于排查(截断会导致部分退款不在列表内) */
    private void warnIfTruncated(String condition, List<Long> ids, int limit, String orderType) {
        if (ids.size() >= limit) {
            log.warn("退款列表逆反查:条件[{}]匹配{}数达到上限{},结果可能被截断", condition, orderType, limit);
        }
    }
    private static List<Long> safeIds(List<Long> ids) {
        return ids == null ? new ArrayList<>() : new ArrayList<>(ids);
    }
    /** 逆反查结果:null = 不过滤;空列表 = 无匹配 */
    private static class RefundOrderIds {
        private final List<Long> type1OrderIds;
        private final List<Long> type2OrderIds;
        private final List<Long> type3OrderIds;
        private RefundOrderIds(List<Long> type1OrderIds, List<Long> type2OrderIds, List<Long> type3OrderIds) {
            this.type1OrderIds = type1OrderIds;
            this.type2OrderIds = type2OrderIds;
            this.type3OrderIds = type3OrderIds;
        }
        /** 三个类型全部明确无匹配(非 null 且为空)时,整页为空,可短路 */
        private boolean isEmpty() {
            return type1OrderIds != null && type1OrderIds.isEmpty()
                    && type2OrderIds != null && type2OrderIds.isEmpty()
                    && type3OrderIds != null && type3OrderIds.isEmpty();
        }
    }
    private void enrichRefundOrderInfo(List<TChargingOrderRefund> refunds) {
        if (refunds == null || refunds.isEmpty()) {
            return;
ruoyi-service/ruoyi-order/src/main/resources/mapper/order/TChargingOrderMapper.xml
@@ -294,115 +294,15 @@
        ORDER BY COALESCE(o.pay_time, o.create_time, o.end_time) DESC, o.create_time DESC, o.id DESC
    </select>
    <select id="getRefundList" resultType="com.ruoyi.order.api.model.TChargingOrderRefund">
        select * from (
        SELECT
        re.id,
        re.`code`,
        re.refund_code,
        re.refund_title,
        re.refund_reason,
        re.refund_amount,
        re.balance_refund_amount,
        re.third_refund_amount,
        re.refund_total_amount,
        re.refund_time,
        re.pay_type,
        re.pay_amount,
        re.refund_remark,
        co.recharge_serial_number as serail_number,
        co.payment_amount,
        co.create_time as pay_time,
        co.id as order_id,
        co.app_user_id as user_id,
        co.site_id as site_id,
        co.pay_method as pay_method,
        1 as type
        FROM t_charging_order_refund re
        LEFT JOIN t_charging_order co ON re.charging_order_id = co.id
        <if test="data.specialRefund != null and data.specialRefund">
            WHERE re.refund_title = '特殊退款处理'
        </if>
        <if test="data.specialRefund == null or !data.specialRefund">
        UNION ALL
        SELECT
        re.id,
        re.`code`,
        re.refund_code,
        re.refund_title,
        re.refund_reason,
        re.refund_amount,
        0 as balance_refund_amount,
        re.refund_amount as third_refund_amount,
        re.refund_total_amount,
        re.refund_time,
        re.pay_type,
        re.pay_amount,
        re.refund_remark,
        so.serial_number,
        so.payment_amount,
        so.create_time as pay_time,
        so.id as order_id,
        so.app_user_id as user_id,
        null as site_id,
        3 as pay_method,
        2 as type
        FROM t_shopping_order_refund re
        LEFT JOIN t_shopping_order so on re.shopping_order_id = so.id
        UNION ALL
        SELECT
        vr.id,
        vr.`code`,
        vr.refund_code,
        vr.refund_title,
        vr.refund_reason,
        vr.refund_amount,
        0 as balance_refund_amount,
        vr.refund_amount as third_refund_amount,
        vr.refund_total_amount,
        vr.refund_time,
        vr.pay_type,
        vr.pay_amount,
        vr.refund_remark,
        vo.serial_number,
        vo.payment_amount,
        vo.create_time as pay_time,
        vo.id as order_id,
        vo.app_user_id as user_id,
        null as site_id,
        3 as pay_method,
        3 as type
        FROM t_vip_order_refund vr
        LEFT JOIN t_vip_order vo on vr.vip_order_id = vo.id
        </if>
        ) o
        <where>
            <if test="data.code != null and data.code != ''">
                AND o.code LIKE CONCAT('%',#{data.code},'%')
            </if>
            <if test="data.refundCode != null and data.refundCode != ''">
                AND o.refund_code LIKE CONCAT('%',#{data.refundCode},'%')
            </if>
            <if test="data.serialNumber != null and data.serialNumber != ''">
                AND o.serail_number LIKE CONCAT('%',#{data.serialNumber},'%')
            </if>
            <if test="data.specialRefund != null and data.specialRefund">
                AND o.refund_title = '特殊退款处理'
            </if>
            <if test="data.siteIds != null and data.siteIds.size() > 0">
                AND (o.type != 1 OR o.site_id IN
                <foreach collection="data.siteIds" item="siteId" open="(" separator="," close=")">
                    #{siteId}
                </foreach>
                )
            </if>
        </where>
        order by o.refund_time desc
    </select>
    <select id="getRefundListWithoutOrderJoin" resultType="com.ruoyi.order.api.model.TChargingOrderRefund">
    <!--
        退款列表(筛选逆反查版)。serialNumber/siteIds 筛选时,先由 Service 按条件查出订单 id 列表,
        再按各类型订单 id IN 过滤退款表(IN 为退款表分片键,MyCat 可路由),替代跨分片 LEFT JOIN。
        type1/type2/type3 订单 id:null = 不过滤;空列表 = 无匹配(AND 1=0)。
        各分支同样内联 ORDER BY + LIMIT 下推。
    -->
    <select id="getRefundListByOrderIds" resultType="com.ruoyi.order.api.model.TChargingOrderRefund">
        SELECT * FROM (
        SELECT
        (SELECT
        re.id,
        re.`code`,
        re.refund_code,
@@ -429,10 +329,31 @@
            <if test="data.refundCode != null and data.refundCode != ''">
                AND re.refund_code LIKE CONCAT('%', #{data.refundCode}, '%')
            </if>
            <if test="data.refundTime1 != null and data.refundTime1 != ''">
                AND re.refund_time &gt;= #{data.refundTime1}
            </if>
            <if test="data.refundTime2 != null and data.refundTime2 != ''">
                AND re.refund_time &lt;= #{data.refundTime2}
            </if>
            <choose>
                <when test="type1OrderIds != null and type1OrderIds.size() > 0">
                    AND re.charging_order_id IN
                    <foreach collection="type1OrderIds" item="id" open="(" separator="," close=")">
                        #{id}
                    </foreach>
                </when>
                <otherwise>
                    <if test="type1OrderIds != null">
                        AND 1 = 0
                    </if>
                </otherwise>
            </choose>
        </where>
        ORDER BY re.refund_time DESC
        LIMIT #{branchLimit})
        <if test="data.specialRefund == null or !data.specialRefund">
        UNION ALL
        SELECT
        (SELECT
        re.id,
        re.`code`,
        re.refund_code,
@@ -456,9 +377,30 @@
            <if test="data.refundCode != null and data.refundCode != ''">
                AND re.refund_code LIKE CONCAT('%', #{data.refundCode}, '%')
            </if>
            <if test="data.refundTime1 != null and data.refundTime1 != ''">
                AND re.refund_time &gt;= #{data.refundTime1}
            </if>
            <if test="data.refundTime2 != null and data.refundTime2 != ''">
                AND re.refund_time &lt;= #{data.refundTime2}
            </if>
            <choose>
                <when test="type2OrderIds != null and type2OrderIds.size() > 0">
                    AND re.shopping_order_id IN
                    <foreach collection="type2OrderIds" item="id" open="(" separator="," close=")">
                        #{id}
                    </foreach>
                </when>
                <otherwise>
                    <if test="type2OrderIds != null">
                        AND 1 = 0
                    </if>
                </otherwise>
            </choose>
        </where>
        ORDER BY re.refund_time DESC
        LIMIT #{branchLimit})
        UNION ALL
        SELECT
        (SELECT
        re.id,
        re.`code`,
        re.refund_code,
@@ -482,10 +424,348 @@
            <if test="data.refundCode != null and data.refundCode != ''">
                AND re.refund_code LIKE CONCAT('%', #{data.refundCode}, '%')
            </if>
            <if test="data.refundTime1 != null and data.refundTime1 != ''">
                AND re.refund_time &gt;= #{data.refundTime1}
            </if>
            <if test="data.refundTime2 != null and data.refundTime2 != ''">
                AND re.refund_time &lt;= #{data.refundTime2}
            </if>
            <choose>
                <when test="type3OrderIds != null and type3OrderIds.size() > 0">
                    AND re.vip_order_id IN
                    <foreach collection="type3OrderIds" item="id" open="(" separator="," close=")">
                        #{id}
                    </foreach>
                </when>
                <otherwise>
                    <if test="type3OrderIds != null">
                        AND 1 = 0
                    </if>
                </otherwise>
            </choose>
        </where>
        ORDER BY re.refund_time DESC
        LIMIT #{branchLimit})
        </if>
        ) o
        ORDER BY o.refund_time DESC
        LIMIT #{offset}, #{limit}
    </select>
    <!--
        退款总数(筛选逆反查版),与 getRefundListByOrderIds 同构,三分支 COUNT 可下推。
    -->
    <select id="countRefundListByOrderIds" resultType="long">
        SELECT SUM(cnt) FROM (
        (SELECT COUNT(*) AS cnt
        FROM t_charging_order_refund re
        <where>
            <if test="data.specialRefund != null and data.specialRefund">
                AND re.refund_title = '特殊退款处理'
            </if>
            <if test="data.code != null and data.code != ''">
                AND re.code LIKE CONCAT('%', #{data.code}, '%')
            </if>
            <if test="data.refundCode != null and data.refundCode != ''">
                AND re.refund_code LIKE CONCAT('%', #{data.refundCode}, '%')
            </if>
            <if test="data.refundTime1 != null and data.refundTime1 != ''">
                AND re.refund_time &gt;= #{data.refundTime1}
            </if>
            <if test="data.refundTime2 != null and data.refundTime2 != ''">
                AND re.refund_time &lt;= #{data.refundTime2}
            </if>
            <choose>
                <when test="type1OrderIds != null and type1OrderIds.size() > 0">
                    AND re.charging_order_id IN
                    <foreach collection="type1OrderIds" item="id" open="(" separator="," close=")">
                        #{id}
                    </foreach>
                </when>
                <otherwise>
                    <if test="type1OrderIds != null">
                        AND 1 = 0
                    </if>
                </otherwise>
            </choose>
        </where>)
        <if test="data.specialRefund == null or !data.specialRefund">
        UNION ALL
        (SELECT COUNT(*) AS cnt
        FROM t_shopping_order_refund re
        <where>
            <if test="data.code != null and data.code != ''">
                AND re.code LIKE CONCAT('%', #{data.code}, '%')
            </if>
            <if test="data.refundCode != null and data.refundCode != ''">
                AND re.refund_code LIKE CONCAT('%', #{data.refundCode}, '%')
            </if>
            <if test="data.refundTime1 != null and data.refundTime1 != ''">
                AND re.refund_time &gt;= #{data.refundTime1}
            </if>
            <if test="data.refundTime2 != null and data.refundTime2 != ''">
                AND re.refund_time &lt;= #{data.refundTime2}
            </if>
            <choose>
                <when test="type2OrderIds != null and type2OrderIds.size() > 0">
                    AND re.shopping_order_id IN
                    <foreach collection="type2OrderIds" item="id" open="(" separator="," close=")">
                        #{id}
                    </foreach>
                </when>
                <otherwise>
                    <if test="type2OrderIds != null">
                        AND 1 = 0
                    </if>
                </otherwise>
            </choose>
        </where>)
        UNION ALL
        (SELECT COUNT(*) AS cnt
        FROM t_vip_order_refund re
        <where>
            <if test="data.code != null and data.code != ''">
                AND re.code LIKE CONCAT('%', #{data.code}, '%')
            </if>
            <if test="data.refundCode != null and data.refundCode != ''">
                AND re.refund_code LIKE CONCAT('%', #{data.refundCode}, '%')
            </if>
            <if test="data.refundTime1 != null and data.refundTime1 != ''">
                AND re.refund_time &gt;= #{data.refundTime1}
            </if>
            <if test="data.refundTime2 != null and data.refundTime2 != ''">
                AND re.refund_time &lt;= #{data.refundTime2}
            </if>
            <choose>
                <when test="type3OrderIds != null and type3OrderIds.size() > 0">
                    AND re.vip_order_id IN
                    <foreach collection="type3OrderIds" item="id" open="(" separator="," close=")">
                        #{id}
                    </foreach>
                </when>
                <otherwise>
                    <if test="type3OrderIds != null">
                        AND 1 = 0
                    </if>
                </otherwise>
            </choose>
        </where>)
        </if>
        ) t
    </select>
    <!--
        按支付流水号模糊匹配充电订单 id(逆反查第一步)。
        注意:前导通配符 LIKE '%x%' 无法走 recharge_serial_number 索引,MyCat 广播 50 张分片表全表扫描;
        匹配行一般极少(流水号近似唯一),结果集小,可接受。ORDER BY id 保证截断时结果确定性。
    -->
    <select id="listChargingOrderIdsBySerialNumber" resultType="long">
        SELECT id FROM t_charging_order
        WHERE recharge_serial_number LIKE CONCAT('%', #{serialNumber}, '%')
        ORDER BY id DESC
        LIMIT #{limit}
    </select>
    <!-- 按支付流水号模糊匹配商城订单 id(同前导通配符说明,广播 20 张分片表) -->
    <select id="listShoppingOrderIdsBySerialNumber" resultType="long">
        SELECT id FROM t_shopping_order
        WHERE serial_number LIKE CONCAT('%', #{serialNumber}, '%')
        ORDER BY id DESC
        LIMIT #{limit}
    </select>
    <!-- 按支付流水号模糊匹配会员订单 id(同前导通配符说明,广播 20 张分片表) -->
    <select id="listVipOrderIdsBySerialNumber" resultType="long">
        SELECT id FROM t_vip_order
        WHERE serial_number LIKE CONCAT('%', #{serialNumber}, '%')
        ORDER BY id DESC
        LIMIT #{limit}
    </select>
    <!-- 按站点匹配充电订单 id(逆反查第一步,site_id 走新增索引;ORDER BY id 保证截断时结果确定性) -->
    <select id="listChargingOrderIdsBySiteIds" resultType="long">
        SELECT id FROM t_charging_order
        WHERE site_id IN
        <foreach collection="siteIds" item="siteId" open="(" separator="," close=")">
            #{siteId}
        </foreach>
        ORDER BY id DESC
        LIMIT #{limit}
    </select>
    <!--
        退款列表(分片下推版)。
        三个 UNION 分支各自单表查询,分支内 ORDER BY + LIMIT 可被 MyCat 下推到每张分片表(每表最多取 offset+limit 行),
        代理层只归并三分支共 3×(offset+limit) 行再取页。相比旧版(90 张分片表全量拉回代理层排序)避免全量传输。
    -->
    <select id="getRefundListPage" resultType="com.ruoyi.order.api.model.TChargingOrderRefund">
        SELECT * FROM (
        (SELECT
        re.id,
        re.`code`,
        re.refund_code,
        re.refund_title,
        re.refund_reason,
        re.refund_amount,
        re.balance_refund_amount,
        re.third_refund_amount,
        re.refund_total_amount,
        re.refund_time,
        re.pay_type,
        re.pay_amount,
        re.refund_remark,
        re.charging_order_id AS order_id,
        1 AS type
        FROM t_charging_order_refund re
        <where>
            <if test="data.specialRefund != null and data.specialRefund">
                AND re.refund_title = '特殊退款处理'
            </if>
            <if test="data.code != null and data.code != ''">
                AND re.code LIKE CONCAT('%', #{data.code}, '%')
            </if>
            <if test="data.refundCode != null and data.refundCode != ''">
                AND re.refund_code LIKE CONCAT('%', #{data.refundCode}, '%')
            </if>
            <if test="data.refundTime1 != null and data.refundTime1 != ''">
                AND re.refund_time &gt;= #{data.refundTime1}
            </if>
            <if test="data.refundTime2 != null and data.refundTime2 != ''">
                AND re.refund_time &lt;= #{data.refundTime2}
            </if>
        </where>
        ORDER BY re.refund_time DESC
        LIMIT #{branchLimit})
        <if test="data.specialRefund == null or !data.specialRefund">
        UNION ALL
        (SELECT
        re.id,
        re.`code`,
        re.refund_code,
        re.refund_title,
        re.refund_reason,
        re.refund_amount,
        0 AS balance_refund_amount,
        re.refund_amount AS third_refund_amount,
        re.refund_total_amount,
        re.refund_time,
        re.pay_type,
        re.pay_amount,
        re.refund_remark,
        re.shopping_order_id AS order_id,
        2 AS type
        FROM t_shopping_order_refund re
        <where>
            <if test="data.code != null and data.code != ''">
                AND re.code LIKE CONCAT('%', #{data.code}, '%')
            </if>
            <if test="data.refundCode != null and data.refundCode != ''">
                AND re.refund_code LIKE CONCAT('%', #{data.refundCode}, '%')
            </if>
            <if test="data.refundTime1 != null and data.refundTime1 != ''">
                AND re.refund_time &gt;= #{data.refundTime1}
            </if>
            <if test="data.refundTime2 != null and data.refundTime2 != ''">
                AND re.refund_time &lt;= #{data.refundTime2}
            </if>
        </where>
        ORDER BY re.refund_time DESC
        LIMIT #{branchLimit})
        UNION ALL
        (SELECT
        re.id,
        re.`code`,
        re.refund_code,
        re.refund_title,
        re.refund_reason,
        re.refund_amount,
        0 AS balance_refund_amount,
        re.refund_amount AS third_refund_amount,
        re.refund_total_amount,
        re.refund_time,
        re.pay_type,
        re.pay_amount,
        re.refund_remark,
        re.vip_order_id AS order_id,
        3 AS type
        FROM t_vip_order_refund re
        <where>
            <if test="data.code != null and data.code != ''">
                AND re.code LIKE CONCAT('%', #{data.code}, '%')
            </if>
            <if test="data.refundCode != null and data.refundCode != ''">
                AND re.refund_code LIKE CONCAT('%', #{data.refundCode}, '%')
            </if>
            <if test="data.refundTime1 != null and data.refundTime1 != ''">
                AND re.refund_time &gt;= #{data.refundTime1}
            </if>
            <if test="data.refundTime2 != null and data.refundTime2 != ''">
                AND re.refund_time &lt;= #{data.refundTime2}
            </if>
        </where>
        ORDER BY re.refund_time DESC
        LIMIT #{branchLimit})
        </if>
        ) o
        ORDER BY o.refund_time DESC
        LIMIT #{offset}, #{limit}
    </select>
    <!--
        退款总数(分片下推版)。三分支各自 COUNT(可下推到每张分片表),代理层只 SUM 三个数。
        旧版由分页拦截器生成 SELECT COUNT(*) FROM (90 表全量 UNION),MyCat 无法下推导致全量扫描。
    -->
    <select id="countRefundList" resultType="long">
        SELECT SUM(cnt) FROM (
        (SELECT COUNT(*) AS cnt
        FROM t_charging_order_refund re
        <where>
            <if test="data.specialRefund != null and data.specialRefund">
                AND re.refund_title = '特殊退款处理'
            </if>
            <if test="data.code != null and data.code != ''">
                AND re.code LIKE CONCAT('%', #{data.code}, '%')
            </if>
            <if test="data.refundCode != null and data.refundCode != ''">
                AND re.refund_code LIKE CONCAT('%', #{data.refundCode}, '%')
            </if>
            <if test="data.refundTime1 != null and data.refundTime1 != ''">
                AND re.refund_time &gt;= #{data.refundTime1}
            </if>
            <if test="data.refundTime2 != null and data.refundTime2 != ''">
                AND re.refund_time &lt;= #{data.refundTime2}
            </if>
        </where>)
        <if test="data.specialRefund == null or !data.specialRefund">
        UNION ALL
        (SELECT COUNT(*) AS cnt
        FROM t_shopping_order_refund re
        <where>
            <if test="data.code != null and data.code != ''">
                AND re.code LIKE CONCAT('%', #{data.code}, '%')
            </if>
            <if test="data.refundCode != null and data.refundCode != ''">
                AND re.refund_code LIKE CONCAT('%', #{data.refundCode}, '%')
            </if>
            <if test="data.refundTime1 != null and data.refundTime1 != ''">
                AND re.refund_time &gt;= #{data.refundTime1}
            </if>
            <if test="data.refundTime2 != null and data.refundTime2 != ''">
                AND re.refund_time &lt;= #{data.refundTime2}
            </if>
        </where>)
        UNION ALL
        (SELECT COUNT(*) AS cnt
        FROM t_vip_order_refund re
        <where>
            <if test="data.code != null and data.code != ''">
                AND re.code LIKE CONCAT('%', #{data.code}, '%')
            </if>
            <if test="data.refundCode != null and data.refundCode != ''">
                AND re.refund_code LIKE CONCAT('%', #{data.refundCode}, '%')
            </if>
            <if test="data.refundTime1 != null and data.refundTime1 != ''">
                AND re.refund_time &gt;= #{data.refundTime1}
            </if>
            <if test="data.refundTime2 != null and data.refundTime2 != ''">
                AND re.refund_time &lt;= #{data.refundTime2}
            </if>
        </where>)
        </if>
        ) t
    </select>
    <select id="chargingOrderGroup" resultType="com.ruoyi.common.core.dto.ChargingOrderGroup">
        SELECT SUM(tc.charging_capacity) as charging_capacity,tc.site_id
ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/mapper/PayOrderQueryPerformanceMapperTest.java
@@ -16,20 +16,22 @@
class PayOrderQueryPerformanceMapperTest {
    @Test
    void readsRefundStatusFromOrderTablesWithoutJoiningRefundDetails() throws IOException {
        InputStream input = getClass().getClassLoader()
    private static String readMapper() throws IOException {
        InputStream input = PayOrderQueryPerformanceMapperTest.class.getClassLoader()
                .getResourceAsStream("mapper/order/TChargingOrderMapper.xml");
        assertNotNull(input);
        String mapper;
        try (BufferedReader reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8))) {
            mapper = reader.lines().collect(Collectors.joining("\n"));
            return reader.lines().collect(Collectors.joining("\n"));
        }
    }
    @Test
    void readsRefundStatusFromOrderTablesWithoutJoiningRefundDetails() throws IOException {
        String mapper = readMapper();
        String payOrderQuery = mapper.substring(
                mapper.indexOf("<select id=\"payOrderQuery\""),
                mapper.indexOf("<select id=\"getRefundList\""));
                mapper.indexOf("<select id=\"getRefundListByOrderIds\""));
        assertEquals(3, countOccurrences(payOrderQuery, "a.refund_status as refund_status"));
        assertFalse(payOrderQuery.contains("t_charging_order_refund"));
        assertFalse(payOrderQuery.contains("t_shopping_order_refund"));
@@ -38,18 +40,11 @@
    @Test
    void invoiceOrderViewRetainsLinkedOrdersAfterPaymentStatusChanges() throws IOException {
        InputStream input = getClass().getClassLoader()
                .getResourceAsStream("mapper/order/TChargingOrderMapper.xml");
        assertNotNull(input);
        String mapper;
        try (BufferedReader reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8))) {
            mapper = reader.lines().collect(Collectors.joining("\n"));
        }
        String mapper = readMapper();
        String payOrderQuery = mapper.substring(
                mapper.indexOf("<select id=\"payOrderQuery\""),
                mapper.indexOf("<select id=\"getRefundList\""));
                mapper.indexOf("<select id=\"getRefundListByOrderIds\""));
        assertEquals(3, countOccurrences(payOrderQuery, "#{data.invoiceId} IS NOT NULL"));
        assertTrue(payOrderQuery.contains(
                "a.recharge_payment_status = 2 OR a.status = -1 OR #{data.invoiceId} IS NOT NULL"));
@@ -59,56 +54,54 @@
    @Test
    void paymentOrderListUsesLatestPaymentTimeAsItsDefaultSort() throws IOException {
        InputStream input = getClass().getClassLoader()
                .getResourceAsStream("mapper/order/TChargingOrderMapper.xml");
        assertNotNull(input);
        String mapper;
        try (BufferedReader reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8))) {
            mapper = reader.lines().collect(Collectors.joining("\n"));
        }
        String mapper = readMapper();
        String payOrderQuery = mapper.substring(
                mapper.indexOf("<select id=\"payOrderQuery\""),
                mapper.indexOf("<select id=\"getRefundList\""));
                mapper.indexOf("<select id=\"getRefundListByOrderIds\""));
        assertTrue(payOrderQuery.contains(
                "ORDER BY COALESCE(o.pay_time, o.create_time, o.end_time) DESC, o.create_time DESC, o.id DESC"));
    }
    @Test
    void refundListAppliesPartnerSiteScopeInsidePagedQuery() throws IOException {
        InputStream input = getClass().getClassLoader()
                .getResourceAsStream("mapper/order/TChargingOrderMapper.xml");
        assertNotNull(input);
    void refundListBranchesPushLimitIntoEachShard() throws IOException {
        String mapper = readMapper();
        String mapper;
        try (BufferedReader reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8))) {
            mapper = reader.lines().collect(Collectors.joining("\n"));
        String refundList = mapper.substring(
                mapper.indexOf("<select id=\"getRefundListPage\""),
                mapper.indexOf("<select id=\"countRefundList\""));
        // 每个 UNION 分支必须内联 ORDER BY + LIMIT(branchLimit=offset+pageSize),外层只做归并切页
        assertEquals(3, countOccurrences(refundList, "ORDER BY re.refund_time DESC"));
        assertEquals(3, countOccurrences(refundList, "LIMIT #{branchLimit})"));
        assertTrue(refundList.contains("LIMIT #{offset}, #{limit}"));
        }
        String refundQuery = mapper.substring(
                mapper.indexOf("<select id=\"getRefundList\""),
                mapper.indexOf("<select id=\"chargingOrderGroup\""));
        assertTrue(refundQuery.contains("data.siteIds"));
        assertTrue(refundQuery.contains("o.site_id IN"));
        assertEquals(3, countOccurrences(refundQuery, "as site_id"));
    @Test
    void refundListAppliesPartnerSiteScopeViaReverseLookup() throws IOException {
        String mapper = readMapper();
        // 合作商站点范围通过逆反查实现:先按 site_id 查订单 id,再按退款表分片键 IN 过滤
        String siteLookup = mapper.substring(
                mapper.indexOf("<select id=\"listChargingOrderIdsBySiteIds\""),
                mapper.indexOf("<select id=\"getRefundListPage\""));
        assertTrue(siteLookup.contains("site_id IN"));
        assertTrue(siteLookup.contains("ORDER BY id DESC"));
        String filteredList = mapper.substring(
                mapper.indexOf("<select id=\"getRefundListByOrderIds\""),
                mapper.indexOf("<select id=\"countRefundListByOrderIds\""));
        assertTrue(filteredList.contains("type1OrderIds"));
        assertTrue(filteredList.contains("re.charging_order_id IN"));
    }
    @Test
    void specialRefundListSkipsUnrelatedOrderUnions() throws IOException {
        InputStream input = getClass().getClassLoader()
                .getResourceAsStream("mapper/order/TChargingOrderMapper.xml");
        assertNotNull(input);
        String mapper;
        try (BufferedReader reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8))) {
            mapper = reader.lines().collect(Collectors.joining("\n"));
        }
        String mapper = readMapper();
        String refundQuery = mapper.substring(
                mapper.indexOf("<select id=\"getRefundList\""),
                mapper.indexOf("<select id=\"chargingOrderGroup\""));
        assertTrue(refundQuery.contains("WHERE re.refund_title = '特殊退款处理'"));
                mapper.indexOf("<select id=\"getRefundListPage\""),
                mapper.indexOf("<select id=\"countRefundList\""));
        assertTrue(refundQuery.contains("refund_title = '特殊退款处理'"));
        assertTrue(refundQuery.contains("<if test=\"data.specialRefund == null or !data.specialRefund\">"));
    }