From 6fc77feef6337bc478423efad5de91fa520b4990 Mon Sep 17 00:00:00 2001
From: longz <longzhaoz@qq.com>
Date: 星期一, 13 七月 2026 21:01:40 +0800
Subject: [PATCH] 支付分账测试相关
---
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TChargingOrderServiceImpl.java | 443 +++++++++++++++
ruoyi-service/ruoyi-payment/src/main/java/com/ruoyi/payment/icbc/service/impl/IcbcPayServiceImpl.java | 143 +++-
docs/superpowers/plans/2026-07-11-icbc-admin-fund-operations.md | 66 ++
ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/vo/ChargingOrderListVO.java | 4
sql/20260711_icbc_payment_flow_record.sql | 22
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/IcbcAdminFundOperationSupport.java | 148 +++++
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/TChargingOrderService.java | 17
ruoyi-api/ruoyi-api-payment/src/main/java/com/ruoyi/payment/api/factory/IcbcPaymentFallbackFactory.java | 7
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/dto/ChargingSimulationReq.java | 12
ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/IcbcAdminFundOperationSupportTest.java | 159 +++++
ruoyi-service/ruoyi-order/src/main/resources/mapper/order/TChargingOrderMapper.xml | 2
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/dto/IcbcAdminFundOperationReq.java | 12
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/controller/TChargingOrderController.java | 47 +
ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/ChargingSimulationSupportTest.java | 49 +
ruoyi-service/ruoyi-payment/src/main/java/com/ruoyi/payment/domain/IcbcPaymentFlowRecord.java | 31 +
ruoyi-service/ruoyi-payment/src/test/java/com/ruoyi/payment/icbc/service/impl/IcbcNotifyParserTest.java | 27
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/ChargingSimulationSupport.java | 127 ++++
docs/superpowers/specs/2026-07-11-icbc-admin-fund-operations-design.md | 57 ++
ruoyi-api/ruoyi-api-payment/src/main/java/com/ruoyi/payment/api/feignClient/IcbcPaymentClient.java | 8
ruoyi-service/ruoyi-payment/src/main/java/com/ruoyi/payment/mapper/IcbcPaymentFlowRecordMapper.java | 7
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/ChargingOrderSettlementExecutor.java | 22
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/vo/ChargingSimulationVO.java | 25
ruoyi-service/ruoyi-payment/src/main/java/com/ruoyi/payment/icbc/service/impl/IcbcNotifyParser.java | 54 +
ruoyi-api/ruoyi-api-payment/src/main/java/com/ruoyi/payment/api/vo/IcbcSubOrderQueryResp.java | 1
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/vo/IcbcAdminSubOrderVO.java | 45 +
ruoyi-service/ruoyi-payment/src/main/java/com/ruoyi/payment/icbc/service/IcbcPaymentFlowRecorder.java | 92 +++
26 files changed, 1,558 insertions(+), 69 deletions(-)
diff --git a/docs/superpowers/plans/2026-07-11-icbc-admin-fund-operations.md b/docs/superpowers/plans/2026-07-11-icbc-admin-fund-operations.md
new file mode 100644
index 0000000..38ccfd4
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-11-icbc-admin-fund-operations.md
@@ -0,0 +1,66 @@
+# ICBC Admin Fund Operations Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add management-console operations for querying and manually operating ICBC funds for real charging orders.
+
+**Architecture:** The order service validates local charging orders and share records, then calls the payment service through Feign. The management frontend exposes one fund-operation modal from the charging-order list.
+
+**Tech Stack:** Java 8, Spring Boot, MyBatis-Plus, OpenFeign, JUnit 5, React 18, Ant Design Pro.
+
+## Global Constraints
+
+- Only ICBC paid charging orders are eligible.
+- Refund operations affect real bank funds but do not update local business status.
+- A specific share record must be selected for sub-order refunds.
+- Existing unrelated workspace changes must remain untouched.
+
+---
+
+### Task 1: Request Resolution and Validation
+
+**Files:**
+- Create: `ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/IcbcAdminFundOperationSupport.java`
+- Test: `ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/IcbcAdminFundOperationSupportTest.java`
+
+- [ ] Write failing tests for ICBC order eligibility, positive amounts and DTO assembly.
+- [ ] Run the focused test and confirm failure because the support class is missing.
+- [ ] Implement the minimal pure support class.
+- [ ] Run the focused test and confirm success.
+
+### Task 2: Backend Management APIs
+
+**Files:**
+- Modify: `ruoyi-api/ruoyi-api-payment/src/main/java/com/ruoyi/payment/api/feignClient/IcbcPaymentClient.java`
+- Modify: `ruoyi-api/ruoyi-api-payment/src/main/java/com/ruoyi/payment/api/factory/IcbcPaymentFallbackFactory.java`
+- Create: `ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/dto/IcbcAdminFundOperationReq.java`
+- Create: `ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/vo/IcbcAdminSubOrderVO.java`
+- Modify: `ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/TChargingOrderService.java`
+- Modify: `ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TChargingOrderServiceImpl.java`
+- Modify: `ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/controller/TChargingOrderController.java`
+
+- [ ] Add the missing Feign sub-order-refund operation and fallback.
+- [ ] Add request/response models for order code, amount and selected share record.
+- [ ] Add service methods that query payment, refund the main order, query all share records and refund one selected sub-order.
+- [ ] Add permission-protected controller endpoints.
+- [ ] Compile the order and payment dependency graph.
+
+### Task 3: Management Fund Operation Modal
+
+**Files:**
+- Modify: `chargingPile_management/src/pages/order/pileOrder/service.js`
+- Modify: `chargingPile_management/src/pages/order/pileOrder/index.jsx`
+- Modify: `chargingPile_management/src/pages/order/pileOrder/style.less`
+
+- [ ] Add four API wrappers.
+- [ ] Add the ICBC-only operation button.
+- [ ] Add payment query, main refund and sub-order table sections.
+- [ ] Add real-fund warnings, amount validation, loading states and confirmation dialogs.
+- [ ] Run frontend syntax/type/build checks.
+
+### Task 4: Final Verification
+
+- [ ] Run focused JUnit tests.
+- [ ] Run Maven compile for order and payment modules.
+- [ ] Run frontend static checks.
+- [ ] Run scoped `git diff --check` without touching unrelated changes.
diff --git a/docs/superpowers/specs/2026-07-11-icbc-admin-fund-operations-design.md b/docs/superpowers/specs/2026-07-11-icbc-admin-fund-operations-design.md
new file mode 100644
index 0000000..6e13df2
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-11-icbc-admin-fund-operations-design.md
@@ -0,0 +1,57 @@
+# 工行管理后台资金操作设计
+
+## 目标
+
+在“订单管理/充电桩订单列表”为正常扫码支付生成的 ICBC 充电订单提供支付查询、主订单退款、分账查询和分账退款操作。
+
+## 约束
+
+- 仅 `source_name=ICBC` 且已支付的充电订单允许操作。
+- 管理端只提交业务订单号;退款类操作额外提交金额。
+- 分账查询返回订单下全部工行分账记录,分账退款必须选择具体记录。
+- 操作只调用工行接口并返回结果,不修改充电订单、退款记录或分账记录的业务状态。
+- 工行支付审计表继续记录查询、退款、分账查询和分账退款请求及响应。
+- 管理端必须显示“仅操作银行资金,不同步本地订单状态”的风险提示。
+
+## 后端架构
+
+订单服务负责管理端编排:校验订单、读取 `t_charging_order` 和 `t_charging_order_share_record`,自动组装工行参数,再通过 `IcbcPaymentClient` 调用支付服务。
+
+接口:
+
+- `POST /order/t-charging-order/icbc/fund/payment-query`
+- `POST /order/t-charging-order/icbc/fund/refund`
+- `POST /order/t-charging-order/icbc/fund/sub-orders`
+- `POST /order/t-charging-order/icbc/fund/sub-order-refund`
+
+主订单退款使用订单 `code`、`recharge_serial_number` 和管理端金额。若本地缺少工行订单号,先调用支付查询补充本次请求上下文,但不回写订单。
+
+分账查询按订单读取全部 `pay_channel=ICBC` 且未删除的分账记录,并逐条调用工行子订单查询。单条查询失败不影响其他记录返回。
+
+分账退款以选中的分账记录为准,使用其原订单号、子订单号、商户号、协议号和原交易日期组装请求。
+
+## 管理界面
+
+充电桩订单列表中,符合条件的订单显示“资金操作”。弹窗顶部展示订单号、工行订单号和风险提示,并提供:
+
+- 支付查询按钮及原始结果。
+- 主订单退款金额输入框和二次确认。
+- 分账子订单表格、逐条查询结果和逐条退款入口。
+
+提交期间按钮禁用,退款必须再次确认真实资金影响。
+
+## 错误处理
+
+- 非 ICBC、未支付、订单不存在直接拒绝。
+- 金额必须大于零。
+- 分账记录必须属于当前订单且为 ICBC 渠道。
+- 工行调用失败时返回明确错误及原始响应,不修改本地业务状态。
+- 后端使用订单级本地锁避免同一实例上的并发重复操作;跨实例重复操作风险由工行结果和审计记录追踪。
+
+## 测试
+
+- 参数解析与订单校验单元测试。
+- 工行订单号缺失时查询补全测试。
+- 多分账记录全部返回、单条失败隔离测试。
+- 非法分账记录、非 ICBC、未支付和非法金额测试。
+- 后端模块编译及管理端构建/类型检查。
diff --git a/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/vo/ChargingOrderListVO.java b/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/vo/ChargingOrderListVO.java
index c4d17c5..e46ece6 100644
--- a/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/vo/ChargingOrderListVO.java
+++ b/ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/vo/ChargingOrderListVO.java
@@ -38,6 +38,10 @@
private Integer chargingGunId;
@ApiModelProperty(value = "订单来源(1=自己平台,2=三方平台)")
private Integer orderSource;
+ @ApiModelProperty(value = "支付渠道来源")
+ private String sourceName;
+ @ApiModelProperty(value = "第三方支付流水号或工行订单号")
+ private String rechargeSerialNumber;
@ApiModelProperty(value = "是否白名单用户")
private Integer isWhitelist;
@ApiModelProperty(value = "白名单用户退款金额")
diff --git a/ruoyi-api/ruoyi-api-payment/src/main/java/com/ruoyi/payment/api/factory/IcbcPaymentFallbackFactory.java b/ruoyi-api/ruoyi-api-payment/src/main/java/com/ruoyi/payment/api/factory/IcbcPaymentFallbackFactory.java
index d4bb251..e8d70f6 100644
--- a/ruoyi-api/ruoyi-api-payment/src/main/java/com/ruoyi/payment/api/factory/IcbcPaymentFallbackFactory.java
+++ b/ruoyi-api/ruoyi-api-payment/src/main/java/com/ruoyi/payment/api/factory/IcbcPaymentFallbackFactory.java
@@ -8,6 +8,8 @@
import com.ruoyi.payment.api.vo.IcbcRefundResp;
import com.ruoyi.payment.api.vo.IcbcSubOrderQueryReq;
import com.ruoyi.payment.api.vo.IcbcSubOrderQueryResp;
+import com.ruoyi.payment.api.vo.IcbcSubOrderRefundReq;
+import com.ruoyi.payment.api.vo.IcbcSubOrderRefundResp;
import com.ruoyi.payment.api.vo.IcbcSubOrderSplitReq;
import com.ruoyi.payment.api.vo.IcbcSubOrderSplitResp;
import org.slf4j.Logger;
@@ -52,6 +54,11 @@
}
@Override
+ public R<IcbcSubOrderRefundResp> refundSubOrder(IcbcSubOrderRefundReq req) {
+ return R.fail("工行小订单拆分退款失败:" + throwable.getMessage());
+ }
+
+ @Override
public R<IcbcSubOrderQueryResp> querySubOrder(IcbcSubOrderQueryReq req) {
return R.fail("查询工行子订单失败:" + throwable.getMessage());
}
diff --git a/ruoyi-api/ruoyi-api-payment/src/main/java/com/ruoyi/payment/api/feignClient/IcbcPaymentClient.java b/ruoyi-api/ruoyi-api-payment/src/main/java/com/ruoyi/payment/api/feignClient/IcbcPaymentClient.java
index 4763db5..4cd1d87 100644
--- a/ruoyi-api/ruoyi-api-payment/src/main/java/com/ruoyi/payment/api/feignClient/IcbcPaymentClient.java
+++ b/ruoyi-api/ruoyi-api-payment/src/main/java/com/ruoyi/payment/api/feignClient/IcbcPaymentClient.java
@@ -9,6 +9,8 @@
import com.ruoyi.payment.api.vo.IcbcRefundResp;
import com.ruoyi.payment.api.vo.IcbcSubOrderQueryReq;
import com.ruoyi.payment.api.vo.IcbcSubOrderQueryResp;
+import com.ruoyi.payment.api.vo.IcbcSubOrderRefundReq;
+import com.ruoyi.payment.api.vo.IcbcSubOrderRefundResp;
import com.ruoyi.payment.api.vo.IcbcSubOrderSplitReq;
import com.ruoyi.payment.api.vo.IcbcSubOrderSplitResp;
import org.springframework.cloud.openfeign.FeignClient;
@@ -53,6 +55,12 @@
R<IcbcSubOrderSplitResp> splitSubOrder(@RequestBody IcbcSubOrderSplitReq req);
/**
+ * 工行小订单拆分退款。
+ */
+ @PostMapping("/icbc/sub-order/refund")
+ R<IcbcSubOrderRefundResp> refundSubOrder(@RequestBody IcbcSubOrderRefundReq req);
+
+ /**
* 查询工行子订单。
*/
@PostMapping("/icbc/sub-order/query")
diff --git a/ruoyi-api/ruoyi-api-payment/src/main/java/com/ruoyi/payment/api/vo/IcbcSubOrderQueryResp.java b/ruoyi-api/ruoyi-api-payment/src/main/java/com/ruoyi/payment/api/vo/IcbcSubOrderQueryResp.java
index 8603a3e..a2c8ebc 100644
--- a/ruoyi-api/ruoyi-api-payment/src/main/java/com/ruoyi/payment/api/vo/IcbcSubOrderQueryResp.java
+++ b/ruoyi-api/ruoyi-api-payment/src/main/java/com/ruoyi/payment/api/vo/IcbcSubOrderQueryResp.java
@@ -15,6 +15,7 @@
private String subOrderNo;
private String subMerOrderId;
private String orderAmnt;
+ private String orderRefundAmnt;
private String payDate;
private String status;
private String rawResponse;
diff --git a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/controller/TChargingOrderController.java b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/controller/TChargingOrderController.java
index e072c76..e9cfeab 100644
--- a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/controller/TChargingOrderController.java
+++ b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/controller/TChargingOrderController.java
@@ -53,6 +53,8 @@
import com.ruoyi.order.util.PreviousSixMonths;
import com.ruoyi.order.util.mongodb.service.UploadRealTimeMonitoringDataService;
import com.ruoyi.order.vo.EndOfChargePageInfo;
+import com.ruoyi.order.vo.ChargingSimulationVO;
+import com.ruoyi.order.vo.IcbcAdminSubOrderVO;
import com.ruoyi.order.vo.StatisticsOfBatteryVO;
import com.ruoyi.order.vo.UpdateOrderPaymentAmountDto;
import com.ruoyi.order.vo.WatchChargingOrderVo;
@@ -60,6 +62,9 @@
import com.ruoyi.other.api.feignClient.UserSiteClient;
import com.ruoyi.payment.api.feignClient.AliPaymentClient;
import com.ruoyi.payment.api.feignClient.WxPaymentClient;
+import com.ruoyi.payment.api.vo.IcbcPaymentResp;
+import com.ruoyi.payment.api.vo.IcbcRefundResp;
+import com.ruoyi.payment.api.vo.IcbcSubOrderRefundResp;
import com.ruoyi.system.api.domain.SysUser;
import com.ruoyi.system.api.feignClient.SysUserClient;
import com.ruoyi.system.api.feignClient.SysUserRoleClient;
@@ -762,6 +767,48 @@
public AjaxResult paymentChargingOrder(@RequestBody AddChargingOrder addChargingOrder) {
return chargingOrderService.paymentChargingOrder(addChargingOrder);
}
+
+ @RequiresPermissions(value = {"/chargingPileOrder", "/paymentOrder/refund"}, logical = Logical.AND)
+ @PostMapping("/icbc/fund/payment-query")
+ @ApiOperation(value = "管理后台查询工行支付订单", tags = {"后台-充电桩订单"})
+ public AjaxResult<IcbcPaymentResp> queryIcbcAdminPayment(@RequestBody IcbcAdminFundOperationReq request) {
+ return AjaxResult.success(chargingOrderService.queryIcbcAdminPayment(request == null ? null : request.getOrderCode()));
+ }
+
+ @RequiresPermissions(value = {"/chargingPileOrder", "/paymentOrder/refund"}, logical = Logical.AND)
+ @PostMapping("/icbc/fund/refund")
+ @ApiOperation(value = "管理后台工行主订单退款", tags = {"后台-充电桩订单"})
+ public AjaxResult<IcbcRefundResp> refundIcbcAdminPayment(@RequestBody IcbcAdminFundOperationReq request) {
+ return AjaxResult.success(chargingOrderService.refundIcbcAdminPayment(request));
+ }
+
+ @RequiresPermissions(value = {"/chargingPileOrder", "/paymentOrder/refund"}, logical = Logical.AND)
+ @PostMapping("/icbc/fund/sub-orders")
+ @ApiOperation(value = "管理后台查询工行分账子订单", tags = {"后台-充电桩订单"})
+ public AjaxResult<List<IcbcAdminSubOrderVO>> queryIcbcAdminSubOrders(@RequestBody IcbcAdminFundOperationReq request) {
+ return AjaxResult.success(chargingOrderService.queryIcbcAdminSubOrders(request == null ? null : request.getOrderCode()));
+ }
+
+ @RequiresPermissions(value = {"/chargingPileOrder", "/paymentOrder/refund"}, logical = Logical.AND)
+ @PostMapping("/icbc/fund/sub-order-refund")
+ @ApiOperation(value = "管理后台工行分账子订单退款", tags = {"后台-充电桩订单"})
+ public AjaxResult<IcbcSubOrderRefundResp> refundIcbcAdminSubOrder(@RequestBody IcbcAdminFundOperationReq request) {
+ return AjaxResult.success(chargingOrderService.refundIcbcAdminSubOrder(request));
+ }
+
+ @ResponseBody
+ @GetMapping("/simulation/current")
+ @ApiOperation(value = "查询当前枪可模拟结算的工行订单", tags = {"小程序-模拟充电"})
+ public AjaxResult<ChargingSimulationVO> getCurrentSimulationOrder(@RequestParam("gunId") Integer gunId) {
+ return AjaxResult.success(chargingOrderService.getCurrentSimulationOrder(gunId));
+ }
+
+ @ResponseBody
+ @PostMapping("/simulation/complete")
+ @ApiOperation(value = "模拟完成充电并执行真实结算", tags = {"小程序-模拟充电"})
+ public AjaxResult<ChargingSimulationVO> completeSimulation(@RequestBody ChargingSimulationReq request) {
+ return AjaxResult.success(chargingOrderService.completeSimulation(request));
+ }
/**
* 充电充值支付回调
diff --git a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/dto/ChargingSimulationReq.java b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/dto/ChargingSimulationReq.java
new file mode 100644
index 0000000..8a978d5
--- /dev/null
+++ b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/dto/ChargingSimulationReq.java
@@ -0,0 +1,12 @@
+package com.ruoyi.order.dto;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+@Data
+public class ChargingSimulationReq {
+ private Integer gunId;
+ private BigDecimal electricityFee;
+ private BigDecimal serviceFee;
+}
diff --git a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/dto/IcbcAdminFundOperationReq.java b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/dto/IcbcAdminFundOperationReq.java
new file mode 100644
index 0000000..83f571d
--- /dev/null
+++ b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/dto/IcbcAdminFundOperationReq.java
@@ -0,0 +1,12 @@
+package com.ruoyi.order.dto;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+@Data
+public class IcbcAdminFundOperationReq {
+ private String orderCode;
+ private Long shareRecordId;
+ private BigDecimal amount;
+}
diff --git a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/ChargingOrderSettlementExecutor.java b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/ChargingOrderSettlementExecutor.java
new file mode 100644
index 0000000..e062ac1
--- /dev/null
+++ b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/ChargingOrderSettlementExecutor.java
@@ -0,0 +1,22 @@
+package com.ruoyi.order.service;
+
+import com.ruoyi.common.core.domain.R;
+import com.ruoyi.order.api.vo.TransactionRecordMessageVO;
+import org.springframework.context.annotation.Lazy;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+
+/**
+ * 通过独立Spring Bean调用结算服务,确保@Transactional代理生效。
+ */
+@Service
+public class ChargingOrderSettlementExecutor {
+ @Lazy
+ @Resource
+ private TChargingOrderService chargingOrderService;
+
+ public R settle(TransactionRecordMessageVO record) {
+ return chargingOrderService.endChargeBillingCharge(record);
+ }
+}
diff --git a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/TChargingOrderService.java b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/TChargingOrderService.java
index bff805c..e4e07da 100644
--- a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/TChargingOrderService.java
+++ b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/TChargingOrderService.java
@@ -14,6 +14,8 @@
import com.ruoyi.order.api.vo.*;
import com.ruoyi.order.dto.*;
import com.ruoyi.order.vo.ChargingOrderListInfoVO;
+import com.ruoyi.order.vo.ChargingSimulationVO;
+import com.ruoyi.order.vo.IcbcAdminSubOrderVO;
import com.ruoyi.order.vo.EndOfChargePageInfo;
import com.ruoyi.order.vo.StatisticsOfBatteryVO;
import org.springframework.web.bind.annotation.RequestBody;
@@ -24,6 +26,9 @@
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
+import com.ruoyi.payment.api.vo.IcbcPaymentResp;
+import com.ruoyi.payment.api.vo.IcbcRefundResp;
+import com.ruoyi.payment.api.vo.IcbcSubOrderRefundResp;
/**
* <p>
@@ -66,6 +71,18 @@
* @return
*/
AjaxResult paymentChargingOrder(AddChargingOrder addChargingOrder);
+
+ ChargingSimulationVO getCurrentSimulationOrder(Integer gunId);
+
+ ChargingSimulationVO completeSimulation(ChargingSimulationReq request);
+
+ IcbcPaymentResp queryIcbcAdminPayment(String orderCode);
+
+ IcbcRefundResp refundIcbcAdminPayment(IcbcAdminFundOperationReq request);
+
+ List<IcbcAdminSubOrderVO> queryIcbcAdminSubOrders(String orderCode);
+
+ IcbcSubOrderRefundResp refundIcbcAdminSubOrder(IcbcAdminFundOperationReq request);
/**
diff --git a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/ChargingSimulationSupport.java b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/ChargingSimulationSupport.java
new file mode 100644
index 0000000..3b54db4
--- /dev/null
+++ b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/ChargingSimulationSupport.java
@@ -0,0 +1,127 @@
+package com.ruoyi.order.service.impl;
+
+import com.ruoyi.order.api.model.AccountingStrategyDetailOrder;
+import com.ruoyi.order.api.vo.TransactionRecordMessageVO;
+
+import java.lang.reflect.Method;
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+
+final class ChargingSimulationSupport {
+ private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SS");
+
+ private ChargingSimulationSupport() {
+ }
+
+ static PreparedSimulation prepare(String orderCode,
+ List<AccountingStrategyDetailOrder> tariffs,
+ BigDecimal electricityFee,
+ BigDecimal serviceFee,
+ LocalDateTime endTime) {
+ validate(orderCode, tariffs, electricityFee, serviceFee);
+ if (tariffs.size() > 12) {
+ throw new IllegalArgumentException("模拟计费最多支持12个计费时段");
+ }
+ int index = findTariffIndex(tariffs, endTime.toLocalTime());
+ AccountingStrategyDetailOrder tariff = tariffs.get(index);
+ tariff.setElectrovalence(electricityFee);
+ tariff.setServiceCharge(serviceFee);
+ tariff.setCostServiceCharge(serviceFee);
+
+ TransactionRecordMessageVO record = new TransactionRecordMessageVO();
+ record.setTransaction_serial_number(orderCode);
+ record.setStart_time(endTime.minusSeconds(1).format(DATE_TIME_FORMATTER));
+ record.setEnd_time(endTime.format(DATE_TIME_FORMATTER));
+ record.setTrade_date(endTime.format(DATE_TIME_FORMATTER));
+ record.setTotal_electricity(BigDecimal.ONE);
+ record.setLoss_total_electricity(BigDecimal.ONE);
+ record.setPayment_amount(electricityFee.add(serviceFee));
+ record.setMethod(1);
+ record.setStop_reason(1);
+ setPeriodCharge(record, tariff.getType(), index + 1);
+ return new PreparedSimulation(tariff, record);
+ }
+
+ private static void validate(String orderCode,
+ List<AccountingStrategyDetailOrder> tariffs,
+ BigDecimal electricityFee,
+ BigDecimal serviceFee) {
+ if (orderCode == null || orderCode.trim().isEmpty()) {
+ throw new IllegalArgumentException("模拟订单号不能为空");
+ }
+ if (tariffs == null || tariffs.isEmpty()) {
+ throw new IllegalArgumentException("订单计费策略为空");
+ }
+ if (electricityFee == null || electricityFee.compareTo(BigDecimal.ZERO) < 0) {
+ throw new IllegalArgumentException("电费不能小于0");
+ }
+ if (serviceFee == null || serviceFee.compareTo(BigDecimal.ZERO) < 0) {
+ throw new IllegalArgumentException("服务费不能小于0");
+ }
+ if (electricityFee.add(serviceFee).compareTo(BigDecimal.ZERO) <= 0) {
+ throw new IllegalArgumentException("模拟消费金额必须大于0");
+ }
+ }
+
+ private static int findTariffIndex(List<AccountingStrategyDetailOrder> tariffs, LocalTime time) {
+ for (int i = 0; i < tariffs.size(); i++) {
+ AccountingStrategyDetailOrder tariff = tariffs.get(i);
+ LocalTime start = parseTime(tariff.getStartTime(), false);
+ LocalTime end = parseTime(tariff.getEndTime(), true);
+ if (!time.isBefore(start) && !time.isAfter(end)) {
+ return i;
+ }
+ }
+ throw new IllegalArgumentException("当前时间未匹配到订单计费时段");
+ }
+
+ private static LocalTime parseTime(String value, boolean end) {
+ if ("24:00".equals(value)) {
+ return LocalTime.MAX;
+ }
+ LocalTime time = LocalTime.parse(value, DateTimeFormatter.ofPattern("HH:mm"));
+ return end ? time.withSecond(59).withNano(999999999) : time;
+ }
+
+ private static void setPeriodCharge(TransactionRecordMessageVO record, Integer type, int index) {
+ String prefix;
+ if (Integer.valueOf(1).equals(type)) {
+ prefix = "Spike";
+ } else if (Integer.valueOf(2).equals(type)) {
+ prefix = "Peak";
+ } else if (Integer.valueOf(3).equals(type)) {
+ prefix = "Flat";
+ } else if (Integer.valueOf(4).equals(type)) {
+ prefix = "Valley";
+ } else {
+ throw new IllegalArgumentException("不支持的计费时段类型:" + type);
+ }
+ try {
+ Method setter = TransactionRecordMessageVO.class.getMethod("set" + prefix + "_charge" + index, BigDecimal.class);
+ setter.invoke(record, BigDecimal.ONE);
+ } catch (Exception e) {
+ throw new IllegalStateException("构造模拟计费时段失败", e);
+ }
+ }
+
+ static final class PreparedSimulation {
+ private final AccountingStrategyDetailOrder tariff;
+ private final TransactionRecordMessageVO record;
+
+ PreparedSimulation(AccountingStrategyDetailOrder tariff, TransactionRecordMessageVO record) {
+ this.tariff = tariff;
+ this.record = record;
+ }
+
+ AccountingStrategyDetailOrder getTariff() {
+ return tariff;
+ }
+
+ TransactionRecordMessageVO getRecord() {
+ return record;
+ }
+ }
+}
diff --git a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/IcbcAdminFundOperationSupport.java b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/IcbcAdminFundOperationSupport.java
new file mode 100644
index 0000000..c6d8f5b
--- /dev/null
+++ b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/IcbcAdminFundOperationSupport.java
@@ -0,0 +1,148 @@
+package com.ruoyi.order.service.impl;
+
+import com.ruoyi.order.api.model.TChargingOrder;
+import com.ruoyi.order.api.model.TChargingOrderShareRecord;
+import com.ruoyi.payment.api.vo.IcbcRefundReq;
+import com.ruoyi.payment.api.vo.IcbcSubOrderQueryReq;
+import com.ruoyi.payment.api.vo.IcbcSubOrderRefundReq;
+
+import java.math.BigDecimal;
+
+final class IcbcAdminFundOperationSupport {
+ private IcbcAdminFundOperationSupport() {
+ }
+
+ static void validateOrder(TChargingOrder order) {
+ if (order == null) {
+ throw new IllegalArgumentException("充电订单不存在");
+ }
+ if (!"ICBC".equals(order.getSourceName())) {
+ throw new IllegalArgumentException("仅支持工商银行支付订单");
+ }
+ if (!Integer.valueOf(2).equals(order.getRechargePaymentStatus())) {
+ throw new IllegalArgumentException("订单尚未支付成功");
+ }
+ }
+
+ static BigDecimal requireAmount(BigDecimal amount) {
+ if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
+ throw new IllegalArgumentException("操作金额必须大于0");
+ }
+ if (amount.stripTrailingZeros().scale() > 2) {
+ throw new IllegalArgumentException("操作金额最多保留两位小数");
+ }
+ BigDecimal normalized = amount.setScale(2, BigDecimal.ROUND_HALF_UP);
+ if (normalized.compareTo(BigDecimal.ZERO) <= 0) {
+ throw new IllegalArgumentException("操作金额必须大于0");
+ }
+ return normalized;
+ }
+
+ static String buildPendingRefundValue(String operationToken, BigDecimal amount) {
+ if (isBlank(operationToken)) {
+ throw new IllegalArgumentException("退款流水号不能为空");
+ }
+ return operationToken + "|" + requireAmount(amount).toPlainString();
+ }
+
+ static String resolveRefundNo(String pendingValue, BigDecimal amount, String newOperationToken) {
+ BigDecimal normalized = requireAmount(amount);
+ if (isBlank(pendingValue)) {
+ return newOperationToken;
+ }
+ int separator = pendingValue.lastIndexOf('|');
+ if (separator <= 0 || separator == pendingValue.length() - 1) {
+ throw new IllegalArgumentException("退款幂等记录格式错误");
+ }
+ BigDecimal pendingAmount;
+ try {
+ pendingAmount = new BigDecimal(pendingValue.substring(separator + 1));
+ } catch (NumberFormatException e) {
+ throw new IllegalArgumentException("退款幂等记录格式错误");
+ }
+ if (pendingAmount.compareTo(normalized) != 0) {
+ throw new IllegalArgumentException("上一笔退款结果未知,请按原金额重试");
+ }
+ return pendingValue.substring(0, separator);
+ }
+
+ static boolean shouldClearPendingRefund(boolean reusedPending, String status, boolean definitiveResponse) {
+ return "SUCCESS".equals(status) || (!reusedPending && definitiveResponse);
+ }
+
+ static IcbcRefundReq buildRefundRequest(TChargingOrder order, BigDecimal amount, String outRefundNo) {
+ validateOrder(order);
+ IcbcRefundReq request = new IcbcRefundReq();
+ request.setOutTradeNo(order.getCode());
+ request.setOrderId(order.getRechargeSerialNumber());
+ request.setOutRefundNo(outRefundNo);
+ request.setRefundAmount(requireAmount(amount));
+ request.setTotalAmount(order.getThirdPayAmount());
+ request.setReason("管理后台工行退款");
+ return request;
+ }
+
+ static IcbcSubOrderQueryReq buildSubOrderQueryRequest(TChargingOrder order, TChargingOrderShareRecord shareRecord) {
+ validateOrder(order);
+ validateShareRecord(order, shareRecord);
+ IcbcSubOrderQueryReq request = new IcbcSubOrderQueryReq();
+ request.setOrderNoType("2");
+ request.setMerOrderId(order.getCode());
+ request.setPrtiId(shareRecord.getOriMerId());
+ request.setQueryType("1");
+ request.setMerTrnscSerno(shareRecord.getSubOrderId());
+ request.setSubPrtiId(shareRecord.getSubMerId());
+ return request;
+ }
+
+ static IcbcSubOrderRefundReq buildSubOrderRefundRequest(TChargingOrder order,
+ TChargingOrderShareRecord shareRecord,
+ BigDecimal amount,
+ String retSubOrderId,
+ String outtrxSerialNo) {
+ validateOrder(order);
+ validateShareRecord(order, shareRecord);
+ if (Boolean.TRUE.equals(shareRecord.getDelFlag())) {
+ throw new IllegalArgumentException("分账记录已删除");
+ }
+ if (!"SUCCESS".equals(shareRecord.getOnlineShareStatus())) {
+ throw new IllegalArgumentException("仅支持已成功的工行分账记录退款");
+ }
+ if (isBlank(shareRecord.getSeqNo()) || isBlank(shareRecord.getBusiType())
+ || isBlank(shareRecord.getOriMerId()) || shareRecord.getOriTrxDate() == null
+ || isBlank(shareRecord.getSubMerId()) || isBlank(shareRecord.getSubMerPrtclNo())) {
+ throw new IllegalArgumentException("分账记录缺少工行退款参数");
+ }
+ IcbcSubOrderRefundReq request = new IcbcSubOrderRefundReq();
+ request.setChargingOrderCode(order.getCode());
+ request.setSeqNo(shareRecord.getSeqNo());
+ request.setBusiType(shareRecord.getBusiType());
+ request.setOriMerId(shareRecord.getOriMerId());
+ request.setOriTrxDate(shareRecord.getOriTrxDate() == null ? null : shareRecord.getOriTrxDate().toString());
+ request.setSubOrderId(shareRecord.getSubOrderId());
+ request.setRetSubOrderId(retSubOrderId);
+ request.setOuttrxSerialNo(outtrxSerialNo);
+ request.setSubMerId(shareRecord.getSubMerId());
+ request.setSubMerPrtclNo(shareRecord.getSubMerPrtclNo());
+ request.setRefundAmount(requireAmount(amount));
+ request.setMerAcct(shareRecord.getMerAcct());
+ request.setMerSpInfor("管理后台工行分账退款");
+ return request;
+ }
+
+ private static void validateShareRecord(TChargingOrder order, TChargingOrderShareRecord shareRecord) {
+ if (shareRecord == null || !order.getId().equals(shareRecord.getChargingOrderId())) {
+ throw new IllegalArgumentException("分账记录不属于当前订单");
+ }
+ if (!"ICBC".equals(shareRecord.getPayChannel())) {
+ throw new IllegalArgumentException("仅支持工商银行分账记录");
+ }
+ if (shareRecord.getSubOrderId() == null || shareRecord.getSubOrderId().trim().isEmpty()) {
+ throw new IllegalArgumentException("分账记录缺少工行子订单号");
+ }
+ }
+
+ private static boolean isBlank(String value) {
+ return value == null || value.trim().isEmpty();
+ }
+}
diff --git a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TChargingOrderServiceImpl.java b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TChargingOrderServiceImpl.java
index c72ad45..f1b0083 100644
--- a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TChargingOrderServiceImpl.java
+++ b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TChargingOrderServiceImpl.java
@@ -5,6 +5,7 @@
import com.alibaba.fastjson2.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.account.api.feignClient.*;
@@ -61,6 +62,8 @@
import com.ruoyi.order.util.mongodb.service.TransactionRecordService;
import com.ruoyi.order.util.mongodb.service.UploadRealTimeMonitoringDataService;
import com.ruoyi.order.vo.EndOfChargePageInfo;
+import com.ruoyi.order.vo.ChargingSimulationVO;
+import com.ruoyi.order.vo.IcbcAdminSubOrderVO;
import com.ruoyi.order.vo.OrderCountByDate;
import com.ruoyi.order.vo.StatisticsOfBatteryVO;
import com.ruoyi.other.api.domain.*;
@@ -77,6 +80,7 @@
import com.ruoyi.payment.api.vo.*;
import com.ruoyi.system.api.domain.SysUser;
import com.ruoyi.system.api.feignClient.SysUserClient;
+import org.redisson.api.RBucket;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.slf4j.Logger;
@@ -155,6 +159,8 @@
private IcbcPaymentClient icbcPaymentClient;
@Resource
private TChargingOrderShareRecordMapper chargingOrderShareRecordMapper;
+ @Resource
+ private ChargingOrderSettlementExecutor chargingOrderSettlementExecutor;
@Resource
private AppCouponClient appCouponClient;
@@ -817,7 +823,7 @@
chargingOrder.setWhitelistServiceRefundAmount(BigDecimal.ZERO);
chargingOrder.setVipDiscountAmount(BigDecimal.ZERO);
chargingOrder.setOrderSource(1);
- if (thirdPayAmount.compareTo(BigDecimal.ZERO) > 0) {
+ if (thirdPayAmount.compareTo(BigDecimal.ZERO) > 0 && Integer.valueOf(1).equals(addChargingOrder.getPaymentType())) {
chargingOrder.setSourceName("ICBC");
}
chargingOrder.setTitle("【充电桩充电】" + chargingPile.getNumber() + "号桩/" + tChargingGun.getCode() + "号枪");
@@ -880,21 +886,371 @@
}
//会员优惠折扣将其计入增加充电时长(增加总充电金额)
//如果充电总金额未使用完,则需要退回费用=(原金额/总金额)*(总金额-实际充电金额)
- IcbcPaymentReq req = new IcbcPaymentReq();
- req.setOutTradeNo(chargingOrder.getCode());
- req.setAmount(thirdPayAmount);
- req.setSubject("充电充值");
- req.setBody("充电充值");
- req.setPaymentType(addChargingOrder.getPaymentType());
- req.setNotifyUrl("/payment/icbc/callBack");
- req.setReturnUrl(addChargingOrder.getReturnUrl());
- req.setOpenId(Integer.valueOf(1).equals(addChargingOrder.getPaymentType()) ? appUser.getWxOpenid() : appUser.getAliOpenid());
- req.setProfitSharing(buildIcbcProfitSharing(site));
- IcbcPaymentResp data = icbcPaymentClient.payment(req).getData();
- if (isIcbcPaymentCreated(data)) {
- return AjaxResult.success(data);
+ if (Integer.valueOf(1).equals(addChargingOrder.getPaymentType())) {
+ IcbcPaymentReq req = new IcbcPaymentReq();
+ req.setOutTradeNo(chargingOrder.getCode());
+ req.setAmount(thirdPayAmount);
+ req.setSubject("充电充值");
+ req.setBody("充电充值");
+ req.setPaymentType(1);
+ req.setNotifyUrl("/payment/icbc/callBack");
+ req.setReturnUrl(addChargingOrder.getReturnUrl());
+ req.setOpenId(appUser.getWxOpenid());
+ req.setProfitSharing(buildIcbcProfitSharing(site));
+ IcbcPaymentResp data = icbcPaymentClient.payment(req).getData();
+ if (isIcbcPaymentCreated(data)) {
+ if (StringUtils.hasLength(data.getTradeNo())) {
+ chargingOrder.setRechargeSerialNumber(data.getTradeNo());
+ this.updateById(chargingOrder);
+ }
+ return AjaxResult.success(data);
+ }
+ throw new RuntimeException("调起工行支付失败");
}
- throw new RuntimeException("调起工行支付失败");
+ if (Integer.valueOf(2).equals(addChargingOrder.getPaymentType())) {
+ AliPaymentReq req = new AliPaymentReq();
+ req.setOutTradeNo(chargingOrder.getCode());
+ req.setTotalAmount(thirdPayAmount.toString());
+ req.setSubject("充电充值");
+ req.setBuyerOpenId(appUser.getAliOpenid());
+ req.setBody("充电充值");
+ req.setNotifyUrl("/payment/ali/callBack");
+ AliPaymentResp data = aliPaymentClient.payment(req).getData();
+ if (data != null) {
+ return AjaxResult.success(data);
+ }
+ throw new RuntimeException("调起支付宝支付失败");
+ }
+ if (Integer.valueOf(3).equals(addChargingOrder.getPaymentType())) {
+ AliPaymentReq req = new AliPaymentReq();
+ req.setOutTradeNo(chargingOrder.getCode());
+ req.setTotalAmount(thirdPayAmount.toString());
+ req.setSubject("充电充值");
+ req.setBuyerOpenId(appUser.getAliOpenid());
+ req.setBody("充电充值");
+ req.setNotifyUrl("/payment/aliH5/callBack");
+ req.setReturnUrl(addChargingOrder.getReturnUrl());
+ String data = h5AliPaymentClient.payment(req).getData();
+ if (StringUtils.hasLength(data)) {
+ return AjaxResult.success("操作成功", data);
+ }
+ }
+ throw new RuntimeException("无效的支付方式");
+ }
+
+ @Override
+ public ChargingSimulationVO getCurrentSimulationOrder(Integer gunId) {
+ if (gunId == null) {
+ throw new ServiceException("充电枪不能为空");
+ }
+ Long userId = tokenService.getLoginUserApplet().getUserId();
+ return buildSimulationVO(findSimulationOrder(userId, gunId));
+ }
+
+ @Override
+ public ChargingSimulationVO completeSimulation(ChargingSimulationReq request) {
+ if (request == null || request.getGunId() == null) {
+ throw new ServiceException("充电枪不能为空");
+ }
+ Long userId = tokenService.getLoginUserApplet().getUserId();
+ TChargingOrder order = findSimulationOrder(userId, request.getGunId());
+ List<UploadRealTimeMonitoringData> monitoringData = uploadRealTimeMonitoringDataService.getDataByOrderCode(order.getCode());
+ if (monitoringData != null && !monitoringData.isEmpty()) {
+ throw new ServiceException("订单已存在真实充电监控数据,禁止模拟结算");
+ }
+ BigDecimal electricityFee = money(request.getElectricityFee());
+ BigDecimal serviceFee = money(request.getServiceFee());
+ BigDecimal simulationTotal = electricityFee.add(serviceFee).setScale(2, RoundingMode.HALF_UP);
+ BigDecimal availableAmount = money(order.getChargeAmount()).compareTo(BigDecimal.ZERO) > 0
+ ? money(order.getChargeAmount()) : money(order.getRechargeAmount());
+ if (simulationTotal.compareTo(availableAmount) > 0) {
+ throw new ServiceException("模拟消费金额不能超过订单可消费金额" + availableAmount.toPlainString() + "元");
+ }
+
+ List<AccountingStrategyDetailOrder> tariffs = accountingStrategyDetailOrderService.list(
+ new LambdaQueryWrapper<AccountingStrategyDetailOrder>()
+ .eq(AccountingStrategyDetailOrder::getChargingOrderId, order.getId())
+ .orderByAsc(AccountingStrategyDetailOrder::getStartTime));
+ ChargingSimulationSupport.PreparedSimulation prepared;
+ try {
+ prepared = ChargingSimulationSupport.prepare(order.getCode(), tariffs, electricityFee, serviceFee, LocalDateTime.now());
+ } catch (IllegalArgumentException e) {
+ throw new ServiceException(e.getMessage());
+ }
+ accountingStrategyDetailOrderService.updateById(prepared.getTariff());
+
+ TChargingGun gun = chargingGunClient.getChargingGunById(order.getChargingGunId()).getData();
+ TChargingPile pile = chargingPileClient.getChargingPileById(order.getChargingPileId()).getData();
+ TransactionRecordMessageVO record = prepared.getRecord();
+ record.setCharging_gun_code(gun == null ? null : gun.getCode());
+ record.setCharging_pile_code(pile == null ? null : pile.getCode());
+ if (Integer.valueOf(2).equals(order.getStatus())) {
+ TChargingOrder charging = new TChargingOrder();
+ charging.setId(order.getId());
+ charging.setStatus(3);
+ charging.setStartTime(LocalDateTime.now().minusSeconds(1));
+ this.updateById(charging);
+ }
+ R settlement = chargingOrderSettlementExecutor.settle(record);
+ if (R.isError(settlement)) {
+ throw new ServiceException(StringUtils.hasLength(settlement.getMsg()) ? settlement.getMsg() : "模拟结算失败");
+ }
+ return buildSimulationVO(this.getById(order.getId()));
+ }
+
+ private TChargingOrder findSimulationOrder(Long userId, Integer gunId) {
+ TChargingOrder order = this.getOne(new LambdaQueryWrapper<TChargingOrder>()
+ .eq(TChargingOrder::getAppUserId, userId)
+ .eq(TChargingOrder::getChargingGunId, gunId)
+ .eq(TChargingOrder::getSourceName, "ICBC")
+ .eq(TChargingOrder::getRechargePaymentStatus, 2)
+ .in(TChargingOrder::getStatus, Arrays.asList(2, 3, 4))
+ .eq(TChargingOrder::getDelFlag, 0)
+ .isNull(TChargingOrder::getRefundStatus)
+ .orderByDesc(TChargingOrder::getId)
+ .last("limit 1"));
+ if (order == null) {
+ throw new ServiceException("当前枪没有属于你的可模拟ICBC已支付订单");
+ }
+ return order;
+ }
+
+ private ChargingSimulationVO buildSimulationVO(TChargingOrder order) {
+ ChargingSimulationVO result = new ChargingSimulationVO();
+ result.setOrderId(order.getId());
+ result.setOrderCode(order.getCode());
+ result.setOrderStatus(order.getStatus());
+ result.setRechargeAmount(order.getRechargeAmount());
+ result.setChargeAmount(order.getChargeAmount());
+ result.setElectricityFee(order.getElectrovalence());
+ result.setServiceFee(order.getServiceCharge());
+ result.setPaymentAmount(order.getPaymentAmount());
+ result.setRefundAmount(order.getRefundAmount());
+ result.setRefundStatus(order.getRefundStatus());
+ result.setIcbcOrderId(order.getRechargeSerialNumber());
+ TChargingOrderShareRecord shareRecord = chargingOrderShareRecordMapper.selectOne(
+ new LambdaQueryWrapper<TChargingOrderShareRecord>()
+ .eq(TChargingOrderShareRecord::getChargingOrderId, order.getId())
+ .eq(TChargingOrderShareRecord::getPayChannel, "ICBC")
+ .eq(TChargingOrderShareRecord::getDelFlag, false)
+ .orderByDesc(TChargingOrderShareRecord::getId)
+ .last("limit 1"));
+ if (shareRecord != null) {
+ result.setShareAmount(shareRecord.getShareAmount());
+ result.setShareStatus(shareRecord.getOnlineShareStatus());
+ result.setSubOrderId(shareRecord.getSubOrderId());
+ result.setSubOrderSeq(shareRecord.getSubOrderSeq());
+ result.setSubOrderTrxid(shareRecord.getSubOrderTrxid());
+ }
+ return result;
+ }
+
+ @Override
+ public IcbcPaymentResp queryIcbcAdminPayment(String orderCode) {
+ TChargingOrder order = loadIcbcAdminOrder(orderCode);
+ return requireFeignData(icbcPaymentClient.query(order.getCode()), "工行支付查询失败");
+ }
+
+ @Override
+ public IcbcRefundResp refundIcbcAdminPayment(IcbcAdminFundOperationReq request) {
+ if (request == null) {
+ throw new ServiceException("请求参数不能为空");
+ }
+ TChargingOrder order = loadIcbcAdminOrder(request.getOrderCode());
+ BigDecimal amount;
+ try {
+ amount = IcbcAdminFundOperationSupport.requireAmount(request.getAmount());
+ } catch (IllegalArgumentException e) {
+ throw new ServiceException(e.getMessage());
+ }
+ BigDecimal paidAmount = money(order.getThirdPayAmount()).compareTo(BigDecimal.ZERO) > 0
+ ? money(order.getThirdPayAmount()) : money(order.getRechargeAmount());
+ if (amount.compareTo(paidAmount) > 0) {
+ throw new ServiceException("退款金额不能超过订单第三方支付金额" + paidAmount.toPlainString() + "元");
+ }
+ RLock lock = redissonClient.getLock("icbcAdminFund:order:" + order.getCode());
+ if (!lock.tryLock()) {
+ throw new ServiceException("该订单正在执行工行资金操作,请勿重复提交");
+ }
+ try {
+ IcbcPaymentResp payment = requireFeignData(icbcPaymentClient.query(order.getCode()), "工行支付查询失败");
+ String orderId = extractIcbcOrderId(payment);
+ order.setRechargeSerialNumber(orderId);
+ RBucket<String> pendingRefund = redissonClient.getBucket("icbcAdminFund:pending:main:" + order.getCode());
+ String generatedRefundNo = "ICBCADMINREFUND" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS"));
+ String pendingValue = pendingRefund.get();
+ boolean reusingPending = StringUtils.hasLength(pendingValue);
+ String refundNo;
+ try {
+ refundNo = IcbcAdminFundOperationSupport.resolveRefundNo(pendingValue, amount, generatedRefundNo);
+ } catch (IllegalArgumentException e) {
+ throw new ServiceException(e.getMessage());
+ }
+ IcbcRefundReq refundRequest = IcbcAdminFundOperationSupport.buildRefundRequest(order, amount, refundNo);
+ if (!reusingPending) {
+ pendingRefund.set(IcbcAdminFundOperationSupport.buildPendingRefundValue(refundNo, amount));
+ }
+ IcbcRefundResp response = requireFeignData(icbcPaymentClient.refund(refundRequest), "工行退款调用失败");
+ if (IcbcAdminFundOperationSupport.shouldClearPendingRefund(
+ reusingPending, response.getStatus(), isDefinitiveIcbcResponse(response.getRawResponse()))) {
+ pendingRefund.delete();
+ }
+ return response;
+ } finally {
+ if (lock.isHeldByCurrentThread()) {
+ lock.unlock();
+ }
+ }
+ }
+
+ @Override
+ public List<IcbcAdminSubOrderVO> queryIcbcAdminSubOrders(String orderCode) {
+ TChargingOrder order = loadIcbcAdminOrder(orderCode);
+ List<TChargingOrderShareRecord> records = chargingOrderShareRecordMapper.selectList(
+ new LambdaQueryWrapper<TChargingOrderShareRecord>()
+ .eq(TChargingOrderShareRecord::getChargingOrderId, order.getId())
+ .eq(TChargingOrderShareRecord::getPayChannel, "ICBC")
+ .eq(TChargingOrderShareRecord::getDelFlag, false)
+ .orderByAsc(TChargingOrderShareRecord::getId));
+ List<IcbcAdminSubOrderVO> result = new ArrayList<>();
+ for (TChargingOrderShareRecord record : records) {
+ IcbcAdminSubOrderVO item = buildIcbcAdminSubOrderVO(record);
+ try {
+ IcbcSubOrderQueryReq query = IcbcAdminFundOperationSupport.buildSubOrderQueryRequest(order, record);
+ item.applyQuery(requireFeignData(icbcPaymentClient.querySubOrder(query), "工行子订单查询失败"));
+ } catch (Exception e) {
+ item.setQueryStatus("FAIL");
+ item.setErrorMessage(e.getMessage());
+ }
+ result.add(item);
+ }
+ return result;
+ }
+
+ @Override
+ public IcbcSubOrderRefundResp refundIcbcAdminSubOrder(IcbcAdminFundOperationReq request) {
+ if (request == null || request.getShareRecordId() == null) {
+ throw new ServiceException("请选择需要退款的分账子订单");
+ }
+ TChargingOrder order = loadIcbcAdminOrder(request.getOrderCode());
+ TChargingOrderShareRecord shareRecord = chargingOrderShareRecordMapper.selectById(request.getShareRecordId());
+ if (shareRecord == null || !order.getId().equals(shareRecord.getChargingOrderId())) {
+ throw new ServiceException("分账记录不属于当前订单");
+ }
+ BigDecimal amount;
+ try {
+ amount = IcbcAdminFundOperationSupport.requireAmount(request.getAmount());
+ } catch (IllegalArgumentException e) {
+ throw new ServiceException(e.getMessage());
+ }
+ if (shareRecord.getShareAmount() != null && amount.compareTo(shareRecord.getShareAmount()) > 0) {
+ throw new ServiceException("分账退款金额不能超过分账金额" + shareRecord.getShareAmount().toPlainString() + "元");
+ }
+ RLock lock = redissonClient.getLock("icbcAdminFund:order:" + order.getCode());
+ if (!lock.tryLock()) {
+ throw new ServiceException("该分账子订单正在退款,请勿重复提交");
+ }
+ try {
+ String suffix = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS"));
+ RBucket<String> pendingRefund = redissonClient.getBucket("icbcAdminFund:pending:sub:" + shareRecord.getId());
+ String generatedToken = "ICBCSUBREFUND" + suffix + ":ICBCSUBREFUNDNO" + suffix;
+ String pendingValue = pendingRefund.get();
+ boolean reusingPending = StringUtils.hasLength(pendingValue);
+ String operationToken;
+ try {
+ operationToken = IcbcAdminFundOperationSupport.resolveRefundNo(pendingValue, amount, generatedToken);
+ } catch (IllegalArgumentException e) {
+ throw new ServiceException(e.getMessage());
+ }
+ String[] refundNumbers = operationToken.split(":", 2);
+ if (refundNumbers.length != 2) {
+ throw new ServiceException("工行分账退款幂等记录格式错误");
+ }
+ IcbcSubOrderRefundReq refundRequest;
+ try {
+ refundRequest = IcbcAdminFundOperationSupport.buildSubOrderRefundRequest(
+ order, shareRecord, amount, refundNumbers[0], refundNumbers[1]);
+ } catch (IllegalArgumentException e) {
+ throw new ServiceException(e.getMessage());
+ }
+ if (!reusingPending) {
+ pendingRefund.set(IcbcAdminFundOperationSupport.buildPendingRefundValue(operationToken, amount));
+ }
+ IcbcSubOrderRefundResp response = requireFeignData(
+ icbcPaymentClient.refundSubOrder(refundRequest), "工行分账退款调用失败");
+ if (IcbcAdminFundOperationSupport.shouldClearPendingRefund(
+ reusingPending, response.getStatus(), isDefinitiveIcbcResponse(response.getRawResponse()))) {
+ pendingRefund.delete();
+ }
+ return response;
+ } finally {
+ if (lock.isHeldByCurrentThread()) {
+ lock.unlock();
+ }
+ }
+ }
+
+ private TChargingOrder loadIcbcAdminOrder(String orderCode) {
+ if (!StringUtils.hasLength(orderCode)) {
+ throw new ServiceException("订单号不能为空");
+ }
+ TChargingOrder order = this.getOne(new LambdaQueryWrapper<TChargingOrder>()
+ .eq(TChargingOrder::getCode, orderCode)
+ .eq(TChargingOrder::getDelFlag, 0)
+ .last("limit 1"));
+ try {
+ IcbcAdminFundOperationSupport.validateOrder(order);
+ } catch (IllegalArgumentException e) {
+ throw new ServiceException(e.getMessage());
+ }
+ return order;
+ }
+
+ private String extractIcbcOrderId(IcbcPaymentResp payment) {
+ try {
+ JSONObject response = JSON.parseObject(payment.getRawResponse());
+ String orderId = response == null ? null : response.getString("order_id");
+ if (StringUtils.hasLength(orderId)) {
+ return orderId;
+ }
+ } catch (Exception ignored) {
+ }
+ throw new ServiceException("工行支付查询未返回真实order_id,禁止退款");
+ }
+
+ private boolean isDefinitiveIcbcResponse(String rawResponse) {
+ if (!StringUtils.hasLength(rawResponse)) {
+ return false;
+ }
+ try {
+ JSONObject response = JSON.parseObject(rawResponse);
+ return response != null && (response.containsKey("returnCode") || response.containsKey("return_code"));
+ } catch (Exception ignored) {
+ return false;
+ }
+ }
+
+ private IcbcAdminSubOrderVO buildIcbcAdminSubOrderVO(TChargingOrderShareRecord record) {
+ IcbcAdminSubOrderVO item = new IcbcAdminSubOrderVO();
+ item.setShareRecordId(record.getId());
+ item.setOrderCode(record.getChargingOrderCode());
+ item.setShareAmount(record.getShareAmount());
+ item.setOnlineShareStatus(record.getOnlineShareStatus());
+ item.setSubOrderId(record.getSubOrderId());
+ item.setSubMerId(record.getSubMerId());
+ item.setSubMerPrtclNo(record.getSubMerPrtclNo());
+ item.setOriTrxDate(record.getOriTrxDate());
+ item.setBankOrderId(record.getIcbcOrderId());
+ item.setSubOrderSeq(record.getSubOrderSeq());
+ item.setSubOrderTrxid(record.getSubOrderTrxid());
+ return item;
+ }
+
+ private <T> T requireFeignData(R<T> result, String message) {
+ if (result == null || R.isError(result) || result.getData() == null) {
+ throw new ServiceException(result != null && StringUtils.hasLength(result.getMsg()) ? result.getMsg() : message);
+ }
+ return result.getData();
}
@@ -911,7 +1267,26 @@
@Override
public AjaxResult chargingOrderCallback(Integer paymentType, String out_trade_no, String transaction_id, String attach) {
TChargingOrder chargingOrder = this.getOne(new LambdaQueryWrapper<TChargingOrder>().eq(TChargingOrder::getCode, out_trade_no));
+ if (chargingOrder == null) {
+ return AjaxResult.error("支付回调订单不存在");
+ }
if (chargingOrder.getRechargePaymentStatus() == 2) {
+ if (isIcbcOrder(chargingOrder) && StringUtils.hasLength(transaction_id)
+ && !transaction_id.equals(chargingOrder.getRechargeSerialNumber())) {
+ chargingOrder.setRechargeSerialNumber(transaction_id);
+ this.updateById(chargingOrder);
+ }
+ return AjaxResult.success();
+ }
+ LocalDateTime callbackPayTime = LocalDateTime.now();
+ int claimed = this.baseMapper.update(null, new LambdaUpdateWrapper<TChargingOrder>()
+ .eq(TChargingOrder::getId, chargingOrder.getId())
+ .eq(TChargingOrder::getRechargePaymentStatus, 1)
+ .set(TChargingOrder::getRechargePaymentStatus, 2)
+ .set(TChargingOrder::getRechargeSerialNumber, transaction_id)
+ .set(TChargingOrder::getStatus, 2)
+ .set(TChargingOrder::getPayTime, callbackPayTime));
+ if (claimed == 0) {
return AjaxResult.success();
}
if (isBalancePayMode(chargingOrder.getPayMethod())
@@ -927,7 +1302,7 @@
chargingOrder.setRechargePaymentStatus(2);
chargingOrder.setRechargeSerialNumber(transaction_id);
chargingOrder.setStatus(2);
- chargingOrder.setPayTime(LocalDateTime.now());
+ chargingOrder.setPayTime(callbackPayTime);
//添加安全检测数据到缓存中,每步安全检测完成后需要更新缓存数据
PreChargeCheck preChargeCheck = new PreChargeCheck();
@@ -3183,15 +3558,19 @@
@Transactional
public R endChargeBillingCharge(TransactionRecordMessageVO vo) {
RLock lock = redissonClient.getLock("endOrder:" + vo.getTransaction_serial_number());
+ boolean locked = false;
try {
- boolean tryLock = lock.tryLock(60, 60, TimeUnit.SECONDS);
+ locked = lock.tryLock(60, 60, TimeUnit.SECONDS);
+ if (!locked) {
+ return R.fail("订单正在结算,请勿重复操作");
+ }
//添加redis缓存数据,过期时间,用于防止处理缓慢且硬件重复发送报文导致的重复退款
Boolean hasKey = redisService.hasKey("END_ORDER:" + vo.getTransaction_serial_number());
if(hasKey){
return R.fail("不能重复退款");
}
redisService.setCacheObject("END_ORDER:" + vo.getTransaction_serial_number(), System.currentTimeMillis() + "", 10L, TimeUnit.MINUTES);
- if(tryLock){
+ if(locked){
TChargingOrder chargingOrder = this.getOne(new LambdaQueryWrapper<TChargingOrder>().eq(TChargingOrder::getCode, vo.getTransaction_serial_number()));
if (null == chargingOrder) {
log.error("订单不存在,订单号:{}", vo.getTransaction_serial_number());
@@ -3683,8 +4062,15 @@
this.updateById(chargingOrder);
triggerChargingOrderShare(chargingOrder);
} else if (isIcbcOrder(chargingOrder)) {
- refundByIcbcAndFinish(chargingOrder, chargingOrderRefund, thirdRefundAmount, "充电完成退款", false,
- refundAmount, balanceRefundAmount);
+ boolean refundSuccess = refundByIcbcAndFinish(chargingOrder, chargingOrderRefund, thirdRefundAmount,
+ "充电完成退款", false, refundAmount, balanceRefundAmount);
+ if (!refundSuccess) {
+ chargingOrderRefund.setRefundStatus(1);
+ chargingOrderRefundService.save(chargingOrderRefund);
+ chargingOrder.setRefundStatus(1);
+ this.updateById(chargingOrder);
+ throw new ServiceException("工行退款失败,结算未完成,请检查支付流水后重试退款");
+ }
} else if (1 == rechargePaymentType) {
WxPaymentRefundModel model = new WxPaymentRefundModel();
model.setOut_trade_no(chargingOrder.getCode());
@@ -3827,9 +4213,12 @@
chargingMessageClient.pushOrderStatus(chargingOrderVo);
}
} catch (Exception e) {
- e.printStackTrace();
+ log.error("充电订单结算失败,订单号:{}", vo == null ? null : vo.getTransaction_serial_number(), e);
+ return R.fail("充电订单结算失败:" + firstNotBlank(e.getMessage(), "未知异常"));
} finally {
- lock.unlock();
+ if (locked && lock.isHeldByCurrentThread()) {
+ lock.unlock();
+ }
}
return R.ok();
}
@@ -6131,7 +6520,15 @@
if (resp == null || !StringUtils.hasLength(resp.getStatus())) {
return false;
}
- return "SUCCESS".equals(resp.getStatus());
+ if (!"SUCCESS".equals(resp.getStatus()) || !StringUtils.hasLength(resp.getPayData())) {
+ return false;
+ }
+ try {
+ JSONObject payData = JSON.parseObject(resp.getPayData());
+ return StringUtils.hasLength(payData.getString("wxDataPackage")) || StringUtils.hasLength(payData.getString("package"));
+ } catch (Exception e) {
+ return false;
+ }
}
private void triggerChargingOrderShare(TChargingOrder chargingOrder, Site site) {
diff --git a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/vo/ChargingSimulationVO.java b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/vo/ChargingSimulationVO.java
new file mode 100644
index 0000000..922ca1b
--- /dev/null
+++ b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/vo/ChargingSimulationVO.java
@@ -0,0 +1,25 @@
+package com.ruoyi.order.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+@Data
+public class ChargingSimulationVO {
+ private Long orderId;
+ private String orderCode;
+ private Integer orderStatus;
+ private BigDecimal rechargeAmount;
+ private BigDecimal chargeAmount;
+ private BigDecimal electricityFee;
+ private BigDecimal serviceFee;
+ private BigDecimal paymentAmount;
+ private BigDecimal refundAmount;
+ private Integer refundStatus;
+ private BigDecimal shareAmount;
+ private String shareStatus;
+ private String icbcOrderId;
+ private String subOrderId;
+ private String subOrderSeq;
+ private String subOrderTrxid;
+}
diff --git a/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/vo/IcbcAdminSubOrderVO.java b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/vo/IcbcAdminSubOrderVO.java
new file mode 100644
index 0000000..83c1dda
--- /dev/null
+++ b/ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/vo/IcbcAdminSubOrderVO.java
@@ -0,0 +1,45 @@
+package com.ruoyi.order.vo;
+
+import com.ruoyi.payment.api.vo.IcbcSubOrderQueryResp;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+
+@Data
+public class IcbcAdminSubOrderVO {
+ private Long shareRecordId;
+ private String orderCode;
+ private BigDecimal shareAmount;
+ private String onlineShareStatus;
+ private String subOrderId;
+ private String subMerId;
+ private String subMerPrtclNo;
+ private LocalDate oriTrxDate;
+ private String queryStatus;
+ private String bankOrderStatus;
+ private String bankOrderId;
+ private String subOrderSeq;
+ private String subOrderTrxid;
+ private String bankOrderAmount;
+ private String bankRefundAmount;
+ private String rawResponse;
+ private String errorMessage;
+
+ public void applyQuery(IcbcSubOrderQueryResp response) {
+ if (response == null) {
+ queryStatus = "FAIL";
+ errorMessage = "工行子订单查询无响应";
+ return;
+ }
+ queryStatus = response.getStatus();
+ bankOrderStatus = response.getOrderStatus();
+ bankOrderId = response.getOrderId();
+ bankOrderAmount = response.getOrderAmnt();
+ bankRefundAmount = response.getOrderRefundAmnt();
+ rawResponse = response.getRawResponse();
+ if (!"SUCCESS".equals(response.getStatus())) {
+ errorMessage = response.getRawResponse();
+ }
+ }
+}
diff --git a/ruoyi-service/ruoyi-order/src/main/resources/mapper/order/TChargingOrderMapper.xml b/ruoyi-service/ruoyi-order/src/main/resources/mapper/order/TChargingOrderMapper.xml
index e3fe1aa..9d995fa 100644
--- a/ruoyi-service/ruoyi-order/src/main/resources/mapper/order/TChargingOrderMapper.xml
+++ b/ruoyi-service/ruoyi-order/src/main/resources/mapper/order/TChargingOrderMapper.xml
@@ -1106,6 +1106,8 @@
a.start_charge_seq as startChargeSeq,
a.charging_gun_id as chargingGunId,
a.order_source as orderSource,
+ a.source_name as sourceName,
+ a.recharge_serial_number as rechargeSerialNumber,
a.`code`,
a.order_type as orderType,
d.`name` as terminalName,
diff --git a/ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/ChargingSimulationSupportTest.java b/ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/ChargingSimulationSupportTest.java
new file mode 100644
index 0000000..13d2754
--- /dev/null
+++ b/ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/ChargingSimulationSupportTest.java
@@ -0,0 +1,49 @@
+package com.ruoyi.order.service.impl;
+
+import com.ruoyi.order.api.model.AccountingStrategyDetailOrder;
+import com.ruoyi.order.api.vo.TransactionRecordMessageVO;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.Arrays;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class ChargingSimulationSupportTest {
+
+ @Test
+ void buildsOneKwhRecordForSelectedTariffPeriod() {
+ AccountingStrategyDetailOrder first = detail(1, 1, "00:00", "12:00");
+ AccountingStrategyDetailOrder second = detail(2, 3, "12:00", "23:59");
+ LocalDateTime endTime = LocalDateTime.of(2026, 7, 11, 15, 30, 0);
+
+ ChargingSimulationSupport.PreparedSimulation prepared = ChargingSimulationSupport.prepare(
+ "CD202607110001",
+ Arrays.asList(first, second),
+ new BigDecimal("0.03"),
+ new BigDecimal("0.02"),
+ endTime);
+
+ assertEquals(second, prepared.getTariff());
+ assertEquals(new BigDecimal("0.03"), second.getElectrovalence());
+ assertEquals(new BigDecimal("0.02"), second.getServiceCharge());
+ assertEquals(new BigDecimal("0.02"), second.getCostServiceCharge());
+
+ TransactionRecordMessageVO record = prepared.getRecord();
+ assertEquals("CD202607110001", record.getTransaction_serial_number());
+ assertEquals(BigDecimal.ONE, record.getTotal_electricity());
+ assertEquals(BigDecimal.ONE, record.getLoss_total_electricity());
+ assertEquals(BigDecimal.ONE, record.getFlat_charge2());
+ assertEquals(new BigDecimal("0.05"), record.getPayment_amount());
+ }
+
+ private AccountingStrategyDetailOrder detail(int id, int type, String start, String end) {
+ AccountingStrategyDetailOrder detail = new AccountingStrategyDetailOrder();
+ detail.setId(id);
+ detail.setType(type);
+ detail.setStartTime(start);
+ detail.setEndTime(end);
+ return detail;
+ }
+}
diff --git a/ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/IcbcAdminFundOperationSupportTest.java b/ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/IcbcAdminFundOperationSupportTest.java
new file mode 100644
index 0000000..41d9cb3
--- /dev/null
+++ b/ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/IcbcAdminFundOperationSupportTest.java
@@ -0,0 +1,159 @@
+package com.ruoyi.order.service.impl;
+
+import com.ruoyi.order.api.model.TChargingOrder;
+import com.ruoyi.order.api.model.TChargingOrderShareRecord;
+import com.ruoyi.payment.api.vo.IcbcRefundReq;
+import com.ruoyi.payment.api.vo.IcbcSubOrderQueryReq;
+import com.ruoyi.payment.api.vo.IcbcSubOrderRefundReq;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class IcbcAdminFundOperationSupportTest {
+
+ @Test
+ void rejectsNonIcbcOrder() {
+ TChargingOrder order = paidOrder();
+ order.setSourceName("ALIPAY");
+
+ IllegalArgumentException error = assertThrows(IllegalArgumentException.class,
+ () -> IcbcAdminFundOperationSupport.validateOrder(order));
+
+ assertEquals("仅支持工商银行支付订单", error.getMessage());
+ }
+
+ @Test
+ void buildsMainOrderRefundFromBusinessOrder() {
+ IcbcRefundReq request = IcbcAdminFundOperationSupport.buildRefundRequest(
+ paidOrder(), new BigDecimal("0.12"), "ICBCADMINREFUND001");
+
+ assertEquals("CD202607110001", request.getOutTradeNo());
+ assertEquals("231011990056000012345", request.getOrderId());
+ assertEquals("ICBCADMINREFUND001", request.getOutRefundNo());
+ assertEquals(new BigDecimal("0.12"), request.getRefundAmount());
+ assertEquals("管理后台工行退款", request.getReason());
+ }
+
+ @Test
+ void buildsConsumptionSubOrderQueryFromShareRecord() {
+ IcbcSubOrderQueryReq request = IcbcAdminFundOperationSupport.buildSubOrderQueryRequest(
+ paidOrder(), shareRecord());
+
+ assertEquals("2", request.getOrderNoType());
+ assertEquals("CD202607110001", request.getMerOrderId());
+ assertEquals("231011990056", request.getPrtiId());
+ assertEquals("1", request.getQueryType());
+ assertEquals("ICBCSUB202607110001", request.getMerTrnscSerno());
+ assertEquals("231011990055", request.getSubPrtiId());
+ }
+
+ @Test
+ void buildsSubOrderRefundFromSelectedShareRecord() {
+ IcbcSubOrderRefundReq request = IcbcAdminFundOperationSupport.buildSubOrderRefundRequest(
+ paidOrder(), shareRecord(), new BigDecimal("0.03"),
+ "ICBCSUBREFUND001", "ICBCSUBREFUNDSERIAL001");
+
+ assertEquals("CD202607110001", request.getChargingOrderCode());
+ assertEquals("CD202607110001", request.getSeqNo());
+ assertEquals("1", request.getBusiType());
+ assertEquals("231011990056", request.getOriMerId());
+ assertEquals("2026-07-11", request.getOriTrxDate());
+ assertEquals("ICBCSUB202607110001", request.getSubOrderId());
+ assertEquals("231011990055", request.getSubMerId());
+ assertEquals("2310119900550201", request.getSubMerPrtclNo());
+ assertEquals(new BigDecimal("0.03"), request.getRefundAmount());
+ }
+
+ @Test
+ void rejectsAmountWithMoreThanTwoDecimalPlaces() {
+ IllegalArgumentException error = assertThrows(IllegalArgumentException.class,
+ () -> IcbcAdminFundOperationSupport.requireAmount(new BigDecimal("0.004")));
+
+ assertEquals("操作金额最多保留两位小数", error.getMessage());
+ }
+
+ @Test
+ void rejectsDeletedOrUnsuccessfulShareRecord() {
+ TChargingOrderShareRecord deleted = shareRecord();
+ deleted.setDelFlag(true);
+ IllegalArgumentException deletedError = assertThrows(IllegalArgumentException.class,
+ () -> IcbcAdminFundOperationSupport.buildSubOrderRefundRequest(
+ paidOrder(), deleted, new BigDecimal("0.01"), "REFUND1", "SERIAL1"));
+ assertEquals("分账记录已删除", deletedError.getMessage());
+
+ TChargingOrderShareRecord failed = shareRecord();
+ failed.setOnlineShareStatus("FAIL");
+ IllegalArgumentException failedError = assertThrows(IllegalArgumentException.class,
+ () -> IcbcAdminFundOperationSupport.buildSubOrderRefundRequest(
+ paidOrder(), failed, new BigDecimal("0.01"), "REFUND2", "SERIAL2"));
+ assertEquals("仅支持已成功的工行分账记录退款", failedError.getMessage());
+ }
+
+ @Test
+ void rejectsIncompleteSuccessfulShareRecord() {
+ TChargingOrderShareRecord record = shareRecord();
+ record.setSubMerPrtclNo(null);
+
+ IllegalArgumentException error = assertThrows(IllegalArgumentException.class,
+ () -> IcbcAdminFundOperationSupport.buildSubOrderRefundRequest(
+ paidOrder(), record, new BigDecimal("0.01"), "REFUND3", "SERIAL3"));
+
+ assertEquals("分账记录缺少工行退款参数", error.getMessage());
+ }
+
+ @Test
+ void reusesUnknownRefundSerialOnlyForTheSameAmount() {
+ String pending = IcbcAdminFundOperationSupport.buildPendingRefundValue(
+ "ICBCADMINREFUND001", new BigDecimal("0.04"));
+
+ assertEquals("ICBCADMINREFUND001", IcbcAdminFundOperationSupport.resolveRefundNo(
+ pending, new BigDecimal("0.04"), "ICBCADMINREFUND002"));
+
+ IllegalArgumentException error = assertThrows(IllegalArgumentException.class,
+ () -> IcbcAdminFundOperationSupport.resolveRefundNo(
+ pending, new BigDecimal("0.03"), "ICBCADMINREFUND003"));
+ assertEquals("上一笔退款结果未知,请按原金额重试", error.getMessage());
+ }
+
+ @Test
+ void keepsUnknownRetrySerialUntilAConfirmedSuccess() {
+ assertFalse(IcbcAdminFundOperationSupport.shouldClearPendingRefund(true, "FAIL", true));
+ assertTrue(IcbcAdminFundOperationSupport.shouldClearPendingRefund(false, "FAIL", true));
+ assertTrue(IcbcAdminFundOperationSupport.shouldClearPendingRefund(true, "SUCCESS", true));
+ assertFalse(IcbcAdminFundOperationSupport.shouldClearPendingRefund(false, "FAIL", false));
+ }
+
+ private TChargingOrder paidOrder() {
+ TChargingOrder order = new TChargingOrder();
+ order.setId(100L);
+ order.setCode("CD202607110001");
+ order.setSourceName("ICBC");
+ order.setRechargePaymentStatus(2);
+ order.setRechargeSerialNumber("231011990056000012345");
+ return order;
+ }
+
+ private TChargingOrderShareRecord shareRecord() {
+ TChargingOrderShareRecord record = new TChargingOrderShareRecord();
+ record.setId(200L);
+ record.setChargingOrderId(100L);
+ record.setChargingOrderCode("CD202607110001");
+ record.setPayChannel("ICBC");
+ record.setDelFlag(false);
+ record.setOnlineShareStatus("SUCCESS");
+ record.setSeqNo("CD202607110001");
+ record.setBusiType("1");
+ record.setOriMerId("231011990056");
+ record.setOriTrxDate(LocalDate.of(2026, 7, 11));
+ record.setSubOrderId("ICBCSUB202607110001");
+ record.setSubMerId("231011990055");
+ record.setSubMerPrtclNo("2310119900550201");
+ return record;
+ }
+}
diff --git a/ruoyi-service/ruoyi-payment/src/main/java/com/ruoyi/payment/domain/IcbcPaymentFlowRecord.java b/ruoyi-service/ruoyi-payment/src/main/java/com/ruoyi/payment/domain/IcbcPaymentFlowRecord.java
new file mode 100644
index 0000000..11e8d2c
--- /dev/null
+++ b/ruoyi-service/ruoyi-payment/src/main/java/com/ruoyi/payment/domain/IcbcPaymentFlowRecord.java
@@ -0,0 +1,31 @@
+package com.ruoyi.payment.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+@Data
+@TableName("t_icbc_payment_flow_record")
+public class IcbcPaymentFlowRecord {
+ @TableId(type = IdType.AUTO)
+ private Long id;
+ private String outTradeNo;
+ private String businessType;
+ private String eventType;
+ private Integer paymentType;
+ private BigDecimal amount;
+ private String icbcOrderId;
+ private String icbcMsgId;
+ private String thirdTradeNo;
+ private String status;
+ private String requestJson;
+ private String responseJson;
+ private String errorMessage;
+ private LocalDateTime createTime;
+ private LocalDateTime updateTime;
+ private Integer delFlag;
+}
diff --git a/ruoyi-service/ruoyi-payment/src/main/java/com/ruoyi/payment/icbc/service/IcbcPaymentFlowRecorder.java b/ruoyi-service/ruoyi-payment/src/main/java/com/ruoyi/payment/icbc/service/IcbcPaymentFlowRecorder.java
new file mode 100644
index 0000000..fd9729f
--- /dev/null
+++ b/ruoyi-service/ruoyi-payment/src/main/java/com/ruoyi/payment/icbc/service/IcbcPaymentFlowRecorder.java
@@ -0,0 +1,92 @@
+package com.ruoyi.payment.icbc.service;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.ruoyi.payment.domain.IcbcPaymentFlowRecord;
+import com.ruoyi.payment.mapper.IcbcPaymentFlowRecordMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+@Slf4j
+@Service
+public class IcbcPaymentFlowRecorder {
+ @Resource
+ private IcbcPaymentFlowRecordMapper mapper;
+
+ public void record(String eventType,
+ String outTradeNo,
+ Integer paymentType,
+ BigDecimal amount,
+ Object request,
+ Object response,
+ String status,
+ String errorMessage) {
+ try {
+ String requestJson = toJson(request);
+ String responseJson = toJson(response);
+ JSONObject requestObject = parseObject(requestJson);
+ JSONObject responseObject = parseObject(responseJson);
+ IcbcPaymentFlowRecord record = new IcbcPaymentFlowRecord();
+ record.setOutTradeNo(outTradeNo);
+ record.setBusinessType(outTradeNo != null && outTradeNo.startsWith("ICBCTEST") ? "TEST" : "CHARGING_ORDER");
+ record.setEventType(eventType);
+ record.setPaymentType(paymentType);
+ record.setAmount(amount);
+ record.setIcbcOrderId(firstNotBlank(value(responseObject, "order_id"), value(responseObject, "orderId"),
+ value(responseObject, "transaction_id"), value(requestObject, "order_id"), value(requestObject, "orderId")));
+ record.setIcbcMsgId(firstNotBlank(value(responseObject, "msg_id"), value(responseObject, "msgId"),
+ value(requestObject, "msg_id"), value(requestObject, "msgId")));
+ record.setThirdTradeNo(firstNotBlank(value(responseObject, "third_trade_no"), value(responseObject, "thirdTradeNo"),
+ value(requestObject, "third_trade_no"), value(requestObject, "thirdTradeNo")));
+ record.setStatus(status);
+ record.setRequestJson(requestJson);
+ record.setResponseJson(responseJson);
+ record.setErrorMessage(truncate(errorMessage, 1000));
+ record.setCreateTime(LocalDateTime.now());
+ record.setUpdateTime(LocalDateTime.now());
+ record.setDelFlag(0);
+ mapper.insert(record);
+ } catch (Exception e) {
+ log.warn("工行支付流水审计记录失败,事件:{},订单号:{}", eventType, outTradeNo, e);
+ }
+ }
+
+ private String toJson(Object value) {
+ if (value == null) {
+ return null;
+ }
+ return value instanceof String ? (String) value : JSON.toJSONString(value);
+ }
+
+ private JSONObject parseObject(String value) {
+ if (value == null || value.trim().isEmpty()) {
+ return null;
+ }
+ try {
+ return JSON.parseObject(value);
+ } catch (Exception ignored) {
+ return null;
+ }
+ }
+
+ private String value(JSONObject object, String key) {
+ return object == null ? null : object.getString(key);
+ }
+
+ private String firstNotBlank(String... values) {
+ for (String value : values) {
+ if (value != null && !value.trim().isEmpty()) {
+ return value;
+ }
+ }
+ return null;
+ }
+
+ private String truncate(String value, int maxLength) {
+ return value == null || value.length() <= maxLength ? value : value.substring(0, maxLength);
+ }
+}
diff --git a/ruoyi-service/ruoyi-payment/src/main/java/com/ruoyi/payment/icbc/service/impl/IcbcNotifyParser.java b/ruoyi-service/ruoyi-payment/src/main/java/com/ruoyi/payment/icbc/service/impl/IcbcNotifyParser.java
new file mode 100644
index 0000000..cc028f9
--- /dev/null
+++ b/ruoyi-service/ruoyi-payment/src/main/java/com/ruoyi/payment/icbc/service/impl/IcbcNotifyParser.java
@@ -0,0 +1,54 @@
+package com.ruoyi.payment.icbc.service.impl;
+
+import com.ruoyi.common.core.utils.StringUtils;
+
+import java.util.HashMap;
+import java.util.Map;
+
+final class IcbcNotifyParser {
+ private IcbcNotifyParser() {
+ }
+
+ static Map<String, String> parseVerified(Map<String, String> params) {
+ String status = firstNotBlank(params.get("return_code"), params.get("orderStatus"), params.get("pay_status"), params.get("status"));
+ if (StringUtils.isNotEmpty(status) && !isPaidStatus(status)) {
+ throw new IllegalArgumentException("工行回调支付状态非成功:" + status);
+ }
+ String outTradeNo = firstNotBlank(params.get("outTradeNo"), params.get("out_trade_no"));
+ if (StringUtils.isEmpty(outTradeNo)) {
+ throw new IllegalArgumentException("工行回调缺少订单号");
+ }
+ String tradeNo = firstNotBlank(params.get("order_id"), params.get("tradeNo"), params.get("trade_no"), params.get("msg_id"));
+ if (StringUtils.isEmpty(tradeNo)) {
+ throw new IllegalArgumentException("工行回调缺少工行订单号");
+ }
+ String attach = firstNotBlank(params.get("attach"), params.get("paymentType"), params.get("payType"));
+ if (StringUtils.isEmpty(attach)) {
+ throw new IllegalArgumentException("工行回调缺少支付类型");
+ }
+ Map<String, String> result = new HashMap<>();
+ result.put("out_trade_no", outTradeNo);
+ result.put("transaction_id", tradeNo);
+ result.put("attach", attach);
+ return result;
+ }
+
+ private static boolean isPaidStatus(String status) {
+ return "0".equalsIgnoreCase(status)
+ || "SUCCESS".equalsIgnoreCase(status)
+ || "PAID".equalsIgnoreCase(status)
+ || "PAY_SUCCESS".equalsIgnoreCase(status);
+ }
+
+ private static String firstNotBlank(String... values) {
+ if (values == null) {
+ return null;
+ }
+ for (String value : values) {
+ if (StringUtils.isNotEmpty(value)) {
+ return value;
+ }
+ }
+ return null;
+ }
+}
diff --git a/ruoyi-service/ruoyi-payment/src/main/java/com/ruoyi/payment/icbc/service/impl/IcbcPayServiceImpl.java b/ruoyi-service/ruoyi-payment/src/main/java/com/ruoyi/payment/icbc/service/impl/IcbcPayServiceImpl.java
index c43aa15..20196ab 100644
--- a/ruoyi-service/ruoyi-payment/src/main/java/com/ruoyi/payment/icbc/service/impl/IcbcPayServiceImpl.java
+++ b/ruoyi-service/ruoyi-payment/src/main/java/com/ruoyi/payment/icbc/service/impl/IcbcPayServiceImpl.java
@@ -31,6 +31,7 @@
import com.ruoyi.payment.api.vo.IcbcSubOrderSplitReq;
import com.ruoyi.payment.api.vo.IcbcSubOrderSplitResp;
import com.ruoyi.payment.icbc.config.IcbcPayProperties;
+import com.ruoyi.payment.icbc.service.IcbcPaymentFlowRecorder;
import com.ruoyi.payment.icbc.service.IcbcPayService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -53,6 +54,8 @@
public class IcbcPayServiceImpl implements IcbcPayService {
@Resource
private IcbcPayProperties properties;
+ @Resource
+ private IcbcPaymentFlowRecorder flowRecorder;
@Override
public IcbcPaymentResp payment(IcbcPaymentReq req) {
@@ -67,28 +70,34 @@
CardbusinessAggregatepayB2cOnlineConsumepurchaseRequestV2 request = new CardbusinessAggregatepayB2cOnlineConsumepurchaseRequestV2();
request.setServiceUrl(properties.getGatewayUrl());
request.setBizContent(buildConsumePurchaseBiz(req));
+ flowRecorder.record("PAY_REQUEST", req.getOutTradeNo(), req.getPaymentType(), req.getAmount(),
+ request.getBizContent(), null, "SENT", null);
CardbusinessAggregatepayB2cOnlineConsumepurchaseResponseV2 response = executeIcbc("聚合支付下单", request);
resp.setOrderId(firstNotBlank(response.getOut_trade_no(), req.getOutTradeNo()));
- resp.setTradeNo(firstNotBlank(response.getOrder_id(), response.getMsgId()));
+ resp.setTradeNo(response.getOrder_id());
resp.setStatus(response.isSuccess() ? "SUCCESS" : "FAIL");
resp.setPayData(buildConsumePurchasePayData(response, req.getPaymentType()));
resp.setRawResponse(JSON.toJSONString(response));
+ flowRecorder.record("PAY_RESPONSE", resp.getOrderId(), req.getPaymentType(), req.getAmount(),
+ request.getBizContent(), response, resp.getStatus(), null);
} catch (Exception e) {
log.error("工行聚合支付调用失败,订单号:{}", req == null ? null : req.getOutTradeNo(), e);
resp.setStatus("FAIL");
resp.setRawResponse(e.getMessage());
+ flowRecorder.record("PAY_RESPONSE", req == null ? null : req.getOutTradeNo(), req == null ? null : req.getPaymentType(),
+ req == null ? null : req.getAmount(), req, null, "FAIL", e.getMessage());
}
return resp;
}
@Override
public IcbcPaymentResp query(String outTradeNo) {
- validateCommonConfig();
- validateConfig("icbc.pay.orderQueryUrl", properties.getOrderQueryUrl());
IcbcPaymentResp resp = new IcbcPaymentResp();
resp.setOrderId(outTradeNo);
try {
+ validateCommonConfig();
+ validateConfig("icbc.pay.orderQueryUrl", properties.getOrderQueryUrl());
CardbusinessAggregatepayB2cOnlineOrderqryRequestV2 request = new CardbusinessAggregatepayB2cOnlineOrderqryRequestV2();
request.setServiceUrl(properties.getOrderQueryUrl());
request.setBizContent(buildOrderQueryBiz(outTradeNo));
@@ -98,10 +107,13 @@
resp.setStatus(firstNotBlank(response.getOrderStatus(), response.getPay_status()));
resp.setPayData(JSON.toJSONString(response));
resp.setRawResponse(JSON.toJSONString(response));
+ flowRecorder.record("QUERY", outTradeNo, null, null, buildOrderQueryBiz(outTradeNo), response,
+ response.isSuccess() ? "SUCCESS" : "FAIL", response.isSuccess() ? null : response.getReturnMsg());
} catch (Exception e) {
log.error("工行支付订单查询失败,订单号:{}", outTradeNo, e);
resp.setStatus("QUERY_FAIL");
resp.setRawResponse(e.getMessage());
+ flowRecorder.record("QUERY", outTradeNo, null, null, null, null, "FAIL", e.getMessage());
}
return resp;
}
@@ -118,13 +130,18 @@
@Override
public IcbcRefundResp refund(IcbcRefundReq req) {
- validateCommonConfig();
- validateConfig("icbc.pay.refundUrl", properties.getRefundUrl());
IcbcRefundResp resp = new IcbcRefundResp();
- resp.setOutTradeNo(req.getOutTradeNo());
- resp.setOrderId(req.getOrderId());
- resp.setOutRefundNo(req.getOutRefundNo());
+ if (req != null) {
+ resp.setOutTradeNo(req.getOutTradeNo());
+ resp.setOrderId(req.getOrderId());
+ resp.setOutRefundNo(req.getOutRefundNo());
+ }
try {
+ validateCommonConfig();
+ validateConfig("icbc.pay.refundUrl", properties.getRefundUrl());
+ if (req == null) {
+ throw new IllegalArgumentException("工行退款请求不能为空");
+ }
CardbusinessAggregatepayB2cOnlineMerrefundRequestV1 request = new CardbusinessAggregatepayB2cOnlineMerrefundRequestV1();
request.setServiceUrl(properties.getRefundUrl());
request.setBizContent(buildRefundBiz(req));
@@ -137,23 +154,33 @@
resp.setRefundId(firstNotBlank(response.getIntrx_serial_no(), response.getOuttrxSerialNo()));
resp.setRefundTime(response.getRefund_time());
resp.setRefundAmount(firstNotBlank(response.getRealRejectAmt(), response.getRejectAmt(), toFen(req.getRefundAmount())));
+ flowRecorder.record("REFUND", req.getOutTradeNo(), null, req.getRefundAmount(), request.getBizContent(), response,
+ resp.getStatus(), null);
} catch (Exception e) {
- log.error("工行退款失败,订单号:{},退款单号:{}", req.getOutTradeNo(), req.getOutRefundNo(), e);
+ log.error("工行退款失败,订单号:{},退款单号:{}",
+ req == null ? null : req.getOutTradeNo(), req == null ? null : req.getOutRefundNo(), e);
resp.setStatus("FAIL");
resp.setRawResponse(e.getMessage());
+ flowRecorder.record("REFUND", req == null ? null : req.getOutTradeNo(), null, req == null ? null : req.getRefundAmount(),
+ req, null, "FAIL", e.getMessage());
}
return resp;
}
@Override
public IcbcSubOrderSplitResp splitSubOrder(IcbcSubOrderSplitReq req) {
- validateCommonConfig();
- validateConfig("icbc.pay.merPrtclNo", properties.getMerPrtclNo());
- validateConfig("icbc.pay.subOrderSplitUrl", properties.getSubOrderSplitUrl());
IcbcSubOrderSplitResp resp = new IcbcSubOrderSplitResp();
- resp.setChargingOrderCode(req.getChargingOrderCode());
- resp.setSubOrderId(req.getSubOrderId());
+ if (req != null) {
+ resp.setChargingOrderCode(req.getChargingOrderCode());
+ resp.setSubOrderId(req.getSubOrderId());
+ }
try {
+ validateCommonConfig();
+ validateConfig("icbc.pay.merPrtclNo", properties.getMerPrtclNo());
+ validateConfig("icbc.pay.subOrderSplitUrl", properties.getSubOrderSplitUrl());
+ if (req == null) {
+ throw new IllegalArgumentException("工行分账请求不能为空");
+ }
CardbusinessAggregatepayB2cOnlineSersuborderRequestV1 request = new CardbusinessAggregatepayB2cOnlineSersuborderRequestV1();
request.setServiceUrl(properties.getSubOrderSplitUrl());
request.setBizContent(buildSubOrderSplitBiz(req));
@@ -161,24 +188,33 @@
resp.setStatus(response.isSuccess() ? "SUCCESS" : "FAIL");
resp.setRawResponse(JSON.toJSONString(response));
fillSplitOutput(resp, response.getOrderSubmitActOutput());
+ flowRecorder.record("SPLIT", req.getChargingOrderCode(), null, req.getClassifyAmt(), request.getBizContent(), response,
+ resp.getStatus(), null);
} catch (Exception e) {
- log.error("工行小订单拆分失败,订单号:{}", req.getChargingOrderCode(), e);
+ log.error("工行小订单拆分失败,订单号:{}", req == null ? null : req.getChargingOrderCode(), e);
resp.setStatus("FAIL");
resp.setRawResponse(e.getMessage());
+ flowRecorder.record("SPLIT", req == null ? null : req.getChargingOrderCode(), null, req == null ? null : req.getClassifyAmt(),
+ req, null, "FAIL", e.getMessage());
}
return resp;
}
@Override
public IcbcSubOrderRefundResp refundSubOrder(IcbcSubOrderRefundReq req) {
- validateCommonConfig();
- validateConfig("icbc.pay.subOrderRefundUrl", properties.getSubOrderRefundUrl());
IcbcSubOrderRefundResp resp = new IcbcSubOrderRefundResp();
- resp.setChargingOrderCode(req.getChargingOrderCode());
- resp.setSubOrderId(req.getSubOrderId());
- resp.setRetSubOrderId(req.getRetSubOrderId());
- resp.setOuttrxSerialNo(req.getOuttrxSerialNo());
+ if (req != null) {
+ resp.setChargingOrderCode(req.getChargingOrderCode());
+ resp.setSubOrderId(req.getSubOrderId());
+ resp.setRetSubOrderId(req.getRetSubOrderId());
+ resp.setOuttrxSerialNo(req.getOuttrxSerialNo());
+ }
try {
+ validateCommonConfig();
+ validateConfig("icbc.pay.subOrderRefundUrl", properties.getSubOrderRefundUrl());
+ if (req == null) {
+ throw new IllegalArgumentException("工行分账退款请求不能为空");
+ }
CardbusinessAggregatepayB2cOnlineSersuborderrefundRequestV1 request = new CardbusinessAggregatepayB2cOnlineSersuborderrefundRequestV1();
request.setServiceUrl(properties.getSubOrderRefundUrl());
request.setBizContent(buildSubOrderRefundBiz(req));
@@ -189,20 +225,29 @@
resp.setSubOrderSeq(response.getSub_order_seq());
resp.setSubOrderTrxid(response.getSub_order_trxid());
resp.setRefundAmount(toFen(req.getRefundAmount()));
+ flowRecorder.record("SPLIT_REFUND", req.getChargingOrderCode(), null, req.getRefundAmount(), request.getBizContent(), response,
+ resp.getStatus(), null);
} catch (Exception e) {
- log.error("工行小订单拆分退款失败,订单号:{},子订单号:{},退款流水:{}", req.getChargingOrderCode(), req.getSubOrderId(), req.getOuttrxSerialNo(), e);
+ log.error("工行小订单拆分退款失败,订单号:{},子订单号:{},退款流水:{}",
+ req == null ? null : req.getChargingOrderCode(), req == null ? null : req.getSubOrderId(),
+ req == null ? null : req.getOuttrxSerialNo(), e);
resp.setStatus("FAIL");
resp.setRawResponse(e.getMessage());
+ flowRecorder.record("SPLIT_REFUND", req == null ? null : req.getChargingOrderCode(), null,
+ req == null ? null : req.getRefundAmount(), req, null, "FAIL", e.getMessage());
}
return resp;
}
@Override
public IcbcSubOrderQueryResp querySubOrder(IcbcSubOrderQueryReq req) {
- validateCommonConfig();
- validateConfig("icbc.pay.subOrderQueryUrl", properties.getSubOrderQueryUrl());
IcbcSubOrderQueryResp resp = new IcbcSubOrderQueryResp();
try {
+ validateCommonConfig();
+ validateConfig("icbc.pay.subOrderQueryUrl", properties.getSubOrderQueryUrl());
+ if (req == null) {
+ throw new IllegalArgumentException("工行子订单查询请求不能为空");
+ }
CardbusinessOrderSuborderqueryRequestV1 request = new CardbusinessOrderSuborderqueryRequestV1();
request.setServiceUrl(properties.getSubOrderQueryUrl());
request.setBizContent(buildSubOrderQueryBiz(req));
@@ -215,13 +260,18 @@
resp.setSubOrderNo(response.getSubOrderNo());
resp.setSubMerOrderId(response.getSubMerOrderId());
resp.setOrderAmnt(response.getOrderAmnt());
+ resp.setOrderRefundAmnt(response.getOrderRefundAmnt());
resp.setPayDate(response.getPayDate());
resp.setStatus(response.isSuccess() ? "SUCCESS" : "FAIL");
resp.setRawResponse(JSON.toJSONString(response));
+ flowRecorder.record("SPLIT_QUERY", firstNotBlank(req.getMerOrderId(), req.getOrderId(), req.getSubOrderNo()), null, null, request.getBizContent(), response,
+ resp.getStatus(), null);
} catch (Exception e) {
- log.error("工行子订单查询失败,子订单号:{}", req.getSubOrderNo(), e);
+ log.error("工行子订单查询失败,子订单号:{}", req == null ? null : req.getSubOrderNo(), e);
resp.setStatus("FAIL");
resp.setRawResponse(e.getMessage());
+ flowRecorder.record("SPLIT_QUERY", req == null ? null : firstNotBlank(req.getMerOrderId(), req.getOrderId(), req.getSubOrderNo()), null, null,
+ req, null, "FAIL", e.getMessage());
}
return resp;
}
@@ -233,28 +283,29 @@
String[] values = entry.getValue();
params.put(entry.getKey(), values == null || values.length == 0 ? "" : String.join(",", values));
}
- if (!verifyNotify(params)) {
- throw new IllegalArgumentException("工行回调验签失败");
+ try {
+ if (!verifyNotify(params)) {
+ throw new IllegalArgumentException("工行回调验签失败");
+ }
+ mergeBizContent(params);
+ Map<String, String> result = IcbcNotifyParser.parseVerified(params);
+ flowRecorder.record("PAY_NOTIFY", result.get("out_trade_no"), parseInteger(result.get("attach")), null,
+ params, result, "SUCCESS", null);
+ return result;
+ } catch (RuntimeException e) {
+ flowRecorder.record("PAY_NOTIFY", firstNotBlank(params.get("out_trade_no"), params.get("outTradeNo")),
+ parseInteger(firstNotBlank(params.get("attach"), params.get("paymentType"))), null,
+ params, null, "FAIL", e.getMessage());
+ throw e;
}
- mergeBizContent(params);
- String status = firstNotBlank(params.get("return_code"), params.get("orderStatus"), params.get("pay_status"), params.get("status"));
- if (StringUtils.isNotEmpty(status) && !isPaidStatus(status)) {
- throw new IllegalArgumentException("工行回调支付状态非成功:" + status);
+ }
+
+ private Integer parseInteger(String value) {
+ try {
+ return StringUtils.isEmpty(value) ? null : Integer.valueOf(value);
+ } catch (NumberFormatException ignored) {
+ return null;
}
- String outTradeNo = firstNotBlank(params.get("outTradeNo"), params.get("out_trade_no"), params.get("order_id"));
- if (StringUtils.isEmpty(outTradeNo)) {
- throw new IllegalArgumentException("工行回调缺少订单号");
- }
- String tradeNo = firstNotBlank(params.get("tradeNo"), params.get("trade_no"), params.get("msg_id"));
- String attach = firstNotBlank(params.get("attach"), params.get("paymentType"), params.get("payType"));
- if (StringUtils.isEmpty(attach)) {
- throw new IllegalArgumentException("工行回调缺少支付类型");
- }
- Map<String, String> result = new HashMap<>();
- result.put("out_trade_no", outTradeNo);
- result.put("transaction_id", tradeNo);
- result.put("attach", attach);
- return result;
}
private DefaultIcbcClient buildClient() {
@@ -442,7 +493,7 @@
CardbusinessOrderSuborderqueryRequestV1.CardbusinessOrderSuborderqueryRequestV1Biz biz =
new CardbusinessOrderSuborderqueryRequestV1.CardbusinessOrderSuborderqueryRequestV1Biz();
biz.setIcbcAppid(properties.getAppId());
- biz.setSerNo(req.getSerNo());
+ biz.setSerNo(firstNotBlank(req.getSerNo(), properties.getServiceMerId(), properties.getMerId()));
biz.setOrderNoType(req.getOrderNoType());
biz.setOrderId(req.getOrderId());
biz.setMerOrderId(req.getMerOrderId());
diff --git a/ruoyi-service/ruoyi-payment/src/main/java/com/ruoyi/payment/mapper/IcbcPaymentFlowRecordMapper.java b/ruoyi-service/ruoyi-payment/src/main/java/com/ruoyi/payment/mapper/IcbcPaymentFlowRecordMapper.java
new file mode 100644
index 0000000..438a9ee
--- /dev/null
+++ b/ruoyi-service/ruoyi-payment/src/main/java/com/ruoyi/payment/mapper/IcbcPaymentFlowRecordMapper.java
@@ -0,0 +1,7 @@
+package com.ruoyi.payment.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.payment.domain.IcbcPaymentFlowRecord;
+
+public interface IcbcPaymentFlowRecordMapper extends BaseMapper<IcbcPaymentFlowRecord> {
+}
diff --git a/ruoyi-service/ruoyi-payment/src/test/java/com/ruoyi/payment/icbc/service/impl/IcbcNotifyParserTest.java b/ruoyi-service/ruoyi-payment/src/test/java/com/ruoyi/payment/icbc/service/impl/IcbcNotifyParserTest.java
new file mode 100644
index 0000000..6de3884
--- /dev/null
+++ b/ruoyi-service/ruoyi-payment/src/test/java/com/ruoyi/payment/icbc/service/impl/IcbcNotifyParserTest.java
@@ -0,0 +1,27 @@
+package com.ruoyi.payment.icbc.service.impl;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class IcbcNotifyParserTest {
+
+ @Test
+ void usesIcbcOrderIdAsRefundTransactionId() {
+ Map<String, String> params = new HashMap<>();
+ params.put("out_trade_no", "CD202607110001");
+ params.put("order_id", "231011990056000522607080001058");
+ params.put("msg_id", "050200126189883020726302842");
+ params.put("attach", "1");
+ params.put("return_code", "0");
+
+ Map<String, String> result = IcbcNotifyParser.parseVerified(params);
+
+ assertEquals("CD202607110001", result.get("out_trade_no"));
+ assertEquals("231011990056000522607080001058", result.get("transaction_id"));
+ assertEquals("1", result.get("attach"));
+ }
+}
diff --git a/sql/20260711_icbc_payment_flow_record.sql b/sql/20260711_icbc_payment_flow_record.sql
new file mode 100644
index 0000000..86c1cba
--- /dev/null
+++ b/sql/20260711_icbc_payment_flow_record.sql
@@ -0,0 +1,22 @@
+CREATE TABLE IF NOT EXISTS `t_icbc_payment_flow_record` (
+ `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
+ `out_trade_no` varchar(64) DEFAULT NULL COMMENT '业务订单号',
+ `business_type` varchar(32) DEFAULT NULL COMMENT '业务类型',
+ `event_type` varchar(32) NOT NULL COMMENT '事件类型',
+ `payment_type` int DEFAULT NULL COMMENT '支付类型',
+ `amount` decimal(12,2) DEFAULT NULL COMMENT '金额(元)',
+ `icbc_order_id` varchar(64) DEFAULT NULL COMMENT '工行订单号',
+ `icbc_msg_id` varchar(64) DEFAULT NULL COMMENT '工行消息号',
+ `third_trade_no` varchar(64) DEFAULT NULL COMMENT '第三方支付流水号',
+ `status` varchar(32) DEFAULT NULL COMMENT '处理状态',
+ `request_json` longtext COMMENT '请求报文',
+ `response_json` longtext COMMENT '响应报文',
+ `error_message` varchar(1000) DEFAULT NULL COMMENT '异常信息',
+ `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+ `del_flag` tinyint NOT NULL DEFAULT 0 COMMENT '删除标记',
+ PRIMARY KEY (`id`),
+ KEY `idx_icbc_flow_out_trade_no` (`out_trade_no`),
+ KEY `idx_icbc_flow_order_id` (`icbc_order_id`),
+ KEY `idx_icbc_flow_event_time` (`event_type`,`create_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='工行支付流程审计记录';
--
Gitblit v1.7.1