| New file |
| | |
| | | # 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. |
| New file |
| | |
| | | # 工行管理后台资金操作设计 |
| | | |
| | | ## 目标 |
| | | |
| | | 在“订单管理/充电桩订单列表”为正常扫码支付生成的 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、未支付和非法金额测试。 |
| | | - 后端模块编译及管理端构建/类型检查。 |
| | |
| | | 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 = "白名单用户退款金额") |
| | |
| | | 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; |
| | |
| | | } |
| | | |
| | | @Override |
| | | public R<IcbcSubOrderRefundResp> refundSubOrder(IcbcSubOrderRefundReq req) { |
| | | return R.fail("工行小订单拆分退款失败:" + throwable.getMessage()); |
| | | } |
| | | |
| | | @Override |
| | | public R<IcbcSubOrderQueryResp> querySubOrder(IcbcSubOrderQueryReq req) { |
| | | return R.fail("查询工行子订单失败:" + throwable.getMessage()); |
| | | } |
| | |
| | | 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; |
| | |
| | | R<IcbcSubOrderSplitResp> splitSubOrder(@RequestBody IcbcSubOrderSplitReq req); |
| | | |
| | | /** |
| | | * 工行小订单拆分退款。 |
| | | */ |
| | | @PostMapping("/icbc/sub-order/refund") |
| | | R<IcbcSubOrderRefundResp> refundSubOrder(@RequestBody IcbcSubOrderRefundReq req); |
| | | |
| | | /** |
| | | * 查询工行子订单。 |
| | | */ |
| | | @PostMapping("/icbc/sub-order/query") |
| | |
| | | private String subOrderNo; |
| | | private String subMerOrderId; |
| | | private String orderAmnt; |
| | | private String orderRefundAmnt; |
| | | private String payDate; |
| | | private String status; |
| | | private String rawResponse; |
| | |
| | | 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; |
| | |
| | | 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; |
| | |
| | | public AjaxResult paymentChargingOrder(@RequestBody AddChargingOrder addChargingOrder) { |
| | | return chargingOrderService.paymentChargingOrder(addChargingOrder); |
| | | } |
| | | |
| | | @RequiresPermissions(value = {"/chargingPileOrder", "/paymentOrder/refund"}, logical = Logical.AND) |
| | | @PostMapping("/icbc/fund/payment-query") |
| | | @ApiOperation(value = "管理后台查询工行支付订单", tags = {"后台-充电桩订单"}) |
| | | public AjaxResult<IcbcPaymentResp> queryIcbcAdminPayment(@RequestBody IcbcAdminFundOperationReq request) { |
| | | return AjaxResult.success(chargingOrderService.queryIcbcAdminPayment(request == null ? null : request.getOrderCode())); |
| | | } |
| | | |
| | | @RequiresPermissions(value = {"/chargingPileOrder", "/paymentOrder/refund"}, logical = Logical.AND) |
| | | @PostMapping("/icbc/fund/refund") |
| | | @ApiOperation(value = "管理后台工行主订单退款", tags = {"后台-充电桩订单"}) |
| | | public AjaxResult<IcbcRefundResp> refundIcbcAdminPayment(@RequestBody IcbcAdminFundOperationReq request) { |
| | | return AjaxResult.success(chargingOrderService.refundIcbcAdminPayment(request)); |
| | | } |
| | | |
| | | @RequiresPermissions(value = {"/chargingPileOrder", "/paymentOrder/refund"}, logical = Logical.AND) |
| | | @PostMapping("/icbc/fund/sub-orders") |
| | | @ApiOperation(value = "管理后台查询工行分账子订单", tags = {"后台-充电桩订单"}) |
| | | public AjaxResult<List<IcbcAdminSubOrderVO>> queryIcbcAdminSubOrders(@RequestBody IcbcAdminFundOperationReq request) { |
| | | return AjaxResult.success(chargingOrderService.queryIcbcAdminSubOrders(request == null ? null : request.getOrderCode())); |
| | | } |
| | | |
| | | @RequiresPermissions(value = {"/chargingPileOrder", "/paymentOrder/refund"}, logical = Logical.AND) |
| | | @PostMapping("/icbc/fund/sub-order-refund") |
| | | @ApiOperation(value = "管理后台工行分账子订单退款", tags = {"后台-充电桩订单"}) |
| | | public AjaxResult<IcbcSubOrderRefundResp> refundIcbcAdminSubOrder(@RequestBody IcbcAdminFundOperationReq request) { |
| | | return AjaxResult.success(chargingOrderService.refundIcbcAdminSubOrder(request)); |
| | | } |
| | | |
| | | @ResponseBody |
| | | @GetMapping("/simulation/current") |
| | | @ApiOperation(value = "查询当前枪可模拟结算的工行订单", tags = {"小程序-模拟充电"}) |
| | | public AjaxResult<ChargingSimulationVO> getCurrentSimulationOrder(@RequestParam("gunId") Integer gunId) { |
| | | return AjaxResult.success(chargingOrderService.getCurrentSimulationOrder(gunId)); |
| | | } |
| | | |
| | | @ResponseBody |
| | | @PostMapping("/simulation/complete") |
| | | @ApiOperation(value = "模拟完成充电并执行真实结算", tags = {"小程序-模拟充电"}) |
| | | public AjaxResult<ChargingSimulationVO> completeSimulation(@RequestBody ChargingSimulationReq request) { |
| | | return AjaxResult.success(chargingOrderService.completeSimulation(request)); |
| | | } |
| | | |
| | | /** |
| | | * 充电充值支付回调 |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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); |
| | | } |
| | | } |
| | |
| | | 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; |
| | |
| | | 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> |
| | |
| | | * @return |
| | | */ |
| | | AjaxResult paymentChargingOrder(AddChargingOrder addChargingOrder); |
| | | |
| | | ChargingSimulationVO getCurrentSimulationOrder(Integer gunId); |
| | | |
| | | ChargingSimulationVO completeSimulation(ChargingSimulationReq request); |
| | | |
| | | IcbcPaymentResp queryIcbcAdminPayment(String orderCode); |
| | | |
| | | IcbcRefundResp refundIcbcAdminPayment(IcbcAdminFundOperationReq request); |
| | | |
| | | List<IcbcAdminSubOrderVO> queryIcbcAdminSubOrders(String orderCode); |
| | | |
| | | IcbcSubOrderRefundResp refundIcbcAdminSubOrder(IcbcAdminFundOperationReq request); |
| | | |
| | | |
| | | /** |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | 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(); |
| | | } |
| | | } |
| | |
| | | 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.*; |
| | |
| | | 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.*; |
| | |
| | | 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; |
| | |
| | | private IcbcPaymentClient icbcPaymentClient; |
| | | @Resource |
| | | private TChargingOrderShareRecordMapper chargingOrderShareRecordMapper; |
| | | @Resource |
| | | private ChargingOrderSettlementExecutor chargingOrderSettlementExecutor; |
| | | |
| | | @Resource |
| | | private AppCouponClient appCouponClient; |
| | |
| | | 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() + "号枪"); |
| | |
| | | } |
| | | //会员优惠折扣将其计入增加充电时长(增加总充电金额) |
| | | //如果充电总金额未使用完,则需要退回费用=(原金额/总金额)*(总金额-实际充电金额) |
| | | IcbcPaymentReq req = new IcbcPaymentReq(); |
| | | req.setOutTradeNo(chargingOrder.getCode()); |
| | | req.setAmount(thirdPayAmount); |
| | | req.setSubject("充电充值"); |
| | | req.setBody("充电充值"); |
| | | req.setPaymentType(addChargingOrder.getPaymentType()); |
| | | req.setNotifyUrl("/payment/icbc/callBack"); |
| | | req.setReturnUrl(addChargingOrder.getReturnUrl()); |
| | | req.setOpenId(Integer.valueOf(1).equals(addChargingOrder.getPaymentType()) ? appUser.getWxOpenid() : appUser.getAliOpenid()); |
| | | req.setProfitSharing(buildIcbcProfitSharing(site)); |
| | | IcbcPaymentResp data = icbcPaymentClient.payment(req).getData(); |
| | | if (isIcbcPaymentCreated(data)) { |
| | | return AjaxResult.success(data); |
| | | if (Integer.valueOf(1).equals(addChargingOrder.getPaymentType())) { |
| | | IcbcPaymentReq req = new IcbcPaymentReq(); |
| | | req.setOutTradeNo(chargingOrder.getCode()); |
| | | req.setAmount(thirdPayAmount); |
| | | req.setSubject("充电充值"); |
| | | req.setBody("充电充值"); |
| | | req.setPaymentType(1); |
| | | req.setNotifyUrl("/payment/icbc/callBack"); |
| | | req.setReturnUrl(addChargingOrder.getReturnUrl()); |
| | | req.setOpenId(appUser.getWxOpenid()); |
| | | req.setProfitSharing(buildIcbcProfitSharing(site)); |
| | | IcbcPaymentResp data = icbcPaymentClient.payment(req).getData(); |
| | | if (isIcbcPaymentCreated(data)) { |
| | | if (StringUtils.hasLength(data.getTradeNo())) { |
| | | chargingOrder.setRechargeSerialNumber(data.getTradeNo()); |
| | | this.updateById(chargingOrder); |
| | | } |
| | | return AjaxResult.success(data); |
| | | } |
| | | throw new RuntimeException("调起工行支付失败"); |
| | | } |
| | | throw new RuntimeException("调起工行支付失败"); |
| | | if (Integer.valueOf(2).equals(addChargingOrder.getPaymentType())) { |
| | | AliPaymentReq req = new AliPaymentReq(); |
| | | req.setOutTradeNo(chargingOrder.getCode()); |
| | | req.setTotalAmount(thirdPayAmount.toString()); |
| | | req.setSubject("充电充值"); |
| | | req.setBuyerOpenId(appUser.getAliOpenid()); |
| | | req.setBody("充电充值"); |
| | | req.setNotifyUrl("/payment/ali/callBack"); |
| | | AliPaymentResp data = aliPaymentClient.payment(req).getData(); |
| | | if (data != null) { |
| | | return AjaxResult.success(data); |
| | | } |
| | | throw new RuntimeException("调起支付宝支付失败"); |
| | | } |
| | | if (Integer.valueOf(3).equals(addChargingOrder.getPaymentType())) { |
| | | AliPaymentReq req = new AliPaymentReq(); |
| | | req.setOutTradeNo(chargingOrder.getCode()); |
| | | req.setTotalAmount(thirdPayAmount.toString()); |
| | | req.setSubject("充电充值"); |
| | | req.setBuyerOpenId(appUser.getAliOpenid()); |
| | | req.setBody("充电充值"); |
| | | req.setNotifyUrl("/payment/aliH5/callBack"); |
| | | req.setReturnUrl(addChargingOrder.getReturnUrl()); |
| | | String data = h5AliPaymentClient.payment(req).getData(); |
| | | if (StringUtils.hasLength(data)) { |
| | | return AjaxResult.success("操作成功", data); |
| | | } |
| | | } |
| | | throw new RuntimeException("无效的支付方式"); |
| | | } |
| | | |
| | | @Override |
| | | public ChargingSimulationVO getCurrentSimulationOrder(Integer gunId) { |
| | | if (gunId == null) { |
| | | throw new ServiceException("充电枪不能为空"); |
| | | } |
| | | Long userId = tokenService.getLoginUserApplet().getUserId(); |
| | | return buildSimulationVO(findSimulationOrder(userId, gunId)); |
| | | } |
| | | |
| | | @Override |
| | | public ChargingSimulationVO completeSimulation(ChargingSimulationReq request) { |
| | | if (request == null || request.getGunId() == null) { |
| | | throw new ServiceException("充电枪不能为空"); |
| | | } |
| | | Long userId = tokenService.getLoginUserApplet().getUserId(); |
| | | TChargingOrder order = findSimulationOrder(userId, request.getGunId()); |
| | | List<UploadRealTimeMonitoringData> monitoringData = uploadRealTimeMonitoringDataService.getDataByOrderCode(order.getCode()); |
| | | if (monitoringData != null && !monitoringData.isEmpty()) { |
| | | throw new ServiceException("订单已存在真实充电监控数据,禁止模拟结算"); |
| | | } |
| | | BigDecimal electricityFee = money(request.getElectricityFee()); |
| | | BigDecimal serviceFee = money(request.getServiceFee()); |
| | | BigDecimal simulationTotal = electricityFee.add(serviceFee).setScale(2, RoundingMode.HALF_UP); |
| | | BigDecimal availableAmount = money(order.getChargeAmount()).compareTo(BigDecimal.ZERO) > 0 |
| | | ? money(order.getChargeAmount()) : money(order.getRechargeAmount()); |
| | | if (simulationTotal.compareTo(availableAmount) > 0) { |
| | | throw new ServiceException("模拟消费金额不能超过订单可消费金额" + availableAmount.toPlainString() + "元"); |
| | | } |
| | | |
| | | List<AccountingStrategyDetailOrder> tariffs = accountingStrategyDetailOrderService.list( |
| | | new LambdaQueryWrapper<AccountingStrategyDetailOrder>() |
| | | .eq(AccountingStrategyDetailOrder::getChargingOrderId, order.getId()) |
| | | .orderByAsc(AccountingStrategyDetailOrder::getStartTime)); |
| | | ChargingSimulationSupport.PreparedSimulation prepared; |
| | | try { |
| | | prepared = ChargingSimulationSupport.prepare(order.getCode(), tariffs, electricityFee, serviceFee, LocalDateTime.now()); |
| | | } catch (IllegalArgumentException e) { |
| | | throw new ServiceException(e.getMessage()); |
| | | } |
| | | accountingStrategyDetailOrderService.updateById(prepared.getTariff()); |
| | | |
| | | TChargingGun gun = chargingGunClient.getChargingGunById(order.getChargingGunId()).getData(); |
| | | TChargingPile pile = chargingPileClient.getChargingPileById(order.getChargingPileId()).getData(); |
| | | TransactionRecordMessageVO record = prepared.getRecord(); |
| | | record.setCharging_gun_code(gun == null ? null : gun.getCode()); |
| | | record.setCharging_pile_code(pile == null ? null : pile.getCode()); |
| | | if (Integer.valueOf(2).equals(order.getStatus())) { |
| | | TChargingOrder charging = new TChargingOrder(); |
| | | charging.setId(order.getId()); |
| | | charging.setStatus(3); |
| | | charging.setStartTime(LocalDateTime.now().minusSeconds(1)); |
| | | this.updateById(charging); |
| | | } |
| | | R settlement = chargingOrderSettlementExecutor.settle(record); |
| | | if (R.isError(settlement)) { |
| | | throw new ServiceException(StringUtils.hasLength(settlement.getMsg()) ? settlement.getMsg() : "模拟结算失败"); |
| | | } |
| | | return buildSimulationVO(this.getById(order.getId())); |
| | | } |
| | | |
| | | private TChargingOrder findSimulationOrder(Long userId, Integer gunId) { |
| | | TChargingOrder order = this.getOne(new LambdaQueryWrapper<TChargingOrder>() |
| | | .eq(TChargingOrder::getAppUserId, userId) |
| | | .eq(TChargingOrder::getChargingGunId, gunId) |
| | | .eq(TChargingOrder::getSourceName, "ICBC") |
| | | .eq(TChargingOrder::getRechargePaymentStatus, 2) |
| | | .in(TChargingOrder::getStatus, Arrays.asList(2, 3, 4)) |
| | | .eq(TChargingOrder::getDelFlag, 0) |
| | | .isNull(TChargingOrder::getRefundStatus) |
| | | .orderByDesc(TChargingOrder::getId) |
| | | .last("limit 1")); |
| | | if (order == null) { |
| | | throw new ServiceException("当前枪没有属于你的可模拟ICBC已支付订单"); |
| | | } |
| | | return order; |
| | | } |
| | | |
| | | private ChargingSimulationVO buildSimulationVO(TChargingOrder order) { |
| | | ChargingSimulationVO result = new ChargingSimulationVO(); |
| | | result.setOrderId(order.getId()); |
| | | result.setOrderCode(order.getCode()); |
| | | result.setOrderStatus(order.getStatus()); |
| | | result.setRechargeAmount(order.getRechargeAmount()); |
| | | result.setChargeAmount(order.getChargeAmount()); |
| | | result.setElectricityFee(order.getElectrovalence()); |
| | | result.setServiceFee(order.getServiceCharge()); |
| | | result.setPaymentAmount(order.getPaymentAmount()); |
| | | result.setRefundAmount(order.getRefundAmount()); |
| | | result.setRefundStatus(order.getRefundStatus()); |
| | | result.setIcbcOrderId(order.getRechargeSerialNumber()); |
| | | TChargingOrderShareRecord shareRecord = chargingOrderShareRecordMapper.selectOne( |
| | | new LambdaQueryWrapper<TChargingOrderShareRecord>() |
| | | .eq(TChargingOrderShareRecord::getChargingOrderId, order.getId()) |
| | | .eq(TChargingOrderShareRecord::getPayChannel, "ICBC") |
| | | .eq(TChargingOrderShareRecord::getDelFlag, false) |
| | | .orderByDesc(TChargingOrderShareRecord::getId) |
| | | .last("limit 1")); |
| | | if (shareRecord != null) { |
| | | result.setShareAmount(shareRecord.getShareAmount()); |
| | | result.setShareStatus(shareRecord.getOnlineShareStatus()); |
| | | result.setSubOrderId(shareRecord.getSubOrderId()); |
| | | result.setSubOrderSeq(shareRecord.getSubOrderSeq()); |
| | | result.setSubOrderTrxid(shareRecord.getSubOrderTrxid()); |
| | | } |
| | | return result; |
| | | } |
| | | |
| | | @Override |
| | | public IcbcPaymentResp queryIcbcAdminPayment(String orderCode) { |
| | | TChargingOrder order = loadIcbcAdminOrder(orderCode); |
| | | return requireFeignData(icbcPaymentClient.query(order.getCode()), "工行支付查询失败"); |
| | | } |
| | | |
| | | @Override |
| | | public IcbcRefundResp refundIcbcAdminPayment(IcbcAdminFundOperationReq request) { |
| | | if (request == null) { |
| | | throw new ServiceException("请求参数不能为空"); |
| | | } |
| | | TChargingOrder order = loadIcbcAdminOrder(request.getOrderCode()); |
| | | BigDecimal amount; |
| | | try { |
| | | amount = IcbcAdminFundOperationSupport.requireAmount(request.getAmount()); |
| | | } catch (IllegalArgumentException e) { |
| | | throw new ServiceException(e.getMessage()); |
| | | } |
| | | BigDecimal paidAmount = money(order.getThirdPayAmount()).compareTo(BigDecimal.ZERO) > 0 |
| | | ? money(order.getThirdPayAmount()) : money(order.getRechargeAmount()); |
| | | if (amount.compareTo(paidAmount) > 0) { |
| | | throw new ServiceException("退款金额不能超过订单第三方支付金额" + paidAmount.toPlainString() + "元"); |
| | | } |
| | | RLock lock = redissonClient.getLock("icbcAdminFund:order:" + order.getCode()); |
| | | if (!lock.tryLock()) { |
| | | throw new ServiceException("该订单正在执行工行资金操作,请勿重复提交"); |
| | | } |
| | | try { |
| | | IcbcPaymentResp payment = requireFeignData(icbcPaymentClient.query(order.getCode()), "工行支付查询失败"); |
| | | String orderId = extractIcbcOrderId(payment); |
| | | order.setRechargeSerialNumber(orderId); |
| | | RBucket<String> pendingRefund = redissonClient.getBucket("icbcAdminFund:pending:main:" + order.getCode()); |
| | | String generatedRefundNo = "ICBCADMINREFUND" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS")); |
| | | String pendingValue = pendingRefund.get(); |
| | | boolean reusingPending = StringUtils.hasLength(pendingValue); |
| | | String refundNo; |
| | | try { |
| | | refundNo = IcbcAdminFundOperationSupport.resolveRefundNo(pendingValue, amount, generatedRefundNo); |
| | | } catch (IllegalArgumentException e) { |
| | | throw new ServiceException(e.getMessage()); |
| | | } |
| | | IcbcRefundReq refundRequest = IcbcAdminFundOperationSupport.buildRefundRequest(order, amount, refundNo); |
| | | if (!reusingPending) { |
| | | pendingRefund.set(IcbcAdminFundOperationSupport.buildPendingRefundValue(refundNo, amount)); |
| | | } |
| | | IcbcRefundResp response = requireFeignData(icbcPaymentClient.refund(refundRequest), "工行退款调用失败"); |
| | | if (IcbcAdminFundOperationSupport.shouldClearPendingRefund( |
| | | reusingPending, response.getStatus(), isDefinitiveIcbcResponse(response.getRawResponse()))) { |
| | | pendingRefund.delete(); |
| | | } |
| | | return response; |
| | | } finally { |
| | | if (lock.isHeldByCurrentThread()) { |
| | | lock.unlock(); |
| | | } |
| | | } |
| | | } |
| | | |
| | | @Override |
| | | public List<IcbcAdminSubOrderVO> queryIcbcAdminSubOrders(String orderCode) { |
| | | TChargingOrder order = loadIcbcAdminOrder(orderCode); |
| | | List<TChargingOrderShareRecord> records = chargingOrderShareRecordMapper.selectList( |
| | | new LambdaQueryWrapper<TChargingOrderShareRecord>() |
| | | .eq(TChargingOrderShareRecord::getChargingOrderId, order.getId()) |
| | | .eq(TChargingOrderShareRecord::getPayChannel, "ICBC") |
| | | .eq(TChargingOrderShareRecord::getDelFlag, false) |
| | | .orderByAsc(TChargingOrderShareRecord::getId)); |
| | | List<IcbcAdminSubOrderVO> result = new ArrayList<>(); |
| | | for (TChargingOrderShareRecord record : records) { |
| | | IcbcAdminSubOrderVO item = buildIcbcAdminSubOrderVO(record); |
| | | try { |
| | | IcbcSubOrderQueryReq query = IcbcAdminFundOperationSupport.buildSubOrderQueryRequest(order, record); |
| | | item.applyQuery(requireFeignData(icbcPaymentClient.querySubOrder(query), "工行子订单查询失败")); |
| | | } catch (Exception e) { |
| | | item.setQueryStatus("FAIL"); |
| | | item.setErrorMessage(e.getMessage()); |
| | | } |
| | | result.add(item); |
| | | } |
| | | return result; |
| | | } |
| | | |
| | | @Override |
| | | public IcbcSubOrderRefundResp refundIcbcAdminSubOrder(IcbcAdminFundOperationReq request) { |
| | | if (request == null || request.getShareRecordId() == null) { |
| | | throw new ServiceException("请选择需要退款的分账子订单"); |
| | | } |
| | | TChargingOrder order = loadIcbcAdminOrder(request.getOrderCode()); |
| | | TChargingOrderShareRecord shareRecord = chargingOrderShareRecordMapper.selectById(request.getShareRecordId()); |
| | | if (shareRecord == null || !order.getId().equals(shareRecord.getChargingOrderId())) { |
| | | throw new ServiceException("分账记录不属于当前订单"); |
| | | } |
| | | BigDecimal amount; |
| | | try { |
| | | amount = IcbcAdminFundOperationSupport.requireAmount(request.getAmount()); |
| | | } catch (IllegalArgumentException e) { |
| | | throw new ServiceException(e.getMessage()); |
| | | } |
| | | if (shareRecord.getShareAmount() != null && amount.compareTo(shareRecord.getShareAmount()) > 0) { |
| | | throw new ServiceException("分账退款金额不能超过分账金额" + shareRecord.getShareAmount().toPlainString() + "元"); |
| | | } |
| | | RLock lock = redissonClient.getLock("icbcAdminFund:order:" + order.getCode()); |
| | | if (!lock.tryLock()) { |
| | | throw new ServiceException("该分账子订单正在退款,请勿重复提交"); |
| | | } |
| | | try { |
| | | String suffix = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS")); |
| | | RBucket<String> pendingRefund = redissonClient.getBucket("icbcAdminFund:pending:sub:" + shareRecord.getId()); |
| | | String generatedToken = "ICBCSUBREFUND" + suffix + ":ICBCSUBREFUNDNO" + suffix; |
| | | String pendingValue = pendingRefund.get(); |
| | | boolean reusingPending = StringUtils.hasLength(pendingValue); |
| | | String operationToken; |
| | | try { |
| | | operationToken = IcbcAdminFundOperationSupport.resolveRefundNo(pendingValue, amount, generatedToken); |
| | | } catch (IllegalArgumentException e) { |
| | | throw new ServiceException(e.getMessage()); |
| | | } |
| | | String[] refundNumbers = operationToken.split(":", 2); |
| | | if (refundNumbers.length != 2) { |
| | | throw new ServiceException("工行分账退款幂等记录格式错误"); |
| | | } |
| | | IcbcSubOrderRefundReq refundRequest; |
| | | try { |
| | | refundRequest = IcbcAdminFundOperationSupport.buildSubOrderRefundRequest( |
| | | order, shareRecord, amount, refundNumbers[0], refundNumbers[1]); |
| | | } catch (IllegalArgumentException e) { |
| | | throw new ServiceException(e.getMessage()); |
| | | } |
| | | if (!reusingPending) { |
| | | pendingRefund.set(IcbcAdminFundOperationSupport.buildPendingRefundValue(operationToken, amount)); |
| | | } |
| | | IcbcSubOrderRefundResp response = requireFeignData( |
| | | icbcPaymentClient.refundSubOrder(refundRequest), "工行分账退款调用失败"); |
| | | if (IcbcAdminFundOperationSupport.shouldClearPendingRefund( |
| | | reusingPending, response.getStatus(), isDefinitiveIcbcResponse(response.getRawResponse()))) { |
| | | pendingRefund.delete(); |
| | | } |
| | | return response; |
| | | } finally { |
| | | if (lock.isHeldByCurrentThread()) { |
| | | lock.unlock(); |
| | | } |
| | | } |
| | | } |
| | | |
| | | private TChargingOrder loadIcbcAdminOrder(String orderCode) { |
| | | if (!StringUtils.hasLength(orderCode)) { |
| | | throw new ServiceException("订单号不能为空"); |
| | | } |
| | | TChargingOrder order = this.getOne(new LambdaQueryWrapper<TChargingOrder>() |
| | | .eq(TChargingOrder::getCode, orderCode) |
| | | .eq(TChargingOrder::getDelFlag, 0) |
| | | .last("limit 1")); |
| | | try { |
| | | IcbcAdminFundOperationSupport.validateOrder(order); |
| | | } catch (IllegalArgumentException e) { |
| | | throw new ServiceException(e.getMessage()); |
| | | } |
| | | return order; |
| | | } |
| | | |
| | | private String extractIcbcOrderId(IcbcPaymentResp payment) { |
| | | try { |
| | | JSONObject response = JSON.parseObject(payment.getRawResponse()); |
| | | String orderId = response == null ? null : response.getString("order_id"); |
| | | if (StringUtils.hasLength(orderId)) { |
| | | return orderId; |
| | | } |
| | | } catch (Exception ignored) { |
| | | } |
| | | throw new ServiceException("工行支付查询未返回真实order_id,禁止退款"); |
| | | } |
| | | |
| | | private boolean isDefinitiveIcbcResponse(String rawResponse) { |
| | | if (!StringUtils.hasLength(rawResponse)) { |
| | | return false; |
| | | } |
| | | try { |
| | | JSONObject response = JSON.parseObject(rawResponse); |
| | | return response != null && (response.containsKey("returnCode") || response.containsKey("return_code")); |
| | | } catch (Exception ignored) { |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | private IcbcAdminSubOrderVO buildIcbcAdminSubOrderVO(TChargingOrderShareRecord record) { |
| | | IcbcAdminSubOrderVO item = new IcbcAdminSubOrderVO(); |
| | | item.setShareRecordId(record.getId()); |
| | | item.setOrderCode(record.getChargingOrderCode()); |
| | | item.setShareAmount(record.getShareAmount()); |
| | | item.setOnlineShareStatus(record.getOnlineShareStatus()); |
| | | item.setSubOrderId(record.getSubOrderId()); |
| | | item.setSubMerId(record.getSubMerId()); |
| | | item.setSubMerPrtclNo(record.getSubMerPrtclNo()); |
| | | item.setOriTrxDate(record.getOriTrxDate()); |
| | | item.setBankOrderId(record.getIcbcOrderId()); |
| | | item.setSubOrderSeq(record.getSubOrderSeq()); |
| | | item.setSubOrderTrxid(record.getSubOrderTrxid()); |
| | | return item; |
| | | } |
| | | |
| | | private <T> T requireFeignData(R<T> result, String message) { |
| | | if (result == null || R.isError(result) || result.getData() == null) { |
| | | throw new ServiceException(result != null && StringUtils.hasLength(result.getMsg()) ? result.getMsg() : message); |
| | | } |
| | | return result.getData(); |
| | | } |
| | | |
| | | |
| | |
| | | @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()) |
| | |
| | | chargingOrder.setRechargePaymentStatus(2); |
| | | chargingOrder.setRechargeSerialNumber(transaction_id); |
| | | chargingOrder.setStatus(2); |
| | | chargingOrder.setPayTime(LocalDateTime.now()); |
| | | chargingOrder.setPayTime(callbackPayTime); |
| | | |
| | | //添加安全检测数据到缓存中,每步安全检测完成后需要更新缓存数据 |
| | | PreChargeCheck preChargeCheck = new PreChargeCheck(); |
| | |
| | | @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()); |
| | |
| | | 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()); |
| | |
| | | chargingMessageClient.pushOrderStatus(chargingOrderVo); |
| | | } |
| | | } catch (Exception e) { |
| | | e.printStackTrace(); |
| | | log.error("充电订单结算失败,订单号:{}", vo == null ? null : vo.getTransaction_serial_number(), e); |
| | | return R.fail("充电订单结算失败:" + firstNotBlank(e.getMessage(), "未知异常")); |
| | | } finally { |
| | | lock.unlock(); |
| | | if (locked && lock.isHeldByCurrentThread()) { |
| | | lock.unlock(); |
| | | } |
| | | } |
| | | return R.ok(); |
| | | } |
| | |
| | | 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) { |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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(); |
| | | } |
| | | } |
| | | } |
| | |
| | | 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, |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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); |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| | |
| | | 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; |
| | |
| | | public class IcbcPayServiceImpl implements IcbcPayService { |
| | | @Resource |
| | | private IcbcPayProperties properties; |
| | | @Resource |
| | | private IcbcPaymentFlowRecorder flowRecorder; |
| | | |
| | | @Override |
| | | public IcbcPaymentResp payment(IcbcPaymentReq req) { |
| | |
| | | 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)); |
| | |
| | | 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; |
| | | } |
| | |
| | | |
| | | @Override |
| | | public IcbcRefundResp refund(IcbcRefundReq req) { |
| | | validateCommonConfig(); |
| | | validateConfig("icbc.pay.refundUrl", properties.getRefundUrl()); |
| | | IcbcRefundResp resp = new IcbcRefundResp(); |
| | | resp.setOutTradeNo(req.getOutTradeNo()); |
| | | resp.setOrderId(req.getOrderId()); |
| | | resp.setOutRefundNo(req.getOutRefundNo()); |
| | | if (req != null) { |
| | | resp.setOutTradeNo(req.getOutTradeNo()); |
| | | resp.setOrderId(req.getOrderId()); |
| | | resp.setOutRefundNo(req.getOutRefundNo()); |
| | | } |
| | | try { |
| | | validateCommonConfig(); |
| | | validateConfig("icbc.pay.refundUrl", properties.getRefundUrl()); |
| | | if (req == null) { |
| | | throw new IllegalArgumentException("工行退款请求不能为空"); |
| | | } |
| | | CardbusinessAggregatepayB2cOnlineMerrefundRequestV1 request = new CardbusinessAggregatepayB2cOnlineMerrefundRequestV1(); |
| | | request.setServiceUrl(properties.getRefundUrl()); |
| | | request.setBizContent(buildRefundBiz(req)); |
| | |
| | | resp.setRefundId(firstNotBlank(response.getIntrx_serial_no(), response.getOuttrxSerialNo())); |
| | | resp.setRefundTime(response.getRefund_time()); |
| | | resp.setRefundAmount(firstNotBlank(response.getRealRejectAmt(), response.getRejectAmt(), toFen(req.getRefundAmount()))); |
| | | flowRecorder.record("REFUND", req.getOutTradeNo(), null, req.getRefundAmount(), request.getBizContent(), response, |
| | | resp.getStatus(), null); |
| | | } catch (Exception e) { |
| | | log.error("工行退款失败,订单号:{},退款单号:{}", req.getOutTradeNo(), req.getOutRefundNo(), e); |
| | | log.error("工行退款失败,订单号:{},退款单号:{}", |
| | | req == null ? null : req.getOutTradeNo(), req == null ? null : req.getOutRefundNo(), e); |
| | | resp.setStatus("FAIL"); |
| | | resp.setRawResponse(e.getMessage()); |
| | | flowRecorder.record("REFUND", req == null ? null : req.getOutTradeNo(), null, req == null ? null : req.getRefundAmount(), |
| | | req, null, "FAIL", e.getMessage()); |
| | | } |
| | | return resp; |
| | | } |
| | | |
| | | @Override |
| | | public IcbcSubOrderSplitResp splitSubOrder(IcbcSubOrderSplitReq req) { |
| | | validateCommonConfig(); |
| | | validateConfig("icbc.pay.merPrtclNo", properties.getMerPrtclNo()); |
| | | validateConfig("icbc.pay.subOrderSplitUrl", properties.getSubOrderSplitUrl()); |
| | | IcbcSubOrderSplitResp resp = new IcbcSubOrderSplitResp(); |
| | | resp.setChargingOrderCode(req.getChargingOrderCode()); |
| | | resp.setSubOrderId(req.getSubOrderId()); |
| | | if (req != null) { |
| | | resp.setChargingOrderCode(req.getChargingOrderCode()); |
| | | resp.setSubOrderId(req.getSubOrderId()); |
| | | } |
| | | try { |
| | | validateCommonConfig(); |
| | | validateConfig("icbc.pay.merPrtclNo", properties.getMerPrtclNo()); |
| | | validateConfig("icbc.pay.subOrderSplitUrl", properties.getSubOrderSplitUrl()); |
| | | if (req == null) { |
| | | throw new IllegalArgumentException("工行分账请求不能为空"); |
| | | } |
| | | CardbusinessAggregatepayB2cOnlineSersuborderRequestV1 request = new CardbusinessAggregatepayB2cOnlineSersuborderRequestV1(); |
| | | request.setServiceUrl(properties.getSubOrderSplitUrl()); |
| | | request.setBizContent(buildSubOrderSplitBiz(req)); |
| | |
| | | resp.setStatus(response.isSuccess() ? "SUCCESS" : "FAIL"); |
| | | resp.setRawResponse(JSON.toJSONString(response)); |
| | | fillSplitOutput(resp, response.getOrderSubmitActOutput()); |
| | | flowRecorder.record("SPLIT", req.getChargingOrderCode(), null, req.getClassifyAmt(), request.getBizContent(), response, |
| | | resp.getStatus(), null); |
| | | } catch (Exception e) { |
| | | log.error("工行小订单拆分失败,订单号:{}", req.getChargingOrderCode(), e); |
| | | log.error("工行小订单拆分失败,订单号:{}", req == null ? null : req.getChargingOrderCode(), e); |
| | | resp.setStatus("FAIL"); |
| | | resp.setRawResponse(e.getMessage()); |
| | | flowRecorder.record("SPLIT", req == null ? null : req.getChargingOrderCode(), null, req == null ? null : req.getClassifyAmt(), |
| | | req, null, "FAIL", e.getMessage()); |
| | | } |
| | | return resp; |
| | | } |
| | | |
| | | @Override |
| | | public IcbcSubOrderRefundResp refundSubOrder(IcbcSubOrderRefundReq req) { |
| | | validateCommonConfig(); |
| | | validateConfig("icbc.pay.subOrderRefundUrl", properties.getSubOrderRefundUrl()); |
| | | IcbcSubOrderRefundResp resp = new IcbcSubOrderRefundResp(); |
| | | resp.setChargingOrderCode(req.getChargingOrderCode()); |
| | | resp.setSubOrderId(req.getSubOrderId()); |
| | | resp.setRetSubOrderId(req.getRetSubOrderId()); |
| | | resp.setOuttrxSerialNo(req.getOuttrxSerialNo()); |
| | | if (req != null) { |
| | | resp.setChargingOrderCode(req.getChargingOrderCode()); |
| | | resp.setSubOrderId(req.getSubOrderId()); |
| | | resp.setRetSubOrderId(req.getRetSubOrderId()); |
| | | resp.setOuttrxSerialNo(req.getOuttrxSerialNo()); |
| | | } |
| | | try { |
| | | validateCommonConfig(); |
| | | validateConfig("icbc.pay.subOrderRefundUrl", properties.getSubOrderRefundUrl()); |
| | | if (req == null) { |
| | | throw new IllegalArgumentException("工行分账退款请求不能为空"); |
| | | } |
| | | CardbusinessAggregatepayB2cOnlineSersuborderrefundRequestV1 request = new CardbusinessAggregatepayB2cOnlineSersuborderrefundRequestV1(); |
| | | request.setServiceUrl(properties.getSubOrderRefundUrl()); |
| | | request.setBizContent(buildSubOrderRefundBiz(req)); |
| | |
| | | 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)); |
| | |
| | | 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; |
| | | } |
| | |
| | | String[] values = entry.getValue(); |
| | | params.put(entry.getKey(), values == null || values.length == 0 ? "" : String.join(",", values)); |
| | | } |
| | | if (!verifyNotify(params)) { |
| | | throw new IllegalArgumentException("工行回调验签失败"); |
| | | try { |
| | | if (!verifyNotify(params)) { |
| | | throw new IllegalArgumentException("工行回调验签失败"); |
| | | } |
| | | mergeBizContent(params); |
| | | Map<String, String> result = IcbcNotifyParser.parseVerified(params); |
| | | flowRecorder.record("PAY_NOTIFY", result.get("out_trade_no"), parseInteger(result.get("attach")), null, |
| | | params, result, "SUCCESS", null); |
| | | return result; |
| | | } catch (RuntimeException e) { |
| | | flowRecorder.record("PAY_NOTIFY", firstNotBlank(params.get("out_trade_no"), params.get("outTradeNo")), |
| | | parseInteger(firstNotBlank(params.get("attach"), params.get("paymentType"))), null, |
| | | params, null, "FAIL", e.getMessage()); |
| | | throw e; |
| | | } |
| | | mergeBizContent(params); |
| | | String status = firstNotBlank(params.get("return_code"), params.get("orderStatus"), params.get("pay_status"), params.get("status")); |
| | | if (StringUtils.isNotEmpty(status) && !isPaidStatus(status)) { |
| | | throw new IllegalArgumentException("工行回调支付状态非成功:" + status); |
| | | } |
| | | |
| | | private Integer parseInteger(String value) { |
| | | try { |
| | | return StringUtils.isEmpty(value) ? null : Integer.valueOf(value); |
| | | } catch (NumberFormatException ignored) { |
| | | return null; |
| | | } |
| | | String outTradeNo = firstNotBlank(params.get("outTradeNo"), params.get("out_trade_no"), params.get("order_id")); |
| | | if (StringUtils.isEmpty(outTradeNo)) { |
| | | throw new IllegalArgumentException("工行回调缺少订单号"); |
| | | } |
| | | String tradeNo = firstNotBlank(params.get("tradeNo"), params.get("trade_no"), params.get("msg_id")); |
| | | String attach = firstNotBlank(params.get("attach"), params.get("paymentType"), params.get("payType")); |
| | | if (StringUtils.isEmpty(attach)) { |
| | | throw new IllegalArgumentException("工行回调缺少支付类型"); |
| | | } |
| | | Map<String, String> result = new HashMap<>(); |
| | | result.put("out_trade_no", outTradeNo); |
| | | result.put("transaction_id", tradeNo); |
| | | result.put("attach", attach); |
| | | return result; |
| | | } |
| | | |
| | | private DefaultIcbcClient buildClient() { |
| | |
| | | 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()); |
| New file |
| | |
| | | package com.ruoyi.payment.mapper; |
| | | |
| | | import com.baomidou.mybatisplus.core.mapper.BaseMapper; |
| | | import com.ruoyi.payment.domain.IcbcPaymentFlowRecord; |
| | | |
| | | public interface IcbcPaymentFlowRecordMapper extends BaseMapper<IcbcPaymentFlowRecord> { |
| | | } |
| New file |
| | |
| | | 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")); |
| | | } |
| | | } |
| New file |
| | |
| | | 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='工行支付流程审计记录'; |