Prechádzať zdrojové kódy

feat(统一支付): channelExtra 组装与 wayCode 校验

alphaH 1 týždeň pred
rodič
commit
a813521459

+ 269 - 0
java/src/main/java/com/payment/platform/module/payment/unified/util/UnifiedChannelExtraAssembler.java

@@ -0,0 +1,269 @@
+package com.payment.platform.module.payment.unified.util;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.payment.platform.common.exception.BusinessException;
+import lombok.extern.slf4j.Slf4j;
+
+import java.util.LinkedHashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+
+/**
+ * 统一支付 {@code channelExtra} 组装与 {@code wayCode} 校验(spec §4.6,裁定 R19/R32/R33)。
+ *
+ * <p>开放 API 收到的 {@code channel_extra} 是 JSON 对象;上游要的是**紧凑 JSON 字符串**,
+ * 且不同 {@code wayCode} 只认少数几个键。本类负责:
+ * <ul>
+ *   <li>校验 {@code wayCode} 在 16 个支持值之内(逐字、大小写敏感);</li>
+ *   <li>按 wayCode 收敛键集合,未知键丢弃并只记键名;</li>
+ *   <li>付款码对外同时接受 {@code auth_code}/{@code authCode},转上游按配置输出;</li>
+ *   <li>{@code payDataType} 大小写不敏感匹配并输出规范拼写。</li>
+ * </ul>
+ *
+ * <p>★ spec 表里的「默认 codeUrl/payUrl」是**上游缺省行为**:调用方没给 {@code payDataType}
+ * 时这里不注入默认值,结果为空({@link Result#json()} 与 {@link Result#keysCsv()} 均为 null)。
+ *
+ * <p>纯函数、无状态;不记录任何值(付款码、openid、buyerUserId、wxAppId 一律不进日志)。
+ */
+@Slf4j
+public final class UnifiedChannelExtraAssembler {
+
+    /** spec §4.6 的 16 个 wayCode,逐字照抄(大小写敏感)。 */
+    public static final Set<String> SUPPORTED = Set.of(
+            "QR_CASHIER", "AUTO_BAR", "ALI_BAR", "ALI_JSAPI",
+            "ALI_APP", "ALI_WAP", "ALI_PC", "ALI_QR",
+            "WX_BAR", "WX_JSAPI", "WX_LITE", "WX_APP",
+            "WX_H5", "WX_NATIVE", "YSF_BAR", "YSF_JSAPI");
+
+    private static final Set<String> BARCODE = Set.of("AUTO_BAR", "ALI_BAR", "WX_BAR", "YSF_BAR");
+    private static final Set<String> WX_JSAPI_LIKE = Set.of("WX_JSAPI", "WX_LITE");
+    private static final Set<String> PAY_TYPE_CODE = Set.of("QR_CASHIER", "ALI_QR", "WX_NATIVE");
+    private static final String ALI_WAP = "ALI_WAP";
+    private static final String ALI_PC = "ALI_PC";
+
+    private static final Set<String> AUTH_KEYS = Set.of("auth_code", "authCode");
+    private static final Set<String> BUYER_USER_ID_KEYS = Set.of("buyerUserId");
+    private static final Set<String> WX_JSAPI_KEYS = Set.of("openid", "wxAppId");
+    private static final Set<String> OPENID_KEYS = Set.of("openid");
+    private static final Set<String> PAY_TYPE_KEYS = Set.of("payDataType");
+    private static final Set<String> NO_KEYS = Set.of();
+
+    private static final Set<String> CODE_PAY_TYPES = Set.of("codeUrl", "codeImgUrl");
+    private static final Set<String> WAP_PAY_TYPES = Set.of("form", "codeImgUrl", "payUrl");
+    private static final Set<String> PC_PAY_TYPES = Set.of("form", "payUrl");
+
+    private static final String AUTH_CODE_UNDERSCORE = "auth_code";
+    private static final String AUTH_CODE_CAMEL = "authCode";
+    private static final String AUTH_CODE_BOTH = "both";
+    private static final String PAY_DATA_TYPE = "payDataType";
+
+    /** 小写输入 → 规范拼写(R19①:调用方传 {@code payurl} 按 {@code payUrl} 输出)。 */
+    private static final Map<String, String> PAY_DATA_TYPE_CANONICAL = Map.of(
+            "codeurl", "codeUrl",
+            "codeimgurl", "codeImgUrl",
+            "payurl", "payUrl",
+            "form", "form");
+
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+
+    private UnifiedChannelExtraAssembler() {
+    }
+
+    /**
+     * 按 {@code wayCode} 把 {@code channel_extra} 对象收敛成上游要的紧凑 JSON 字符串。
+     *
+     * @param wayCode     16 个支持值之一,大小写敏感
+     * @param extras      开放 API 收到的对象;{@code null} 按空 map 处理
+     * @param authCodeKey 付款码输出键名:{@code auth_code}(默认)/ {@code authCode} / {@code both};
+     *                    {@code null}、空白或未知值一律按 {@code auth_code}
+     * @return 组装结果;没有可输出键时 {@code json} 与 {@code keysCsv} 均为 null
+     * @throws BusinessException 400:不支持的 wayCode,或缺必传参数(付款码/openid/buyerUserId)
+     */
+    public static Result assemble(String wayCode, Map<String, Object> extras, String authCodeKey) {
+        if (wayCode == null || !SUPPORTED.contains(wayCode)) {
+            throw new BusinessException(400, "不支持的 wayCode: " + wayCode);
+        }
+
+        Map<String, Object> input = extras == null ? Map.of() : extras;
+        LinkedHashMap<String, String> output = new LinkedHashMap<>();
+
+        if (BARCODE.contains(wayCode)) {
+            collectBarcode(wayCode, input, authCodeKey, output);
+        } else if ("ALI_JSAPI".equals(wayCode)) {
+            collectAliJsapi(input, output);
+        } else if (WX_JSAPI_LIKE.contains(wayCode)) {
+            collectWxJsapi(wayCode, input, output);
+        } else if ("YSF_JSAPI".equals(wayCode) || "WX_H5".equals(wayCode)) {
+            collectOpenid(input, output);
+        } else if (PAY_TYPE_CODE.contains(wayCode)
+                || ALI_WAP.equals(wayCode) || ALI_PC.equals(wayCode)) {
+            collectPayDataType(wayCode, input, output);
+        }
+        // ALI_APP / WX_APP:无额外参数,所有键都会走未知键丢弃。
+
+        warnUnknownKeys(wayCode, input);
+
+        if (output.isEmpty()) {
+            return new Result(null, null);
+        }
+        return new Result(toJson(output), String.join(",", new TreeSet<>(output.keySet())));
+    }
+
+    /** 条码类:两个键名都收;都给且值不同 → 400;输出键名按配置(R33②)。 */
+    private static void collectBarcode(String wayCode, Map<String, Object> input,
+                                       String authCodeKey, Map<String, String> output) {
+        String underscore = text(input.get(AUTH_CODE_UNDERSCORE));
+        String camel = text(input.get(AUTH_CODE_CAMEL));
+        if (underscore == null && camel == null) {
+            throw new BusinessException(400, "条码支付必须传付款码(auth_code 或 authCode): " + wayCode);
+        }
+        if (underscore != null && camel != null && !underscore.equals(camel)) {
+            throw new BusinessException(400, "auth_code 与 authCode 不一致: " + wayCode);
+        }
+
+        String code = underscore != null ? underscore : camel;
+        switch (normalizedAuthCodeKey(authCodeKey)) {
+            case AUTH_CODE_CAMEL -> output.put(AUTH_CODE_CAMEL, code);
+            case AUTH_CODE_BOTH -> {
+                output.put(AUTH_CODE_UNDERSCORE, code);
+                output.put(AUTH_CODE_CAMEL, code);
+            }
+            default -> output.put(AUTH_CODE_UNDERSCORE, code);
+        }
+    }
+
+    /** ALI_JSAPI:buyerUserId 必传。 */
+    private static void collectAliJsapi(Map<String, Object> input, Map<String, String> output) {
+        String buyerUserId = text(input.get("buyerUserId"));
+        if (buyerUserId == null) {
+            throw new BusinessException(400, "ALI_JSAPI 必须传 buyerUserId");
+        }
+        output.put("buyerUserId", buyerUserId);
+    }
+
+    /** WX_JSAPI / WX_LITE:openid 必传,wxAppId 可选。 */
+    private static void collectWxJsapi(String wayCode, Map<String, Object> input,
+                                       Map<String, String> output) {
+        String openid = text(input.get("openid"));
+        if (openid == null) {
+            throw new BusinessException(400, wayCode + " 必须传 openid");
+        }
+        output.put("openid", openid);
+
+        String wxAppId = text(input.get("wxAppId"));
+        if (wxAppId != null) {
+            output.put("wxAppId", wxAppId);
+        }
+    }
+
+    /** YSF_JSAPI / WX_H5:openid 可选透传(R19②)。 */
+    private static void collectOpenid(Map<String, Object> input, Map<String, String> output) {
+        String openid = text(input.get("openid"));
+        if (openid != null) {
+            output.put("openid", openid);
+        }
+    }
+
+    /** 主扫 / WAP / PC:payDataType 可选;未知值丢弃并只记键名,不 400(R19①)。 */
+    private static void collectPayDataType(String wayCode, Map<String, Object> input,
+                                           Map<String, String> output) {
+        if (!input.containsKey(PAY_DATA_TYPE)) {
+            return;
+        }
+        String canonical = canonicalPayDataType(input.get(PAY_DATA_TYPE), wayCode);
+        if (canonical != null) {
+            output.put(PAY_DATA_TYPE, canonical);
+        } else {
+            log.warn("channelExtra payDataType 未知值已丢弃: wayCode={}, key={}", wayCode, PAY_DATA_TYPE);
+        }
+    }
+
+    /** 大小写不敏感匹配已知值并输出规范拼写;未知值或该 wayCode 不允许的值返回 null。 */
+    private static String canonicalPayDataType(Object raw, String wayCode) {
+        if (raw == null) {
+            return null;
+        }
+        String canonical = PAY_DATA_TYPE_CANONICAL.get(String.valueOf(raw).toLowerCase(Locale.ROOT));
+        if (canonical == null) {
+            return null;
+        }
+        return allowedPayDataTypes(wayCode).contains(canonical) ? canonical : null;
+    }
+
+    private static Set<String> allowedPayDataTypes(String wayCode) {
+        if (ALI_WAP.equals(wayCode)) {
+            return WAP_PAY_TYPES;
+        }
+        if (ALI_PC.equals(wayCode)) {
+            return PC_PAY_TYPES;
+        }
+        return CODE_PAY_TYPES;
+    }
+
+    /** R19③:null/空白/未知值 → {@code auth_code}。 */
+    private static String normalizedAuthCodeKey(String authCodeKey) {
+        if (authCodeKey == null) {
+            return AUTH_CODE_UNDERSCORE;
+        }
+        String trimmed = authCodeKey.trim();
+        if (AUTH_CODE_CAMEL.equals(trimmed)) {
+            return AUTH_CODE_CAMEL;
+        }
+        if (AUTH_CODE_BOTH.equals(trimmed)) {
+            return AUTH_CODE_BOTH;
+        }
+        return AUTH_CODE_UNDERSCORE;
+    }
+
+    /** 各 wayCode 接受的入参键名(未知键按此判断)。 */
+    private static Set<String> acceptedInputKeys(String wayCode) {
+        if (BARCODE.contains(wayCode)) {
+            return AUTH_KEYS;
+        }
+        return switch (wayCode) {
+            case "ALI_JSAPI" -> BUYER_USER_ID_KEYS;
+            case "WX_JSAPI", "WX_LITE" -> WX_JSAPI_KEYS;
+            case "YSF_JSAPI", "WX_H5" -> OPENID_KEYS;
+            case "QR_CASHIER", "ALI_QR", "WX_NATIVE", "ALI_WAP", "ALI_PC" -> PAY_TYPE_KEYS;
+            default -> NO_KEYS;
+        };
+    }
+
+    /** 未知键丢弃,只记 wayCode 与键名(绝不记值)。 */
+    private static void warnUnknownKeys(String wayCode, Map<String, Object> input) {
+        Set<String> accepted = acceptedInputKeys(wayCode);
+        TreeSet<String> unknown = new TreeSet<>();
+        for (String key : input.keySet()) {
+            if (key != null && !accepted.contains(key)) {
+                unknown.add(key);
+            }
+        }
+        if (!unknown.isEmpty()) {
+            log.warn("channelExtra 未知键已丢弃: wayCode={}, keys={}", wayCode, unknown);
+        }
+    }
+
+    /** 非 null 且非空白才算「给了」;值统一转字符串(上游这些字段都是字符串)。 */
+    private static String text(Object value) {
+        if (value == null) {
+            return null;
+        }
+        String text = String.valueOf(value);
+        return text.isBlank() ? null : text;
+    }
+
+    private static String toJson(Map<String, String> output) {
+        try {
+            return MAPPER.writeValueAsString(output);
+        } catch (JsonProcessingException e) {
+            throw new BusinessException(500, "channelExtra 序列化失败");
+        }
+    }
+
+    /** 组装结果:上游要的紧凑 JSON 字符串,以及键名 CSV(值不落库、不进日志)。 */
+    public record Result(String json, String keysCsv) {
+    }
+}

+ 377 - 0
java/src/test/java/com/payment/platform/module/payment/unified/util/UnifiedChannelExtraAssemblerTest.java

@@ -0,0 +1,377 @@
+package com.payment.platform.module.payment.unified.util;
+
+import com.payment.platform.common.exception.BusinessException;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.function.Executable;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * {@code channelExtra} 组装与 16 种 {@code wayCode}(spec §4.6 / Task 8 brief,裁定 R19/R32/R33)。
+ *
+ * <p>三条容易写错的边界:
+ * <ul>
+ *   <li>spec 表里的「默认 codeUrl/payUrl」是**上游缺省行为**,本组装器不注入默认值(R33①);</li>
+ *   <li>{@code payDataType} 只做大小写不敏感匹配并输出规范拼写;未知值丢弃并 warn,**不 400**(R19①);</li>
+ *   <li>付款码两个键名都给且值不同 → 400;{@code authCodeKey="both"} 时输出两个键、同一值(R33②)。</li>
+ * </ul>
+ */
+class UnifiedChannelExtraAssemblerTest {
+
+    private static final Set<String> ALL_WAY_CODES = Set.of(
+            "QR_CASHIER", "AUTO_BAR", "ALI_BAR", "ALI_JSAPI", "ALI_APP", "ALI_WAP", "ALI_PC", "ALI_QR",
+            "WX_BAR", "WX_JSAPI", "WX_LITE", "WX_APP", "WX_H5", "WX_NATIVE", "YSF_BAR", "YSF_JSAPI");
+
+    private static final Set<String> BARCODE_WAY_CODES =
+            Set.of("AUTO_BAR", "ALI_BAR", "WX_BAR", "YSF_BAR");
+
+    /** 插入顺序稳定的入参 map;{@code Map.of} 不收 null 值,这里要覆盖 null 场景。 */
+    private static Map<String, Object> extras(Object... pairs) {
+        Map<String, Object> map = new LinkedHashMap<>();
+        for (int i = 0; i < pairs.length; i += 2) {
+            map.put((String) pairs[i], pairs[i + 1]);
+        }
+        return map;
+    }
+
+    private static BusinessException assert400(Executable call) {
+        BusinessException e = assertThrows(BusinessException.class, call);
+        assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
+        return e;
+    }
+
+    /** 每个 wayCode 的最小合法入参(只有必传项;可选/无参数类给空 map)。 */
+    private static Map<String, Object> minimalExtras(String wayCode) {
+        return switch (wayCode) {
+            case "AUTO_BAR", "ALI_BAR", "WX_BAR", "YSF_BAR" -> extras("auth_code", "123456");
+            case "ALI_JSAPI" -> extras("buyerUserId", "buyer-1");
+            case "WX_JSAPI", "WX_LITE" -> extras("openid", "openid-1");
+            default -> Map.of();
+        };
+    }
+
+    private static boolean producesOutputForMinimalExtras(String wayCode) {
+        return switch (wayCode) {
+            case "AUTO_BAR", "ALI_BAR", "WX_BAR", "YSF_BAR",
+                 "ALI_JSAPI", "WX_JSAPI", "WX_LITE" -> true;
+            default -> false;
+        };
+    }
+
+    // ─────────────────────────── wayCode 集合 ───────────────────────────
+
+    @Test
+    void supportedContainsExactlySixteenVerbatimWayCodes() {
+        Set<String> expected = Set.of(
+                "QR_CASHIER", "AUTO_BAR", "ALI_BAR", "ALI_JSAPI", "ALI_APP", "ALI_WAP", "ALI_PC", "ALI_QR",
+                "WX_BAR", "WX_JSAPI", "WX_LITE", "WX_APP", "WX_H5", "WX_NATIVE", "YSF_BAR", "YSF_JSAPI");
+
+        assertEquals(16, expected.size(), "测试自身的期望集合必须是 16 个");
+        assertEquals(16, UnifiedChannelExtraAssembler.SUPPORTED.size(),
+                "实测: " + UnifiedChannelExtraAssembler.SUPPORTED);
+        assertTrue(UnifiedChannelExtraAssembler.SUPPORTED.containsAll(expected),
+                "实测: " + UnifiedChannelExtraAssembler.SUPPORTED);
+        assertFalse(UnifiedChannelExtraAssembler.SUPPORTED.contains("qr_cashier"),
+                "wayCode 逐字大小写敏感,小写不得被接受");
+    }
+
+    @Test
+    void allSixteenWayCodesAcceptedWithMinimalExtras() {
+        for (String wayCode : ALL_WAY_CODES) {
+            UnifiedChannelExtraAssembler.Result result =
+                    UnifiedChannelExtraAssembler.assemble(wayCode, minimalExtras(wayCode), "auth_code");
+
+            if (producesOutputForMinimalExtras(wayCode)) {
+                assertNotNull(result.json(), wayCode + " 的必传项已给,应产生 channelExtra");
+                assertNotNull(result.keysCsv(), wayCode);
+            } else {
+                assertNull(result.json(), wayCode + " 没有必传/已给参数,键不应出现");
+                assertNull(result.keysCsv(), wayCode);
+            }
+        }
+    }
+
+    @Test
+    void unknownWayCode_rejected() {
+        for (String wayCode : new String[]{"XX", "qr_cashier", "", null}) {
+            BusinessException e = assert400(
+                    () -> UnifiedChannelExtraAssembler.assemble(wayCode, Map.of(), "auth_code"));
+            assertTrue(e.getMessage().contains("不支持的 wayCode"), "实测消息: " + e.getMessage());
+        }
+    }
+
+    // ─────────────────────────── 条码类:付款码键名 ───────────────────────────
+
+    @Test
+    void barcodeWayCodes_acceptBothPaymentCodeKeyNames() {
+        for (String wayCode : BARCODE_WAY_CODES) {
+            UnifiedChannelExtraAssembler.Result underscore =
+                    UnifiedChannelExtraAssembler.assemble(wayCode, extras("auth_code", "123456"), null);
+            assertEquals("{\"auth_code\":\"123456\"}", underscore.json(), wayCode);
+            assertEquals("auth_code", underscore.keysCsv(), wayCode);
+
+            UnifiedChannelExtraAssembler.Result camel =
+                    UnifiedChannelExtraAssembler.assemble(wayCode, extras("authCode", "123456"), "auth_code");
+            assertEquals("{\"auth_code\":\"123456\"}", camel.json(),
+                    wayCode + ":对外两个键名都收,转上游按配置发 auth_code");
+            assertEquals("auth_code", camel.keysCsv(), wayCode);
+        }
+    }
+
+    @Test
+    void barcodeWayCodes_missingPaymentCode_throws400() {
+        for (String wayCode : BARCODE_WAY_CODES) {
+            BusinessException e = assert400(
+                    () -> UnifiedChannelExtraAssembler.assemble(wayCode, Map.of(), "auth_code"));
+            assertTrue(e.getMessage().contains("付款码"), wayCode + " 实测消息: " + e.getMessage());
+
+            assert400(() -> UnifiedChannelExtraAssembler.assemble(wayCode, null, "auth_code"));
+            assert400(() -> UnifiedChannelExtraAssembler.assemble(
+                    wayCode, extras("auth_code", "   "), "auth_code"));
+        }
+    }
+
+    @Test
+    void barcode_bothPaymentCodeKeysEqual_accepted() {
+        UnifiedChannelExtraAssembler.Result result = UnifiedChannelExtraAssembler.assemble(
+                "AUTO_BAR", extras("auth_code", "123456", "authCode", "123456"), "auth_code");
+
+        assertEquals("{\"auth_code\":\"123456\"}", result.json());
+        assertEquals("auth_code", result.keysCsv());
+    }
+
+    @Test
+    void barcode_bothPaymentCodeKeysDiffer_throws400() {
+        BusinessException e = assert400(() -> UnifiedChannelExtraAssembler.assemble(
+                "WX_BAR", extras("auth_code", "123456", "authCode", "654321"), "auth_code"));
+        assertTrue(e.getMessage().contains("不一致"), "实测消息: " + e.getMessage());
+    }
+
+    @Test
+    void barcode_authCodeKeyAuthCode_emitsCamelCaseKey() {
+        UnifiedChannelExtraAssembler.Result result = UnifiedChannelExtraAssembler.assemble(
+                "ALI_BAR", extras("auth_code", "123456"), "authCode");
+
+        assertEquals("{\"authCode\":\"123456\"}", result.json());
+        assertEquals("authCode", result.keysCsv());
+    }
+
+    @Test
+    void barcode_authCodeKeyBoth_emitsBothKeysWithSameValue() {
+        UnifiedChannelExtraAssembler.Result result = UnifiedChannelExtraAssembler.assemble(
+                "YSF_BAR", extras("auth_code", "123456"), "both");
+
+        assertEquals("{\"auth_code\":\"123456\",\"authCode\":\"123456\"}", result.json());
+        // 自然序(ASCII):'C' < '_',所以 authCode 在前
+        assertEquals("authCode,auth_code", result.keysCsv());
+    }
+
+    @Test
+    void barcode_authCodeKeyBoth_withOnlyCamelInput_emitsBothKeys() {
+        UnifiedChannelExtraAssembler.Result result = UnifiedChannelExtraAssembler.assemble(
+                "AUTO_BAR", extras("authCode", "123456"), "both");
+
+        assertEquals("{\"auth_code\":\"123456\",\"authCode\":\"123456\"}", result.json());
+        assertEquals("authCode,auth_code", result.keysCsv());
+    }
+
+    @Test
+    void barcode_authCodeKeyBoth_differingValues_throws400() {
+        assert400(() -> UnifiedChannelExtraAssembler.assemble(
+                "AUTO_BAR", extras("auth_code", "1", "authCode", "2"), "both"));
+    }
+
+    @Test
+    void barcode_authCodeKeyNullOrBlankOrUnknown_fallsBackToUnderscore() {
+        for (String authCodeKey : new String[]{null, "", "   ", "AUTHCODE", "unexpected"}) {
+            UnifiedChannelExtraAssembler.Result result = UnifiedChannelExtraAssembler.assemble(
+                    "AUTO_BAR", extras("auth_code", "123456"), authCodeKey);
+            assertEquals("{\"auth_code\":\"123456\"}", result.json(), "配置=" + authCodeKey);
+        }
+    }
+
+    // ─────────────────────────── JSAPI 类 ───────────────────────────
+
+    @Test
+    void aliJsapi_requiresBuyerUserId() {
+        assert400(() -> UnifiedChannelExtraAssembler.assemble("ALI_JSAPI", Map.of(), null));
+        assert400(() -> UnifiedChannelExtraAssembler.assemble(
+                "ALI_JSAPI", extras("buyerUserId", "   "), null));
+
+        UnifiedChannelExtraAssembler.Result result = UnifiedChannelExtraAssembler.assemble(
+                "ALI_JSAPI", extras("buyerUserId", "buyer-1"), null);
+        assertEquals("{\"buyerUserId\":\"buyer-1\"}", result.json());
+        assertEquals("buyerUserId", result.keysCsv());
+    }
+
+    @Test
+    void wxJsapiAndLite_requireOpenid() {
+        for (String wayCode : new String[]{"WX_JSAPI", "WX_LITE"}) {
+            assert400(() -> UnifiedChannelExtraAssembler.assemble(wayCode, Map.of(), null));
+
+            UnifiedChannelExtraAssembler.Result result = UnifiedChannelExtraAssembler.assemble(
+                    wayCode, extras("openid", "openid-1"), null);
+            assertEquals("{\"openid\":\"openid-1\"}", result.json(), wayCode);
+            assertEquals("openid", result.keysCsv(), wayCode);
+        }
+    }
+
+    @Test
+    void wxJsapi_withWxAppId_emitsBothKeys() {
+        UnifiedChannelExtraAssembler.Result result = UnifiedChannelExtraAssembler.assemble(
+                "WX_LITE", extras("openid", "openid-1", "wxAppId", "wx-app-1"), null);
+
+        assertEquals("{\"openid\":\"openid-1\",\"wxAppId\":\"wx-app-1\"}", result.json());
+        assertEquals("openid,wxAppId", result.keysCsv(), "keysCsv 按字母序");
+        assertFalse(result.json().contains(" "), "JSON 必须紧凑,不能有空白");
+    }
+
+    @Test
+    void ysfJsapi_openidIsOptional() {
+        UnifiedChannelExtraAssembler.Result absent =
+                UnifiedChannelExtraAssembler.assemble("YSF_JSAPI", Map.of(), null);
+        assertNull(absent.json());
+        assertNull(absent.keysCsv());
+
+        UnifiedChannelExtraAssembler.Result present = UnifiedChannelExtraAssembler.assemble(
+                "YSF_JSAPI", extras("openid", "openid-1"), null);
+        assertEquals("{\"openid\":\"openid-1\"}", present.json());
+        assertEquals("openid", present.keysCsv());
+    }
+
+    // ─────────────────────────── 主扫 / PC / WAP 的 payDataType ───────────────────────────
+
+    @Test
+    void qrCashier_withoutExtras_returnsNullJsonAndKeys() {
+        UnifiedChannelExtraAssembler.Result result =
+                UnifiedChannelExtraAssembler.assemble("QR_CASHIER", Map.of(), null);
+
+        assertNull(result.json(), "未传 payDataType 时不注入默认值(默认值是上游行为)");
+        assertNull(result.keysCsv());
+        assertNull(UnifiedChannelExtraAssembler.assemble("QR_CASHIER", null, null).json());
+    }
+
+    @Test
+    void qrCashier_payDataTypeIsCaseInsensitiveAndCanonical() {
+        UnifiedChannelExtraAssembler.Result lower = UnifiedChannelExtraAssembler.assemble(
+                "QR_CASHIER", extras("payDataType", "codeimgurl"), null);
+        assertEquals("{\"payDataType\":\"codeImgUrl\"}", lower.json());
+        assertEquals("payDataType", lower.keysCsv());
+
+        UnifiedChannelExtraAssembler.Result upper = UnifiedChannelExtraAssembler.assemble(
+                "QR_CASHIER", extras("payDataType", "CODEURL"), null);
+        assertEquals("{\"payDataType\":\"codeUrl\"}", upper.json());
+    }
+
+    @Test
+    void qrCashier_unknownPayDataTypeIsDroppedNotRejected() {
+        for (Object value : new Object[]{"form", "garbage", null, "   "}) {
+            UnifiedChannelExtraAssembler.Result result = UnifiedChannelExtraAssembler.assemble(
+                    "QR_CASHIER", extras("payDataType", value), null);
+            assertNull(result.json(), "未知值必须丢弃而不是 400,实测值=" + value);
+            assertNull(result.keysCsv(), "未知值必须丢弃而不是 400,实测值=" + value);
+        }
+    }
+
+    @Test
+    void aliQrAndWxNative_acceptOnlyCodeUrlAndCodeImgUrl() {
+        for (String wayCode : new String[]{"ALI_QR", "WX_NATIVE"}) {
+            assertEquals("{\"payDataType\":\"codeUrl\"}", UnifiedChannelExtraAssembler.assemble(
+                    wayCode, extras("payDataType", "codeUrl"), null).json(), wayCode);
+            assertEquals("{\"payDataType\":\"codeImgUrl\"}", UnifiedChannelExtraAssembler.assemble(
+                    wayCode, extras("payDataType", "CODEIMGURL"), null).json(), wayCode);
+            assertNull(UnifiedChannelExtraAssembler.assemble(
+                    wayCode, extras("payDataType", "payUrl"), null).json(),
+                    wayCode + " 不接受 payUrl");
+        }
+    }
+
+    @Test
+    void aliWap_acceptsFormCodeImgUrlPayUrl_andCanonicalizesPayurl() {
+        assertEquals("{\"payDataType\":\"form\"}", UnifiedChannelExtraAssembler.assemble(
+                "ALI_WAP", extras("payDataType", "FORM"), null).json());
+        assertEquals("{\"payDataType\":\"codeImgUrl\"}", UnifiedChannelExtraAssembler.assemble(
+                "ALI_WAP", extras("payDataType", "codeimgurl"), null).json());
+        assertEquals("{\"payDataType\":\"payUrl\"}", UnifiedChannelExtraAssembler.assemble(
+                "ALI_WAP", extras("payDataType", "payurl"), null).json(),
+                "调用方传 payurl 一律按规范拼写 payUrl 输出");
+        assertNull(UnifiedChannelExtraAssembler.assemble(
+                "ALI_WAP", extras("payDataType", "codeUrl"), null).json(),
+                "ALI_WAP 不接受 codeUrl");
+    }
+
+    @Test
+    void aliPc_acceptsFormAndPayUrlOnly() {
+        assertEquals("{\"payDataType\":\"form\"}", UnifiedChannelExtraAssembler.assemble(
+                "ALI_PC", extras("payDataType", "form"), null).json());
+        assertEquals("{\"payDataType\":\"payUrl\"}", UnifiedChannelExtraAssembler.assemble(
+                "ALI_PC", extras("payDataType", "PAYURL"), null).json());
+        assertNull(UnifiedChannelExtraAssembler.assemble(
+                "ALI_PC", extras("payDataType", "codeImgUrl"), null).json(),
+                "ALI_PC 不接受 codeImgUrl");
+    }
+
+    @Test
+    void payDataTypeOnOtherWayCodes_isDroppedNotRejected() {
+        UnifiedChannelExtraAssembler.Result barcode = UnifiedChannelExtraAssembler.assemble(
+                "AUTO_BAR", extras("auth_code", "123456", "payDataType", "codeUrl"), null);
+        assertEquals("{\"auth_code\":\"123456\"}", barcode.json());
+        assertEquals("auth_code", barcode.keysCsv());
+
+        assertEquals("{\"buyerUserId\":\"buyer-1\"}", UnifiedChannelExtraAssembler.assemble(
+                "ALI_JSAPI", extras("buyerUserId", "buyer-1", "payDataType", "codeUrl"), null).json());
+        assertEquals("{\"openid\":\"openid-1\"}", UnifiedChannelExtraAssembler.assemble(
+                "WX_H5", extras("openid", "openid-1", "payDataType", "payUrl"), null).json());
+
+        assertNull(UnifiedChannelExtraAssembler.assemble(
+                "ALI_APP", extras("payDataType", "codeUrl"), null).json());
+        assertNull(UnifiedChannelExtraAssembler.assemble(
+                "WX_APP", extras("payDataType", "codeUrl"), null).json());
+    }
+
+    // ─────────────────────────── H5 / APP 与未知键 ───────────────────────────
+
+    @Test
+    void wxH5_openidIsOptional() {
+        UnifiedChannelExtraAssembler.Result absent =
+                UnifiedChannelExtraAssembler.assemble("WX_H5", Map.of(), null);
+        assertNull(absent.json());
+        assertNull(absent.keysCsv());
+
+        UnifiedChannelExtraAssembler.Result present = UnifiedChannelExtraAssembler.assemble(
+                "WX_H5", extras("openid", "openid-1"), null);
+        assertEquals("{\"openid\":\"openid-1\"}", present.json());
+    }
+
+    @Test
+    void appWayCodes_haveNoChannelExtraParameters() {
+        for (String wayCode : new String[]{"ALI_APP", "WX_APP"}) {
+            UnifiedChannelExtraAssembler.Result result = UnifiedChannelExtraAssembler.assemble(
+                    wayCode, extras("foo", "bar"), null);
+            assertNull(result.json(), wayCode);
+            assertNull(result.keysCsv(), wayCode);
+        }
+    }
+
+    @Test
+    void unknownKeysDropped_knownKeysKept() {
+        UnifiedChannelExtraAssembler.Result wx = UnifiedChannelExtraAssembler.assemble(
+                "WX_JSAPI", extras("openid", "openid-1", "foo", "bar"), null);
+        assertEquals("{\"openid\":\"openid-1\"}", wx.json());
+        assertEquals("openid", wx.keysCsv(), "keysCsv 不得包含未知键 foo");
+
+        UnifiedChannelExtraAssembler.Result qr = UnifiedChannelExtraAssembler.assemble(
+                "QR_CASHIER", extras("foo", "bar"), null);
+        assertNull(qr.json(), "只给未知键时不抛错,结果为空");
+        assertNull(qr.keysCsv());
+    }
+}