|
|
@@ -0,0 +1,407 @@
|
|
|
+package com.payment.platform.module.payment.unified.service;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
|
|
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
|
|
+import com.payment.platform.common.exception.BusinessException;
|
|
|
+import com.payment.platform.core.security.LoginUser;
|
|
|
+import com.payment.platform.module.payment.unified.client.UnifiedSigner;
|
|
|
+import com.payment.platform.module.payment.unified.config.UnifiedPayProperties;
|
|
|
+import com.payment.platform.module.payment.unified.dto.req.UnifiedOrderCreateCommand;
|
|
|
+import com.payment.platform.module.payment.unified.entity.UnifiedMerchantEntity;
|
|
|
+import com.payment.platform.module.payment.unified.entity.UnifiedOrderEntity;
|
|
|
+import com.payment.platform.module.payment.unified.enums.UnifiedSource;
|
|
|
+import com.payment.platform.module.payment.unified.mapper.UnifiedOrderMapper;
|
|
|
+import com.payment.platform.module.payment.unified.util.UnifiedH5Token;
|
|
|
+import org.apache.ibatis.builder.MapperBuilderAssistant;
|
|
|
+import org.junit.jupiter.api.AfterEach;
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.junit.jupiter.api.extension.ExtendWith;
|
|
|
+import org.mockito.ArgumentCaptor;
|
|
|
+import org.mockito.Mock;
|
|
|
+import org.mockito.junit.jupiter.MockitoExtension;
|
|
|
+import org.springframework.security.core.Authentication;
|
|
|
+import org.springframework.security.core.context.SecurityContext;
|
|
|
+import org.springframework.security.core.context.SecurityContextHolder;
|
|
|
+
|
|
|
+import java.net.URLDecoder;
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
+import java.time.OffsetDateTime;
|
|
|
+import java.util.LinkedHashMap;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+import java.util.Set;
|
|
|
+import java.util.concurrent.atomic.AtomicReference;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNull;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertSame;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertThrows;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
+import static org.mockito.ArgumentMatchers.eq;
|
|
|
+import static org.mockito.ArgumentMatchers.isNull;
|
|
|
+import static org.mockito.Mockito.doThrow;
|
|
|
+import static org.mockito.Mockito.never;
|
|
|
+import static org.mockito.Mockito.verify;
|
|
|
+import static org.mockito.Mockito.verifyNoInteractions;
|
|
|
+import static org.mockito.Mockito.when;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 公开 H5 建单/轮询/发链接(Task 24)。
|
|
|
+ *
|
|
|
+ * <p>三条腿各自守的东西不同:
|
|
|
+ * <ul>
|
|
|
+ * <li><b>建单</b>:公开端点收的是 {@code Map},Bean Validation 一步都不参与 ——
|
|
|
+ * 长度、金额、未知键全部由服务层自己拦;商户不存在/验签失败/停用对外**同一句**话,
|
|
|
+ * 不构成商户号探针。</li>
|
|
|
+ * <li><b>轮询</b>:令牌错、单不存在、商户配置没了对外都是 404「订单不存在」;
|
|
|
+ * 上游一次失败不能让顾客页变 500。</li>
|
|
|
+ * <li><b>发链接</b>:只带 6 个必填参数,租户外的单与不存在同一应答。</li>
|
|
|
+ * </ul>
|
|
|
+ */
|
|
|
+@ExtendWith(MockitoExtension.class)
|
|
|
+class UnifiedH5ServiceTest {
|
|
|
+
|
|
|
+ private static final String SECRET = "test-secret";
|
|
|
+ private static final String MCH_NO = "M001";
|
|
|
+ private static final long MERCHANT_ID = 7L;
|
|
|
+
|
|
|
+ @Mock private UnifiedOrderMapper orderMapper;
|
|
|
+ @Mock private UnifiedOrderService orderService;
|
|
|
+ @Mock private UnifiedMerchantService merchantService;
|
|
|
+
|
|
|
+ private UnifiedPayProperties properties;
|
|
|
+ private UnifiedH5Service service;
|
|
|
+ private UnifiedMerchantEntity cfg;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() {
|
|
|
+ properties = new UnifiedPayProperties();
|
|
|
+ service = new UnifiedH5Service(orderMapper, orderService, merchantService, properties);
|
|
|
+
|
|
|
+ cfg = new UnifiedMerchantEntity();
|
|
|
+ cfg.setId(MERCHANT_ID);
|
|
|
+ cfg.setTenantId(2L);
|
|
|
+ cfg.setMchNo(MCH_NO);
|
|
|
+ cfg.setAppSecret(SECRET);
|
|
|
+ cfg.setStatus(UnifiedMerchantService.STATUS_ENABLED);
|
|
|
+
|
|
|
+ // 初始化 lambda 元数据缓存,使 wrapper.getSqlSegment() 在无 Spring 上下文的单测中可用
|
|
|
+ MybatisConfiguration configuration = new MybatisConfiguration();
|
|
|
+ MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
|
|
+ TableInfoHelper.initTableInfo(assistant, UnifiedOrderEntity.class);
|
|
|
+ }
|
|
|
+
|
|
|
+ @AfterEach
|
|
|
+ void clearSecurityContext() {
|
|
|
+ SecurityContextHolder.clearContext();
|
|
|
+ }
|
|
|
+
|
|
|
+ // ────────────────────────────── 建单 ──────────────────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void create_validSignedRequest_delegatesToOrderCreate_withH5Source() {
|
|
|
+ when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg);
|
|
|
+ UnifiedOrderEntity created = order("0");
|
|
|
+ AtomicReference<Long> scopedTenant = new AtomicReference<>();
|
|
|
+ when(orderService.create(any(), eq(2L), isNull(), eq(UnifiedSource.H5))).thenAnswer(inv -> {
|
|
|
+ Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
|
|
+ assertNotNull(auth, "建单调用时必须有租户上下文");
|
|
|
+ assertTrue(auth.getPrincipal() instanceof LoginUser, "主体必须是 LoginUser");
|
|
|
+ scopedTenant.set(((LoginUser) auth.getPrincipal()).getTenantId());
|
|
|
+ return created;
|
|
|
+ });
|
|
|
+
|
|
|
+ SecurityContext previous = SecurityContextHolder.createEmptyContext();
|
|
|
+ SecurityContextHolder.setContext(previous);
|
|
|
+
|
|
|
+ UnifiedH5Service.Created result = service.create(signed(unsignedParams()), "203.0.113.9");
|
|
|
+
|
|
|
+ assertSame(created, result.order());
|
|
|
+ assertEquals(UnifiedH5Token.issue(SECRET, "UP1"), result.token());
|
|
|
+
|
|
|
+ ArgumentCaptor<UnifiedOrderCreateCommand> cap =
|
|
|
+ ArgumentCaptor.forClass(UnifiedOrderCreateCommand.class);
|
|
|
+ verify(orderService).create(cap.capture(), eq(2L), isNull(), eq(UnifiedSource.H5));
|
|
|
+ UnifiedOrderCreateCommand cmd = cap.getValue();
|
|
|
+ assertEquals(1800, cmd.getExpiredTime(), "H5 单过期时间固定 1800 秒");
|
|
|
+ assertEquals(100L, cmd.getAmount());
|
|
|
+ assertEquals("QR_CASHIER", cmd.getWayCode());
|
|
|
+ assertEquals("测试订单", cmd.getSubject());
|
|
|
+ assertEquals("C1", cmd.getOutTradeNo());
|
|
|
+ assertEquals(MCH_NO, cmd.getMchNo());
|
|
|
+ assertEquals("商品描述", cmd.getBody());
|
|
|
+ assertEquals("https://merchant.example/return", cmd.getReturnUrl());
|
|
|
+ assertEquals("https://merchant.example/notify", cmd.getNotifyUrl());
|
|
|
+ assertEquals("X1", cmd.getExtParam());
|
|
|
+ assertEquals("203.0.113.9", cmd.getClientIp());
|
|
|
+ assertNull(cmd.getChannelExtra(), "H5 页不接受 channelExtra");
|
|
|
+ assertEquals(2L, scopedTenant.get(), "建单必须在商户归属租户的上下文里执行");
|
|
|
+ assertSame(previous, SecurityContextHolder.getContext(), "调用后必须还原原 SecurityContext");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void create_badSignature_is400_withoutTouchingOrderService() {
|
|
|
+ when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg);
|
|
|
+ Map<String, String> params = signed(unsignedParams());
|
|
|
+ params.put("sign", "00000000000000000000000000000000");
|
|
|
+
|
|
|
+ BusinessException e = assertThrows(BusinessException.class,
|
|
|
+ () -> service.create(params, "203.0.113.9"));
|
|
|
+
|
|
|
+ assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
|
|
|
+ assertEquals("支付链接无效或已过期", e.getMessage(), "不区分原因,不构成商户号探针");
|
|
|
+ verifyNoInteractions(orderService);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void create_unknownMerchant_is400_withSameMessage() {
|
|
|
+ when(merchantService.requireByMchNo(MCH_NO))
|
|
|
+ .thenThrow(new BusinessException(404, "商户配置不存在: " + MCH_NO));
|
|
|
+
|
|
|
+ BusinessException e = assertThrows(BusinessException.class,
|
|
|
+ () -> service.create(signed(unsignedParams()), "203.0.113.9"));
|
|
|
+
|
|
|
+ assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
|
|
|
+ assertEquals("支付链接无效或已过期", e.getMessage());
|
|
|
+ verifyNoInteractions(orderService);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void create_disabledMerchant_is400_withSameMessage() {
|
|
|
+ when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg);
|
|
|
+ doThrow(new BusinessException(400, "商户已停用")).when(merchantService).requireEnabled(cfg);
|
|
|
+
|
|
|
+ BusinessException e = assertThrows(BusinessException.class,
|
|
|
+ () -> service.create(signed(unsignedParams()), "203.0.113.9"));
|
|
|
+
|
|
|
+ assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
|
|
|
+ assertEquals("支付链接无效或已过期", e.getMessage());
|
|
|
+ verifyNoInteractions(orderService);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void create_wayCodeOutsideH5Supported_is400_beforeAnyMerchantCall() {
|
|
|
+ Map<String, String> params = unsignedParams();
|
|
|
+ params.put("way_code", "ALI_BAR");
|
|
|
+
|
|
|
+ BusinessException e = assertThrows(BusinessException.class,
|
|
|
+ () -> service.create(signed(params), "203.0.113.9"));
|
|
|
+
|
|
|
+ assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
|
|
|
+ assertEquals("支付链接参数不完整", e.getMessage());
|
|
|
+ verifyNoInteractions(merchantService, orderService);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void create_badAmounts_are400() {
|
|
|
+ for (String amount : List.of("0", "-1", "1.5", "abc", "")) {
|
|
|
+ Map<String, String> params = unsignedParams();
|
|
|
+ params.put("amount", amount);
|
|
|
+
|
|
|
+ BusinessException e = assertThrows(BusinessException.class,
|
|
|
+ () -> service.create(signed(params), "203.0.113.9"), "amount=" + amount);
|
|
|
+
|
|
|
+ assertEquals(400, e.getCode(), "amount=" + amount + " 实测消息: " + e.getMessage());
|
|
|
+ }
|
|
|
+ verifyNoInteractions(merchantService, orderService);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void create_unknownChannelExtraKey_is400() {
|
|
|
+ Map<String, String> params = signed(unsignedParams());
|
|
|
+ params.put("channel_extra", "{}");
|
|
|
+
|
|
|
+ BusinessException e = assertThrows(BusinessException.class,
|
|
|
+ () -> service.create(params, "203.0.113.9"));
|
|
|
+
|
|
|
+ assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
|
|
|
+ assertEquals("支付链接参数不完整", e.getMessage());
|
|
|
+ verifyNoInteractions(merchantService, orderService);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void create_tooLongFields_are400_beforeAnyMerchantCall() {
|
|
|
+ Map<String, Integer> limits = new LinkedHashMap<>();
|
|
|
+ limits.put("out_trade_no", 64);
|
|
|
+ limits.put("subject", 128);
|
|
|
+ limits.put("body", 512);
|
|
|
+ limits.put("return_url", 512);
|
|
|
+ limits.put("notify_url", 512);
|
|
|
+ limits.put("ext_param", 512);
|
|
|
+
|
|
|
+ for (Map.Entry<String, Integer> entry : limits.entrySet()) {
|
|
|
+ Map<String, String> params = unsignedParams();
|
|
|
+ params.put(entry.getKey(), "x".repeat(entry.getValue() + 1));
|
|
|
+
|
|
|
+ BusinessException e = assertThrows(BusinessException.class,
|
|
|
+ () -> service.create(signed(params), "203.0.113.9"), entry.getKey());
|
|
|
+
|
|
|
+ assertEquals(400, e.getCode(),
|
|
|
+ entry.getKey() + " 超长必须 400,实测消息: " + e.getMessage());
|
|
|
+ }
|
|
|
+ verifyNoInteractions(merchantService, orderService);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ────────────────────────────── 轮询 ──────────────────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void snapshot_badToken_is404_withoutUpstreamCall() {
|
|
|
+ when(orderService.requireByOrderNo("UP1")).thenReturn(order("0"));
|
|
|
+ when(merchantService.requireById(MERCHANT_ID)).thenReturn(cfg);
|
|
|
+
|
|
|
+ BusinessException e = assertThrows(BusinessException.class,
|
|
|
+ () -> service.snapshot("UP1", "not-a-valid-token"));
|
|
|
+
|
|
|
+ assertEquals(404, e.getCode(), "实测消息: " + e.getMessage());
|
|
|
+ assertEquals("订单不存在", e.getMessage());
|
|
|
+ verify(orderService, never()).syncFromUpstream(any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void snapshot_nonPendingStatuses_returnRowWithoutUpstreamCall() {
|
|
|
+ for (String status : List.of("2", "3", "4", "5", "6")) {
|
|
|
+ UnifiedOrderEntity row = order(status);
|
|
|
+ row.setLastQueryTime(OffsetDateTime.now().minusMinutes(5));
|
|
|
+ when(orderService.requireByOrderNo("UP1")).thenReturn(row);
|
|
|
+ when(merchantService.requireById(MERCHANT_ID)).thenReturn(cfg);
|
|
|
+
|
|
|
+ assertSame(row, service.snapshot("UP1", UnifiedH5Token.issue(SECRET, "UP1")),
|
|
|
+ "status=" + status);
|
|
|
+ }
|
|
|
+ verify(orderService, never()).syncFromUpstream(any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void snapshot_pendingWithin3s_returnsWithoutUpstreamCall() {
|
|
|
+ UnifiedOrderEntity recent = order("0");
|
|
|
+ recent.setLastQueryTime(OffsetDateTime.now().minusSeconds(1));
|
|
|
+ when(orderService.requireByOrderNo("UP1")).thenReturn(recent);
|
|
|
+ when(merchantService.requireById(MERCHANT_ID)).thenReturn(cfg);
|
|
|
+
|
|
|
+ assertSame(recent, service.snapshot("UP1", UnifiedH5Token.issue(SECRET, "UP1")));
|
|
|
+ verify(orderService, never()).syncFromUpstream(any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void snapshot_stalePending_syncsOnceThenReloads() {
|
|
|
+ UnifiedOrderEntity stale = order("0");
|
|
|
+ stale.setLastQueryTime(OffsetDateTime.now().minusSeconds(10));
|
|
|
+ UnifiedOrderEntity fresh = order("2");
|
|
|
+ when(orderService.requireByOrderNo("UP1")).thenReturn(stale);
|
|
|
+ when(merchantService.requireById(MERCHANT_ID)).thenReturn(cfg);
|
|
|
+ when(orderMapper.selectOne(any())).thenReturn(fresh);
|
|
|
+
|
|
|
+ UnifiedOrderEntity result = service.snapshot("UP1", UnifiedH5Token.issue(SECRET, "UP1"));
|
|
|
+
|
|
|
+ verify(orderService).syncFromUpstream(stale);
|
|
|
+ assertSame(fresh, result, "同步后必须返回重载的库内行,而不是同步前的旧对象");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void snapshot_upstreamFailure_returnsRowInsteadOf500() {
|
|
|
+ UnifiedOrderEntity stale = order("0");
|
|
|
+ stale.setLastQueryTime(OffsetDateTime.now().minusSeconds(10));
|
|
|
+ when(orderService.requireByOrderNo("UP1")).thenReturn(stale);
|
|
|
+ when(merchantService.requireById(MERCHANT_ID)).thenReturn(cfg);
|
|
|
+ doThrow(new RuntimeException("上游超时")).when(orderService).syncFromUpstream(stale);
|
|
|
+
|
|
|
+ assertSame(stale, service.snapshot("UP1", UnifiedH5Token.issue(SECRET, "UP1")),
|
|
|
+ "顾客页轮询不因一次上游失败变 500");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ────────────────────────────── 发链接 ──────────────────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void linkForOrder_otherTenantOrMissingOrder_is404() {
|
|
|
+ when(orderService.findByOrderNo(2L, "UP1")).thenReturn(null);
|
|
|
+
|
|
|
+ BusinessException e = assertThrows(BusinessException.class,
|
|
|
+ () -> service.linkForOrder("UP1", 2L));
|
|
|
+
|
|
|
+ assertEquals(404, e.getCode(), "实测消息: " + e.getMessage());
|
|
|
+ assertEquals("订单不存在", e.getMessage());
|
|
|
+ verifyNoInteractions(merchantService);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void linkForOrder_foundOrder_checksMerchantAndReturnsLink() {
|
|
|
+ UnifiedOrderEntity row = order("0");
|
|
|
+ when(orderService.findByOrderNo(2L, "UP1")).thenReturn(row);
|
|
|
+ when(merchantService.requireById(MERCHANT_ID)).thenReturn(cfg);
|
|
|
+
|
|
|
+ String url = service.linkForOrder("UP1", 2L);
|
|
|
+
|
|
|
+ assertTrue(url.startsWith(properties.getH5PageUrl()), "实测: " + url);
|
|
|
+ verify(merchantService).requireEnabled(cfg);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void buildLink_carriesExactlySixParams_andSignVerifies() {
|
|
|
+ String url = service.buildLink(order("0"), cfg);
|
|
|
+
|
|
|
+ assertTrue(url.startsWith(properties.getH5PageUrl()), "实测: " + url);
|
|
|
+ Map<String, String> parsed = parseQuery(url);
|
|
|
+ assertEquals(Set.of("mch_no", "out_trade_no", "amount", "subject", "way_code", "sign"),
|
|
|
+ parsed.keySet(), "链接只带 6 个必填参数,实测: " + parsed);
|
|
|
+ assertEquals(MCH_NO, parsed.get("mch_no"));
|
|
|
+ assertEquals("C1", parsed.get("out_trade_no"));
|
|
|
+ assertEquals("100", parsed.get("amount"));
|
|
|
+ assertEquals("测试订单", parsed.get("subject"));
|
|
|
+ assertEquals("QR_CASHIER", parsed.get("way_code"));
|
|
|
+ assertTrue(UnifiedSigner.verifyNotification(parsed, SECRET),
|
|
|
+ "链接里的 sign 必须能通过 verifyNotification,实测: " + parsed.get("sign"));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ────────────────────────────── 工具 ──────────────────────────────
|
|
|
+
|
|
|
+ private static Map<String, String> unsignedParams() {
|
|
|
+ Map<String, String> params = new LinkedHashMap<>();
|
|
|
+ params.put("mch_no", MCH_NO);
|
|
|
+ params.put("out_trade_no", "C1");
|
|
|
+ params.put("amount", "100");
|
|
|
+ params.put("subject", "测试订单");
|
|
|
+ params.put("way_code", "QR_CASHIER");
|
|
|
+ params.put("body", "商品描述");
|
|
|
+ params.put("return_url", "https://merchant.example/return");
|
|
|
+ params.put("notify_url", "https://merchant.example/notify");
|
|
|
+ params.put("ext_param", "X1");
|
|
|
+ return params;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static Map<String, String> signed(Map<String, String> params) {
|
|
|
+ Map<String, String> copy = new LinkedHashMap<>(params);
|
|
|
+ copy.put("sign", UnifiedSigner.sign(copy, SECRET));
|
|
|
+ return copy;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static UnifiedOrderEntity order(String status) {
|
|
|
+ UnifiedOrderEntity entity = new UnifiedOrderEntity();
|
|
|
+ entity.setId(11L);
|
|
|
+ entity.setTenantId(2L);
|
|
|
+ entity.setMerchantId(MERCHANT_ID);
|
|
|
+ entity.setOrderNo("UP1");
|
|
|
+ entity.setOutTradeNo("C1");
|
|
|
+ entity.setMchNo(MCH_NO);
|
|
|
+ entity.setWayCode("QR_CASHIER");
|
|
|
+ entity.setAmount(100L);
|
|
|
+ entity.setSubject("测试订单");
|
|
|
+ entity.setTradeStatus(status);
|
|
|
+ return entity;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static Map<String, String> parseQuery(String url) {
|
|
|
+ int at = url.indexOf('?');
|
|
|
+ assertTrue(at > 0, "链接必须带查询串,实测: " + url);
|
|
|
+ Map<String, String> parsed = new LinkedHashMap<>();
|
|
|
+ for (String pair : url.substring(at + 1).split("&")) {
|
|
|
+ int eq = pair.indexOf('=');
|
|
|
+ assertTrue(eq > 0, "查询串片段没有 =,实测: " + pair);
|
|
|
+ parsed.put(URLDecoder.decode(pair.substring(0, eq), StandardCharsets.UTF_8),
|
|
|
+ URLDecoder.decode(pair.substring(eq + 1), StandardCharsets.UTF_8));
|
|
|
+ }
|
|
|
+ return parsed;
|
|
|
+ }
|
|
|
+}
|