longz
2026-07-13 6fc77feef6337bc478423efad5de91fa520b4990
支付分账测试相关
17个文件已添加
9个文件已修改
1579 ■■■■■ 已修改文件
docs/superpowers/plans/2026-07-11-icbc-admin-fund-operations.md 66 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/superpowers/specs/2026-07-11-icbc-admin-fund-operations-design.md 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-api/ruoyi-api-order/src/main/java/com/ruoyi/order/api/vo/ChargingOrderListVO.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-api/ruoyi-api-payment/src/main/java/com/ruoyi/payment/api/factory/IcbcPaymentFallbackFactory.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-api/ruoyi-api-payment/src/main/java/com/ruoyi/payment/api/feignClient/IcbcPaymentClient.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-api/ruoyi-api-payment/src/main/java/com/ruoyi/payment/api/vo/IcbcSubOrderQueryResp.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/controller/TChargingOrderController.java 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/dto/ChargingSimulationReq.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/dto/IcbcAdminFundOperationReq.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/ChargingOrderSettlementExecutor.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/TChargingOrderService.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/ChargingSimulationSupport.java 127 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/IcbcAdminFundOperationSupport.java 148 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/TChargingOrderServiceImpl.java 417 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/vo/ChargingSimulationVO.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/vo/IcbcAdminSubOrderVO.java 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/resources/mapper/order/TChargingOrderMapper.xml 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/ChargingSimulationSupportTest.java 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/IcbcAdminFundOperationSupportTest.java 159 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-payment/src/main/java/com/ruoyi/payment/domain/IcbcPaymentFlowRecord.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-payment/src/main/java/com/ruoyi/payment/icbc/service/IcbcPaymentFlowRecorder.java 92 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-payment/src/main/java/com/ruoyi/payment/icbc/service/impl/IcbcNotifyParser.java 54 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-payment/src/main/java/com/ruoyi/payment/icbc/service/impl/IcbcPayServiceImpl.java 121 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-payment/src/main/java/com/ruoyi/payment/mapper/IcbcPaymentFlowRecordMapper.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-payment/src/test/java/com/ruoyi/payment/icbc/service/impl/IcbcNotifyParserTest.java 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/20260711_icbc_payment_flow_record.sql 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/superpowers/plans/2026-07-11-icbc-admin-fund-operations.md
New file
@@ -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.
docs/superpowers/specs/2026-07-11-icbc-admin-fund-operations-design.md
New file
@@ -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、未支付和非法金额测试。
- 后端模块编译及管理端构建/类型检查。
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 = "白名单用户退款金额")
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());
            }
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")
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;
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;
@@ -763,6 +768,48 @@
        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));
    }
    /**
     * 充电充值支付回调
     */
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/dto/ChargingSimulationReq.java
New file
@@ -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;
}
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/dto/IcbcAdminFundOperationReq.java
New file
@@ -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;
}
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/ChargingOrderSettlementExecutor.java
New file
@@ -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);
    }
}
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>
@@ -67,6 +72,18 @@
     */
    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);
    
    /**
     * 支付成功后的回调处理逻辑
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/ChargingSimulationSupport.java
New file
@@ -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;
        }
    }
}
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/service/impl/IcbcAdminFundOperationSupport.java
New file
@@ -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();
    }
}
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 @@
        }
        //会员优惠折扣将其计入增加充电时长(增加总充电金额)
        //如果充电总金额未使用完,则需要退回费用=(原金额/总金额)*(总金额-实际充电金额)
        if (Integer.valueOf(1).equals(addChargingOrder.getPaymentType())) {
        IcbcPaymentReq req = new IcbcPaymentReq();
        req.setOutTradeNo(chargingOrder.getCode());
        req.setAmount(thirdPayAmount);
        req.setSubject("充电充值");
        req.setBody("充电充值");
        req.setPaymentType(addChargingOrder.getPaymentType());
            req.setPaymentType(1);
        req.setNotifyUrl("/payment/icbc/callBack");
        req.setReturnUrl(addChargingOrder.getReturnUrl());
        req.setOpenId(Integer.valueOf(1).equals(addChargingOrder.getPaymentType()) ? appUser.getWxOpenid() : appUser.getAliOpenid());
            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("调起工行支付失败");
        }
        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 {
            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) {
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/vo/ChargingSimulationVO.java
New file
@@ -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;
}
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/vo/IcbcAdminSubOrderVO.java
New file
@@ -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();
        }
    }
}
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,
ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/ChargingSimulationSupportTest.java
New file
@@ -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;
    }
}
ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/service/impl/IcbcAdminFundOperationSupportTest.java
New file
@@ -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;
    }
}
ruoyi-service/ruoyi-payment/src/main/java/com/ruoyi/payment/domain/IcbcPaymentFlowRecord.java
New file
@@ -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;
}
ruoyi-service/ruoyi-payment/src/main/java/com/ruoyi/payment/icbc/service/IcbcPaymentFlowRecorder.java
New file
@@ -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);
    }
}
ruoyi-service/ruoyi-payment/src/main/java/com/ruoyi/payment/icbc/service/impl/IcbcNotifyParser.java
New file
@@ -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;
    }
}
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();
        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) {
        IcbcSubOrderSplitResp resp = new IcbcSubOrderSplitResp();
        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());
        IcbcSubOrderSplitResp resp = new IcbcSubOrderSplitResp();
        resp.setChargingOrderCode(req.getChargingOrderCode());
        resp.setSubOrderId(req.getSubOrderId());
        try {
            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();
        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));
        }
        try {
        if (!verifyNotify(params)) {
            throw new IllegalArgumentException("工行回调验签失败");
        }
        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);
        }
        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);
            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;
        }
    }
    private Integer parseInteger(String value) {
        try {
            return StringUtils.isEmpty(value) ? null : Integer.valueOf(value);
        } catch (NumberFormatException ignored) {
            return null;
        }
    }
    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());
ruoyi-service/ruoyi-payment/src/main/java/com/ruoyi/payment/mapper/IcbcPaymentFlowRecordMapper.java
New file
@@ -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> {
}
ruoyi-service/ruoyi-payment/src/test/java/com/ruoyi/payment/icbc/service/impl/IcbcNotifyParserTest.java
New file
@@ -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"));
    }
}
sql/20260711_icbc_payment_flow_record.sql
New file
@@ -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='工行支付流程审计记录';