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; } }