longz
2026-07-16 3e0246b5d136626616272b3c700d4d5411886745
分账退款fix
8个文件已修改
281 ■■■■■ 已修改文件
ruoyi-api/ruoyi-api-chargingPile/src/main/java/com/ruoyi/chargingPile/api/dto/PageChargingPileListDTO.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-api/ruoyi-api-chargingPile/src/main/java/com/ruoyi/chargingPile/api/model/Site.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-chargingPile/src/main/java/com/ruoyi/chargingPile/controller/SiteController.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-chargingPile/src/main/resources/mapper/chargingPile/TChargingPileMapper.xml 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TChargingOrderServiceImpl.java 107 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/ChargingOrderShareConfigurationTest.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-payment/src/main/java/com/ruoyi/payment/controller/IcbcPayController.java 66 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-payment/src/test/java/com/ruoyi/payment/controller/IcbcPayControllerTest.java 78 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-api/ruoyi-api-chargingPile/src/main/java/com/ruoyi/chargingPile/api/dto/PageChargingPileListDTO.java
@@ -37,6 +37,14 @@
    private String equipmentType;
    @ApiModelProperty(value = "额定功率")
    private BigDecimal ratedPower;
    @ApiModelProperty(value = "是否支持预约 0不支持1支持")
    private Integer supportOrder;
    @ApiModelProperty(value = "允许预约开始时间,格式HH:mm")
    private String reservationStartTime;
    @ApiModelProperty(value = "允许预约结束时间,格式HH:mm")
    private String reservationEndTime;
    @ApiModelProperty(value = "最早可提前预约分钟数")
    private Integer reservationAheadMinute;
    @ApiModelProperty(value = "查看权限")
    private boolean authQueryInfo = true;
    @ApiModelProperty(value = "编辑权限")
ruoyi-api/ruoyi-api-chargingPile/src/main/java/com/ruoyi/chargingPile/api/model/Site.java
@@ -459,6 +459,9 @@
    @TableField("reservation_end_time")
    @ApiModelProperty(value = "允许预约结束时间,格式HH:mm")
    private String reservationEndTime;
    @TableField("reservation_ahead_minute")
    @ApiModelProperty(value = "最早可提前预约分钟数")
    private Integer reservationAheadMinute;
    /**
     * 高速路服务区编号
     */
ruoyi-service/ruoyi-chargingPile/src/main/java/com/ruoyi/chargingPile/controller/SiteController.java
@@ -564,7 +564,7 @@
    @ApiOperation(value = "获取站点预约设置", tags = {"管理后台-站点管理"})
    public AjaxResult<Site> getReservationSetting(@PathVariable Integer id){
        Site site = siteService.lambdaQuery()
                .select(Site::getId, Site::getName, Site::getSupportOrder, Site::getReservationStartTime, Site::getReservationEndTime)
                .select(Site::getId, Site::getName, Site::getSupportOrder, Site::getReservationStartTime, Site::getReservationEndTime, Site::getReservationAheadMinute)
                .eq(Site::getId, id)
                .eq(Site::getDelFlag, 0)
                .one();
@@ -585,6 +585,7 @@
                .set(Site::getSupportOrder, site.getSupportOrder())
                .set(Site::getReservationStartTime, site.getReservationStartTime())
                .set(Site::getReservationEndTime, site.getReservationEndTime())
                .set(Site::getReservationAheadMinute, site.getReservationAheadMinute())
                .update();
        return success ? AjaxResult.success() : AjaxResult.error("保存预约设置失败");
    }
ruoyi-service/ruoyi-chargingPile/src/main/resources/mapper/chargingPile/TChargingPileMapper.xml
@@ -42,7 +42,11 @@
        c.name as partnerName,
        a.manufacturer,
        a.equipment_type as equipmentType,
        a.rated_power as ratedPower
        a.rated_power as ratedPower,
        b.supportOrder as supportOrder,
        b.reservation_start_time as reservationStartTime,
        b.reservation_end_time as reservationEndTime,
        b.reservation_ahead_minute as reservationAheadMinute
        from t_charging_pile a
        left join t_site b on (a.site_id = b.id)
        left join t_partner c on (a.partner_id = c.id)
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TChargingOrderServiceImpl.java
@@ -90,7 +90,12 @@
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.util.StringUtils;
import javax.annotation.Resource;
@@ -125,6 +130,7 @@
    private static final int WHITELIST_BENEFIT_ALL_FREE = 1;
    private static final int WHITELIST_BENEFIT_ELECTRIC_FREE = 2;
    private static final int WHITELIST_BENEFIT_SERVICE_FREE = 3;
    private static final String SHARE_AFTER_COMMIT_RESOURCE_PREFIX = "chargingOrderShareAfterCommit:";
    @Resource
    private ChargingGunClient chargingGunClient;
@@ -162,6 +168,8 @@
    private TChargingOrderShareRecordMapper chargingOrderShareRecordMapper;
    @Resource
    private ChargingOrderSettlementExecutor chargingOrderSettlementExecutor;
    @Resource
    private PlatformTransactionManager transactionManager;
    
    @Resource
    private AppCouponClient appCouponClient;
@@ -1529,6 +1537,10 @@
        }
        if (reservationTime.isBefore(LocalDateTime.now().minusMinutes(1))) {
            return AjaxResult.error("预约时间不能早于当前时间");
        }
        if (site.getReservationAheadMinute() != null && site.getReservationAheadMinute() >= 0
                && reservationTime.isAfter(LocalDateTime.now().plusMinutes(site.getReservationAheadMinute()))) {
            return AjaxResult.error("预约时间不能超过最早可提前预约时限");
        }
        if (!StringUtils.hasLength(site.getReservationStartTime())
                || !StringUtils.hasLength(site.getReservationEndTime())) {
@@ -6817,6 +6829,58 @@
    }
    private void triggerChargingOrderShare(TChargingOrder chargingOrder, Site site) {
        if (!isNeedChargingOrderShare(chargingOrder)) {
            return;
        }
        if (TransactionSynchronizationManager.isActualTransactionActive()
                && TransactionSynchronizationManager.isSynchronizationActive()) {
            Long chargingOrderId = chargingOrder.getId();
            Integer siteId = chargingOrder.getSiteId();
            String resourceKey = SHARE_AFTER_COMMIT_RESOURCE_PREFIX + chargingOrderId;
            if (TransactionSynchronizationManager.hasResource(resourceKey)) {
                return;
            }
            TransactionSynchronizationManager.bindResource(resourceKey, Boolean.TRUE);
            try {
                TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
                    @Override
                    public void afterCommit() {
                        executeChargingOrderShareAfterCommit(chargingOrderId, siteId);
                    }
                    @Override
                    public void afterCompletion(int status) {
                        if (TransactionSynchronizationManager.hasResource(resourceKey)) {
                            TransactionSynchronizationManager.unbindResource(resourceKey);
                        }
                    }
                });
            } catch (RuntimeException e) {
                TransactionSynchronizationManager.unbindResourceIfPossible(resourceKey);
                throw e;
            }
            log.info("充电订单分账已登记为事务提交后执行,订单号:{}", chargingOrder.getCode());
            return;
        }
        executeChargingOrderShare(chargingOrder, site);
    }
    private void executeChargingOrderShareAfterCommit(Long chargingOrderId, Integer siteId) {
        try {
            TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager);
            transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
            transactionTemplate.execute(status -> {
                TChargingOrder latestOrder = chargingOrderId == null ? null : this.getById(chargingOrderId);
                Site latestSite = loadChargingOrderSite(siteId);
                executeChargingOrderShare(latestOrder, latestSite);
                return null;
            });
        } catch (Exception e) {
            log.error("事务提交后执行充电订单分账失败,订单ID:{}", chargingOrderId, e);
        }
    }
    private void executeChargingOrderShare(TChargingOrder chargingOrder, Site site) {
        try {
            if (!isNeedChargingOrderShare(chargingOrder)) {
                return;
@@ -6848,13 +6912,25 @@
            if (chargingOrder == null || chargingOrder.getSiteId() == null) {
                return;
            }
            R<List<Site>> siteResult = siteClient.getSiteByIds(Arrays.asList(chargingOrder.getSiteId()));
            List<Site> sites = siteResult == null ? null : siteResult.getData();
            Site site = sites == null || sites.isEmpty() ? null : sites.get(0);
            if (TransactionSynchronizationManager.isActualTransactionActive()
                    && TransactionSynchronizationManager.isSynchronizationActive()) {
                triggerChargingOrderShare(chargingOrder, null);
                return;
            }
            Site site = loadChargingOrderSite(chargingOrder.getSiteId());
            triggerChargingOrderShare(chargingOrder, site);
        } catch (Exception e) {
            log.error("充电订单分账站点加载失败,订单号:{}", chargingOrder == null ? null : chargingOrder.getCode(), e);
        }
    }
    private Site loadChargingOrderSite(Integer siteId) {
        if (siteId == null) {
            return null;
        }
        R<List<Site>> siteResult = siteClient.getSiteByIds(Collections.singletonList(siteId));
        List<Site> sites = siteResult == null ? null : siteResult.getData();
        return sites == null || sites.isEmpty() ? null : sites.get(0);
    }
    private boolean isNeedChargingOrderShare(TChargingOrder chargingOrder) {
@@ -6993,7 +7069,30 @@
        Map<String, Object> row = rows.get(0);
        BigDecimal principalAmount = getSummaryDecimal(row, "balanceIncome").max(BigDecimal.ZERO).setScale(2, RoundingMode.HALF_UP);
        BigDecimal giftAmount = getSummaryDecimal(row, "balanceGiftIncome").max(BigDecimal.ZERO).setScale(2, RoundingMode.HALF_UP);
        return new BalanceShareAmount(principalAmount, giftAmount);
        BigDecimal[] normalized = normalizeBalanceShareAmounts(fallbackBalancePay, principalAmount, giftAmount);
        return new BalanceShareAmount(normalized[0], normalized[1]);
    }
    /**
     * 余额退款流水可能在跨服务事务提交前暂时不可见,按订单净余额约束本金和赠送金额总和。
     */
    static BigDecimal[] normalizeBalanceShareAmounts(BigDecimal netBalanceAmount,
                                                     BigDecimal principalAmount,
                                                     BigDecimal giftAmount) {
        BigDecimal netAmount = netBalanceAmount == null ? BigDecimal.ZERO : netBalanceAmount.max(BigDecimal.ZERO).setScale(2, RoundingMode.HALF_UP);
        BigDecimal principal = principalAmount == null ? BigDecimal.ZERO : principalAmount.max(BigDecimal.ZERO).setScale(2, RoundingMode.HALF_UP);
        BigDecimal gift = giftAmount == null ? BigDecimal.ZERO : giftAmount.max(BigDecimal.ZERO).setScale(2, RoundingMode.HALF_UP);
        BigDecimal recordedTotal = principal.add(gift).setScale(2, RoundingMode.HALF_UP);
        if (netAmount.compareTo(BigDecimal.ZERO) <= 0) {
            return new BigDecimal[]{BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP), BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP)};
        }
        if (recordedTotal.compareTo(BigDecimal.ZERO) <= 0) {
            return new BigDecimal[]{netAmount, BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP)};
        }
        BigDecimal normalizedPrincipal = netAmount.multiply(principal)
                .divide(recordedTotal, 2, RoundingMode.HALF_UP);
        BigDecimal normalizedGift = netAmount.subtract(normalizedPrincipal).setScale(2, RoundingMode.HALF_UP);
        return new BigDecimal[]{normalizedPrincipal, normalizedGift};
    }
    private BigDecimal getChargingOrderShareElectricityAmount(TChargingOrder chargingOrder) {
ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/ChargingOrderShareConfigurationTest.java
@@ -6,6 +6,7 @@
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ChargingOrderShareConfigurationTest {
@@ -22,4 +23,13 @@
        assertFalse(TChargingOrderServiceImpl.shouldProcessIcbcOnlineShare("ICBC", BigDecimal.ZERO, partner));
        assertFalse(TChargingOrderServiceImpl.shouldProcessIcbcOnlineShare("BALANCE", new BigDecimal("1.00"), partner));
    }
    @Test
    void scalesTemporarilyStaleBalanceBreakdownToFinalOrderNetAmount() {
        BigDecimal[] normalized = TChargingOrderServiceImpl.normalizeBalanceShareAmounts(
                new BigDecimal("9.00"), new BigDecimal("7.00"), new BigDecimal("3.00"));
        assertEquals(new BigDecimal("6.30"), normalized[0]);
        assertEquals(new BigDecimal("2.70"), normalized[1]);
    }
}
ruoyi-service/ruoyi-payment/src/main/java/com/ruoyi/payment/controller/IcbcPayController.java
@@ -28,6 +28,7 @@
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
@@ -229,7 +230,17 @@
                rawBody,
                summarizeHeaders(request));
        try {
            Map<String, String> callback = icbcPayService.parseNotify(requestParams);
            Map<String, String> callback;
            try {
                callback = icbcPayService.parseNotify(requestParams);
            } catch (Exception verifyException) {
                if (!isSignatureVerificationFailure(verifyException)) {
                    throw verifyException;
                }
                callback = recoverCallbackByPaymentQuery(requestParams);
                log.warn("工行支付回调验签失败,已通过主动查询确认支付成功并继续处理,订单号:{},工行订单号:{}",
                        callback.get("out_trade_no"), callback.get("transaction_id"));
            }
            log.info("工行支付回调解析结果:{}", JSON.toJSONString(callback));
            dispatchChargingOrderCallback(callback);
            success = true;
@@ -240,6 +251,59 @@
        }
    }
    Map<String, String> recoverCallbackByPaymentQuery(Map<String, String[]> requestParams) {
        String appId = firstRequestParam(requestParams, "app_id");
        if (appId == null || !appId.equals(icbcPayProperties.getAppId())) {
            throw new IllegalArgumentException("工行回调APPID与当前配置不一致,禁止查询补偿");
        }
        String bizContent = firstRequestParam(requestParams, "biz_content");
        JSONObject callbackBiz = JSON.parseObject(bizContent);
        String outTradeNo = callbackBiz == null ? null : callbackBiz.getString("out_trade_no");
        if (outTradeNo == null || outTradeNo.trim().isEmpty() || outTradeNo.length() > 64) {
            throw new IllegalArgumentException("工行回调缺少有效订单号,无法查询补偿");
        }
        IcbcPaymentResp payment = icbcPayService.query(outTradeNo);
        JSONObject query = payment == null || payment.getRawResponse() == null
                ? null : JSON.parseObject(payment.getRawResponse());
        if (query == null || query.getIntValue("returnCode") != 0 || !"0".equals(query.getString("pay_status"))) {
            throw new IllegalArgumentException("工行主动查询未确认支付成功");
        }
        String queriedOutTradeNo = query.getString("out_trade_no");
        if (!outTradeNo.equals(queriedOutTradeNo)) {
            throw new IllegalArgumentException("工行主动查询返回订单号不一致");
        }
        String orderId = query.getString("order_id");
        String attach = query.getString("attach");
        if (orderId == null || orderId.trim().isEmpty() || attach == null || attach.trim().isEmpty()) {
            throw new IllegalArgumentException("工行主动查询缺少订单流水或支付类型");
        }
        Map<String, String> callback = new HashMap<>();
        callback.put("out_trade_no", queriedOutTradeNo);
        callback.put("transaction_id", orderId);
        callback.put("attach", attach);
        callback.put("return_code", "0");
        callback.put("return_msg", "主动查询确认支付成功");
        return callback;
    }
    private boolean isSignatureVerificationFailure(Exception exception) {
        Throwable current = exception;
        while (current != null) {
            if (current.getMessage() != null && current.getMessage().contains("工行回调验签失败")) {
                return true;
            }
            current = current.getCause();
        }
        return false;
    }
    private String firstRequestParam(Map<String, String[]> requestParams, String name) {
        String[] values = requestParams == null ? null : requestParams.get(name);
        return values == null || values.length == 0 ? null : values[0];
    }
    private void dispatchChargingOrderCallback(Map<String, String> callback) {
        String outTradeNo = callback.get("out_trade_no");
        String transactionId = callback.get("transaction_id");
ruoyi-service/ruoyi-payment/src/test/java/com/ruoyi/payment/controller/IcbcPayControllerTest.java
@@ -1,10 +1,23 @@
package com.ruoyi.payment.controller;
import com.ruoyi.order.api.feignClient.ChargingOrderClient;
import com.ruoyi.payment.api.vo.IcbcPaymentResp;
import com.ruoyi.payment.icbc.config.IcbcPayProperties;
import com.ruoyi.payment.icbc.service.IcbcPayService;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.LinkedHashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class IcbcPayControllerTest {
@@ -25,4 +38,69 @@
        assertEquals("success", response.getContentAsString().trim());
    }
    @Test
    void recoversSignatureFailureWhenBankQueryConfirmsPayment() throws Exception {
        IcbcPayService payService = mock(IcbcPayService.class);
        ChargingOrderClient chargingOrderClient = mock(ChargingOrderClient.class);
        IcbcPayController controller = controller(payService, chargingOrderClient);
        MockHttpServletRequest request = callbackRequest();
        MockHttpServletResponse response = new MockHttpServletResponse();
        when(payService.parseNotify(anyMap())).thenThrow(new IllegalArgumentException("工行回调验签失败"));
        when(payService.query("CD78120260715165644735")).thenReturn(paidQuery());
        Map<String, String> recovered = controller.recoverCallbackByPaymentQuery(
                new LinkedHashMap<>(request.getParameterMap()));
        assertEquals("231011990056000522607150004167", recovered.get("transaction_id"));
        controller.callBack(request, response);
        assertEquals("success", response.getContentAsString().trim());
        verify(chargingOrderClient).chargingOrderWXCallback(
                "CD78120260715165644735", "231011990056000522607150004167", "1");
    }
    @Test
    void keepsFailureResponseWhenBankQueryDoesNotConfirmPayment() throws Exception {
        IcbcPayService payService = mock(IcbcPayService.class);
        ChargingOrderClient chargingOrderClient = mock(ChargingOrderClient.class);
        IcbcPayController controller = controller(payService, chargingOrderClient);
        MockHttpServletRequest request = callbackRequest();
        MockHttpServletResponse response = new MockHttpServletResponse();
        when(payService.parseNotify(anyMap())).thenThrow(new IllegalArgumentException("工行回调验签失败"));
        IcbcPaymentResp unpaid = new IcbcPaymentResp();
        unpaid.setRawResponse("{\"returnCode\":0,\"pay_status\":\"1\",\"out_trade_no\":\"CD78120260715165644735\"}");
        when(payService.query("CD78120260715165644735")).thenReturn(unpaid);
        controller.callBack(request, response);
        assertEquals("fail", response.getContentAsString().trim());
        verify(chargingOrderClient, never()).chargingOrderWXCallback(
                "CD78120260715165644735", "231011990056000522607150004167", "1");
    }
    private IcbcPayController controller(IcbcPayService payService, ChargingOrderClient chargingOrderClient) {
        IcbcPayProperties properties = new IcbcPayProperties();
        properties.setAppId("11000000000000096526");
        IcbcPayController controller = new IcbcPayController();
        ReflectionTestUtils.setField(controller, "icbcPayService", payService);
        ReflectionTestUtils.setField(controller, "icbcPayProperties", properties);
        ReflectionTestUtils.setField(controller, "chargingOrderClient", chargingOrderClient);
        return controller;
    }
    private MockHttpServletRequest callbackRequest() {
        MockHttpServletRequest request = new MockHttpServletRequest("POST", "/icbc/callBack");
        request.addParameter("app_id", "11000000000000096526");
        request.addParameter("biz_content", "{\"out_trade_no\":\"CD78120260715165644735\"}");
        request.addParameter("sign", "INVALID");
        return request;
    }
    private IcbcPaymentResp paidQuery() {
        IcbcPaymentResp payment = new IcbcPaymentResp();
        payment.setStatus("0");
        payment.setRawResponse("{\"returnCode\":0,\"pay_status\":\"0\",\"out_trade_no\":\"CD78120260715165644735\","
                + "\"order_id\":\"231011990056000522607150004167\",\"attach\":\"1\"}");
        return payment;
    }
}