| | |
| | | if (!"SUCCESS".equals(shareRecord.getOnlineShareStatus())) { |
| | | throw new IllegalArgumentException("仅支持已成功的工行分账记录退款"); |
| | | } |
| | | if (isBlank(shareRecord.getSeqNo()) || isBlank(shareRecord.getBusiType()) |
| | | || isBlank(shareRecord.getOriMerId()) || shareRecord.getOriTrxDate() == null |
| | | if (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.setSeqNo(firstNotBlank(shareRecord.getSeqNo(), order.getCode())); |
| | | request.setBusiType(firstNotBlank(shareRecord.getBusiType(), "1")); |
| | | request.setOriMerId(shareRecord.getOriMerId()); |
| | | request.setOriTrxDate(shareRecord.getOriTrxDate() == null ? null : shareRecord.getOriTrxDate().toString()); |
| | | request.setSubOrderId(shareRecord.getSubOrderId()); |
| | |
| | | private static boolean isBlank(String value) { |
| | | return value == null || value.trim().isEmpty(); |
| | | } |
| | | |
| | | private static String firstNotBlank(String... values) { |
| | | for (String value : values) { |
| | | if (!isBlank(value)) { |
| | | return value; |
| | | } |
| | | } |
| | | return null; |
| | | } |
| | | } |
| | |
| | | |
| | | import static org.junit.jupiter.api.Assertions.assertEquals; |
| | | import static org.junit.jupiter.api.Assertions.assertFalse; |
| | | import static org.junit.jupiter.api.Assertions.assertNull; |
| | | import static org.junit.jupiter.api.Assertions.assertThrows; |
| | | import static org.junit.jupiter.api.Assertions.assertTrue; |
| | | |
| | |
| | | } |
| | | |
| | | @Test |
| | | void buildsLegacySubOrderRefundWithoutStoredOriginalMerchantId() { |
| | | TChargingOrderShareRecord record = shareRecord(); |
| | | record.setSeqNo(null); |
| | | record.setBusiType(null); |
| | | record.setOriMerId(null); |
| | | |
| | | IcbcSubOrderRefundReq request = IcbcAdminFundOperationSupport.buildSubOrderRefundRequest( |
| | | paidOrder(), record, new BigDecimal("1.90"), |
| | | "ICBCSUBREFUNDLEGACY", "ICBCSUBREFUNDSERIALLEGACY"); |
| | | |
| | | assertEquals("CD202607110001", request.getSeqNo()); |
| | | assertEquals("1", request.getBusiType()); |
| | | assertNull(request.getOriMerId()); |
| | | assertEquals(new BigDecimal("1.90"), request.getRefundAmount()); |
| | | } |
| | | |
| | | @Test |
| | | void rejectsAmountWithMoreThanTwoDecimalPlaces() { |
| | | IllegalArgumentException error = assertThrows(IllegalArgumentException.class, |
| | | () -> IcbcAdminFundOperationSupport.requireAmount(new BigDecimal("0.004"))); |
| | |
| | | Map<String, String[]> requestParams = new LinkedHashMap<>(request.getParameterMap()); |
| | | String rawBody = requestParams.isEmpty() ? readRequestBody(request) : ""; |
| | | mergeJsonBody(requestParams, rawBody); |
| | | boolean success = false; |
| | | log.info("工行支付回调原始请求,method:{},uri:{},query:{},contentType:{},params:{},body:{},headers:{}", |
| | | request.getMethod(), |
| | | request.getRequestURI(), |
| | |
| | | Map<String, String> callback = icbcPayService.parseNotify(requestParams); |
| | | log.info("工行支付回调解析结果:{}", JSON.toJSONString(callback)); |
| | | dispatchChargingOrderCallback(callback); |
| | | success = true; |
| | | } catch (Exception e) { |
| | | log.error("工行支付回调处理失败,已记录原始请求并返回success避免重复通知", e); |
| | | log.error("工行支付回调处理失败,已记录原始请求并返回fail等待重试", e); |
| | | } finally { |
| | | writeCallbackResponse(response); |
| | | writeCallbackResponse(response, success); |
| | | } |
| | | } |
| | | |
| | |
| | | return JSON.toJSONString(headers); |
| | | } |
| | | |
| | | private void writeCallbackResponse(HttpServletResponse response) throws Exception { |
| | | private void writeCallbackResponse(HttpServletResponse response, boolean success) throws Exception { |
| | | response.setCharacterEncoding("UTF-8"); |
| | | response.setContentType("text/plain;charset=UTF-8"); |
| | | PrintWriter writer = response.getWriter(); |
| | | writer.println("success"); |
| | | writer.println(success ? "success" : "fail"); |
| | | writer.flush(); |
| | | writer.close(); |
| | | } |
| | |
| | | import org.springframework.stereotype.Service; |
| | | |
| | | import javax.annotation.Resource; |
| | | import java.net.URI; |
| | | import java.math.BigDecimal; |
| | | import java.math.RoundingMode; |
| | | import java.net.URI; |
| | | import java.nio.charset.StandardCharsets; |
| | | import java.security.MessageDigest; |
| | | import java.text.SimpleDateFormat; |
| | | import java.util.Arrays; |
| | | import java.util.Date; |
| | |
| | | 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, |
| | | Map<String, String> auditParams = new HashMap<>(params); |
| | | mergeBizContent(auditParams); |
| | | flowRecorder.record("PAY_NOTIFY", firstNotBlank(auditParams.get("out_trade_no"), auditParams.get("outTradeNo")), |
| | | parseInteger(firstNotBlank(auditParams.get("attach"), auditParams.get("paymentType"))), null, |
| | | params, null, "FAIL", e.getMessage()); |
| | | throw e; |
| | | } |
| | |
| | | String content = buildSignContent(params); |
| | | String signType = firstNotBlank(params.get("sign_type"), properties.getSignType()); |
| | | String publicKey = normalizePublicKey(properties.getIcbcPublicKey()); |
| | | log.info("工行回调验签参数,signType:{},publicKeyLength:{},content:{}", signType, publicKey == null ? 0 : publicKey.length(), content); |
| | | return IcbcSignature.verify(content, signType, publicKey, properties.getCharset(), sign); |
| | | String notifyPath = resolveNotifyPath(); |
| | | log.info("工行回调验签参数,notifyPath:{},signType:{},charset:{},publicKeyLength:{},publicKeySha256:{},contentSha256:{},signLength:{}", |
| | | notifyPath, signType, properties.getCharset(), publicKey == null ? 0 : publicKey.length(), |
| | | sha256Hex(publicKey), sha256Hex(content), sign.length()); |
| | | boolean verified = IcbcSignature.verify(content, signType, publicKey, properties.getCharset(), sign); |
| | | log.info("工行回调验签结果,verified:{}", verified); |
| | | if (!verified) { |
| | | log.warn("工行回调验签未通过,content:{}", content); |
| | | } |
| | | return verified; |
| | | } catch (Exception e) { |
| | | log.error("工行回调验签异常", e); |
| | | return false; |
| | |
| | | } |
| | | } |
| | | |
| | | private String sha256Hex(String value) { |
| | | if (value == null) { |
| | | return ""; |
| | | } |
| | | try { |
| | | byte[] digest = MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)); |
| | | StringBuilder hex = new StringBuilder(digest.length * 2); |
| | | for (byte item : digest) { |
| | | hex.append(String.format("%02x", item & 0xff)); |
| | | } |
| | | return hex.toString(); |
| | | } catch (Exception e) { |
| | | log.warn("计算工行验签摘要失败", e); |
| | | return ""; |
| | | } |
| | | } |
| | | |
| | | private boolean isPaidStatus(String status) { |
| | | return Arrays.asList("1", "2", "SUCCESS", "PAID", "PAY_SUCCESS", "TRADE_SUCCESS").contains(status); |
| | | } |
| New file |
| | |
| | | package com.ruoyi.payment.controller; |
| | | |
| | | import org.junit.jupiter.api.Test; |
| | | import org.springframework.mock.web.MockHttpServletResponse; |
| | | import org.springframework.test.util.ReflectionTestUtils; |
| | | |
| | | import static org.junit.jupiter.api.Assertions.assertEquals; |
| | | |
| | | class IcbcPayControllerTest { |
| | | |
| | | @Test |
| | | void returnsFailWhenCallbackProcessingFails() throws Exception { |
| | | MockHttpServletResponse response = new MockHttpServletResponse(); |
| | | |
| | | ReflectionTestUtils.invokeMethod(new IcbcPayController(), "writeCallbackResponse", response, false); |
| | | |
| | | assertEquals("fail", response.getContentAsString().trim()); |
| | | } |
| | | |
| | | @Test |
| | | void returnsSuccessWhenCallbackProcessingSucceeds() throws Exception { |
| | | MockHttpServletResponse response = new MockHttpServletResponse(); |
| | | |
| | | ReflectionTestUtils.invokeMethod(new IcbcPayController(), "writeCallbackResponse", response, true); |
| | | |
| | | assertEquals("success", response.getContentAsString().trim()); |
| | | } |
| | | } |
| | |
| | | package com.ruoyi.payment.icbc.service.impl; |
| | | |
| | | import com.icbc.api.request.CardbusinessAggregatepayB2cOnlineSersuborderrefundRequestV1; |
| | | import com.ruoyi.payment.api.vo.IcbcSubOrderRefundReq; |
| | | import com.ruoyi.payment.icbc.config.IcbcPayProperties; |
| | | import org.junit.jupiter.api.Test; |
| | | import org.springframework.test.util.ReflectionTestUtils; |
| | |
| | | import java.util.HashMap; |
| | | import java.util.Map; |
| | | |
| | | import static org.junit.jupiter.api.Assertions.assertEquals; |
| | | import static org.junit.jupiter.api.Assertions.assertTrue; |
| | | |
| | | class IcbcPayServiceImplTest { |
| | |
| | | |
| | | assertTrue(Boolean.TRUE.equals(ReflectionTestUtils.invokeMethod(service, "verifyNotify", params))); |
| | | } |
| | | |
| | | @Test |
| | | void verifiesSecondRealPaymentCallbackWithConfiguredNotifyPath() { |
| | | IcbcPayProperties properties = new IcbcPayProperties(); |
| | | properties.setCharset("UTF-8"); |
| | | properties.setSignType("RSA2"); |
| | | properties.setNotifyUrl("https://www.lyt18.cn/payment/icbc/callBack"); |
| | | properties.setIcbcPublicKey( |
| | | "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCMpjaWjngB4E3ATh+G1DVAmQnIpiPEFAEDqRfNGAVvvH35yDetqewKi0l7OEceTMN1C6NPym3zStvSoQayjYV+eIcZERkx31KhtFu9clZKgRTyPjdKMIth/wBtPKjL/5+PYalLdomM4ONthrPgnkN4x4R0+D4+EBpXo8gNiAFsNwIDAQAB"); |
| | | |
| | | Map<String, String> params = new HashMap<>(); |
| | | params.put("api", "/api/cardbusiness/aggregatepay/b2c/online/consumepurchase/V1"); |
| | | params.put("app_id", "11000000000000096526"); |
| | | params.put("biz_content", "{\"access_type\":\"9\",\"attach\":\"1\",\"bank_disc_amt\":\"0\",\"card_flag\":\"\",\"card_kind\":\"\",\"card_no\":\"\",\"coupon_amt\":\"0\",\"cust_id\":\"\",\"decr_flag\":\"\",\"ecoupon_amt\":\"0\",\"mer_disc_amt\":\"0\",\"mer_id\":\"231011990056\",\"msg_id\":\"050200136196883020851826964\",\"open_id\":\"ow8NuxGUrqfA23hZNG-1S01p1EuM\",\"order_id\":\"231011990056000522607150004167\",\"out_trade_no\":\"CD78120260715165644735\",\"pay_time\":\"20260715165653\",\"pay_type\":\"9\",\"payment_amt\":\"600\",\"point_amt\":\"0\",\"return_code\":\"0\",\"return_msg\":\"交易成功\",\"third_party_coupon_amt\":\"0\",\"third_party_discount_amt\":\"0\",\"third_trade_no\":\"4500000248202607151296339259\",\"total_amt\":\"600\",\"total_disc_amt\":\"0\"}"); |
| | | params.put("charset", "UTF-8"); |
| | | params.put("format", "json"); |
| | | params.put("from", "icbc-api"); |
| | | params.put("sign", "GR00J4lJPekAVZMhmc70pHodJzY34aTt2oaJBgJGFsYPPvives8Rhrsksj3RFASy7IXEcr8EfexNtPjbbr9WXwuDJa4+uT2aS3b/TkuVR7xJVPXlZ/355z2kVpxLNtBAvbp6R/agWnw0XSG4pps8eVvbENQ8YE+wsjGDbIR5hNI="); |
| | | params.put("sign_type", "RSA"); |
| | | params.put("timestamp", "2026-07-15 16:56:53"); |
| | | |
| | | IcbcPayServiceImpl service = new IcbcPayServiceImpl(); |
| | | ReflectionTestUtils.setField(service, "properties", properties); |
| | | |
| | | assertTrue(Boolean.TRUE.equals(ReflectionTestUtils.invokeMethod(service, "verifyNotify", params))); |
| | | } |
| | | |
| | | @Test |
| | | void fillsOriginalMerchantIdForLegacySubOrderRefund() { |
| | | IcbcPayProperties properties = new IcbcPayProperties(); |
| | | properties.setMerId("231011990056"); |
| | | properties.setServiceMerId("231000007053"); |
| | | properties.setServiceMerPrtclNo("23100000705300101"); |
| | | |
| | | IcbcSubOrderRefundReq request = new IcbcSubOrderRefundReq(); |
| | | request.setOriMerId(null); |
| | | |
| | | IcbcPayServiceImpl service = new IcbcPayServiceImpl(); |
| | | ReflectionTestUtils.setField(service, "properties", properties); |
| | | CardbusinessAggregatepayB2cOnlineSersuborderrefundRequestV1.CardbusinessAggregatepayB2cOnlineSersuborderrefundRequestV1Biz biz = |
| | | ReflectionTestUtils.invokeMethod(service, "buildSubOrderRefundBiz", request); |
| | | |
| | | assertEquals("231011990056", biz.getOriMerId()); |
| | | } |
| | | } |