|
|
@@ -1,22 +1,29 @@
|
|
|
package com.payment.platform.module.payment.leshouying.service;
|
|
|
|
|
|
+import ch.qos.logback.classic.Logger;
|
|
|
+import ch.qos.logback.classic.spi.ILoggingEvent;
|
|
|
+import ch.qos.logback.core.read.ListAppender;
|
|
|
import com.payment.platform.common.exception.BusinessException;
|
|
|
import com.payment.platform.module.payment.leshouying.client.LeshouyingClient;
|
|
|
import com.payment.platform.module.payment.leshouying.client.LeshouyingResponseParser;
|
|
|
import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties;
|
|
|
import com.payment.platform.module.payment.leshouying.dto.LsyAreaItemDTO;
|
|
|
+import com.payment.platform.module.payment.leshouying.dto.LsyMerchantCreateDTO;
|
|
|
import com.payment.platform.module.payment.leshouying.dto.LsyReviewStatusDTO;
|
|
|
import com.payment.platform.module.payment.leshouying.dto.LsyShopCreateDTO;
|
|
|
import com.payment.platform.module.payment.leshouying.dto.LsyShopItemDTO;
|
|
|
+import com.payment.platform.module.payment.leshouying.enums.LsyMerchantCategory;
|
|
|
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.slf4j.LoggerFactory;
|
|
|
|
|
|
import java.nio.charset.StandardCharsets;
|
|
|
import java.util.LinkedHashMap;
|
|
|
+import java.util.LinkedHashSet;
|
|
|
import java.util.List;
|
|
|
import java.util.Map;
|
|
|
import java.util.Set;
|
|
|
@@ -1192,4 +1199,668 @@ class LsyOnboardingServiceTest {
|
|
|
assertEquals(longName, sent.fileName(), "长文件名必须原样送出,不得截断");
|
|
|
assertEquals(longName, sent.formFields().get("img_name"), "img_name 同样不截断");
|
|
|
}
|
|
|
+
|
|
|
+ // ==================== Task 3:创建商户(上游 page 196236555,路径 /api/merchant/create) ====================
|
|
|
+ //
|
|
|
+ // 出站参数 = 服务层生成的信封位(trace_no)+ 配置三项(notify_url / channel_id / bd_id / rate_code)
|
|
|
+ // + 类别派生两项(merchant_type / account_type)+ DTO 的 54 个业务字段
|
|
|
+ // (inst_no / sign 由 LeshouyingClient 在客户端内部补 —— 本类 mock 的正是那个客户端。)
|
|
|
+
|
|
|
+ /** 夹具:通道 id。上游参数表声明它 {@code Integer(11)}。★ 夹具值**不是**线上凭据(GC 4「不留假值」管的是往配置里填)。 */
|
|
|
+ private static final String CFG_CHANNEL_ID = "1001";
|
|
|
+
|
|
|
+ /** 夹具:业务经理 id(上游声明 {@code Integer(11)})。 */
|
|
|
+ private static final String CFG_BD_ID = "2002";
|
|
|
+
|
|
|
+ /** 夹具:通道类型。创建商户这条腿不送它,只为过凭据闸。 */
|
|
|
+ private static final String CFG_CHANNEL_TYPE = "3";
|
|
|
+
|
|
|
+ /** 夹具:费率模板(文档给的格式示例就是 {@code RT0045})。 */
|
|
|
+ private static final String CFG_RATE_CODE = "RT0045";
|
|
|
+
|
|
|
+ /** 夹具:**进件审核**通知地址。 */
|
|
|
+ private static final String CFG_ONBOARDING_NOTIFY_URL = "https://example.com/xjz/notify/lsy/onboarding";
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 夹具:**收款**通知地址 —— 与 {@link #CFG_ONBOARDING_NOTIFY_URL} 逐字不同。
|
|
|
+ *
|
|
|
+ * <p>两个地址必须在**同一次装配里同时设上**:只设进件那个的话,「取的是哪一个」在断言下不可见
|
|
|
+ * (spec §6.5 明确这是两个不同的地址)。
|
|
|
+ */
|
|
|
+ private static final String CFG_PAY_NOTIFY_URL = "https://example.com/xjz/notify/lsy/pay";
|
|
|
+
|
|
|
+ /** 让 {@code createMerchant} 过得了第一步的凭据闸 —— 五个键缺一个都过不去。 */
|
|
|
+ private void withOnboardingCredentials() {
|
|
|
+ props.setChannelId(CFG_CHANNEL_ID);
|
|
|
+ props.setBdId(CFG_BD_ID);
|
|
|
+ props.setChannelType(CFG_CHANNEL_TYPE);
|
|
|
+ props.setRateCode(CFG_RATE_CODE);
|
|
|
+ props.setOnboardingNotifyUrl(CFG_ONBOARDING_NOTIFY_URL);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 创建商户成功的应答:page 196236555 返回参数表的成功形状,只留本类要读的键。 */
|
|
|
+ private static final String MERCHANT_CREATE_OK =
|
|
|
+ "{\"result_code\":\"SUCCESS\",\"result_msg\":\"操作成功\",\"merchant_no\":\"851901070001\"}";
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 一份**逐字段填满**的创建商户入参:类别取 {@link LsyMerchantCategory#MICRO}({@code (3,4)})。
|
|
|
+ *
|
|
|
+ * <p>★ <b>每个字段的值就是它自己的上游键名</b>({@code merchant_name → "merchant_name"})。
|
|
|
+ * 这一条同时办了两件事:
|
|
|
+ * <ol>
|
|
|
+ * <li><b>每个字段一个独特值</b> ⇒ 任何一处「A 字段取到了 B 的 getter」都会让**值**对不上
|
|
|
+ * (Task 2 评审的裁定 d:夹具只有「全填 / 全空」两态时,接错 getter 完全不可见
|
|
|
+ * —— 那种夹具下 A 与 B 的值同生同灭);</li>
|
|
|
+ * <li>期望表因此可以写成**字面量**(见 {@link #expectedMerchantParams()})。</li>
|
|
|
+ * </ol>
|
|
|
+ *
|
|
|
+ * <p>★ <b>{@code picture_other} 故意留 {@code null}</b>:钉住「未填的可选字段保持 null」——
|
|
|
+ * 既不得变成字符串 {@code "null"}(那会原样上送),也不得变成空串(本仓签名规则是
|
|
|
+ * <b>null 不参与、空串参与</b>,凭空多一项 {@code picture_other=} 进签名原文)。
|
|
|
+ * 其余 53 个字段各有独特值,所以这一处 null 不削弱上面第 1 条。
|
|
|
+ *
|
|
|
+ * <p>值不写成真手机号 / 真身份证:{@code account_idcard_num} 与 {@code account_card_num}
|
|
|
+ * 在本模块是 PII(GC 5),用例里放一眼可辨的假值比放形似真值的串安全。
|
|
|
+ */
|
|
|
+ private static LsyMerchantCreateDTO merchantDto() {
|
|
|
+ LsyMerchantCreateDTO dto = new LsyMerchantCreateDTO();
|
|
|
+ dto.setCategory(LsyMerchantCategory.MICRO);
|
|
|
+ dto.setMerchantName("merchant_name");
|
|
|
+ dto.setMerchantNameShort("merchant_name_short");
|
|
|
+ dto.setMerchantNameBusiness("merchant_name_business");
|
|
|
+ dto.setMerchantMccCode("merchant_mcc_code");
|
|
|
+ dto.setContactName("contact_name");
|
|
|
+ dto.setContactPhone("contact_phone");
|
|
|
+ dto.setContactEmail("contact_email");
|
|
|
+ dto.setContactService("contact_service");
|
|
|
+ dto.setAddressProvince("address_province");
|
|
|
+ dto.setAddressCity("address_city");
|
|
|
+ dto.setAddressCounty("address_county");
|
|
|
+ dto.setAddressStreet("address_street");
|
|
|
+ dto.setLicenseType("license_type");
|
|
|
+ dto.setLicenseNo("license_no");
|
|
|
+ dto.setLicenseStt("license_stt");
|
|
|
+ dto.setLicenseExpire("license_expire");
|
|
|
+ dto.setLegalName("legal_name");
|
|
|
+ dto.setLegalCertNo("legal_cert_no");
|
|
|
+ dto.setLegalCertStt("legal_cert_stt");
|
|
|
+ dto.setLegalCertExpire("legal_cert_expire");
|
|
|
+ dto.setAccountIdcardNum("account_idcard_num");
|
|
|
+ dto.setAccountIdcardStt("account_idcard_stt");
|
|
|
+ dto.setAccountIdcardExpire("account_idcard_expire");
|
|
|
+ dto.setAccountCardName("account_card_name");
|
|
|
+ dto.setAccountCardNum("account_card_num");
|
|
|
+ dto.setAccountCardPhone("account_card_phone");
|
|
|
+ dto.setAccountBankCode("account_bank_code");
|
|
|
+ dto.setRegCapital("reg_capital");
|
|
|
+ dto.setFSettleAccountType("fSettle_account_type");
|
|
|
+ dto.setActualAddr("actual_addr");
|
|
|
+ dto.setBusinessScope("business_scope");
|
|
|
+ dto.setCheckCode("check_code");
|
|
|
+ dto.setRateCode("rate_code");
|
|
|
+ dto.setPictureLicense("picture_license");
|
|
|
+ dto.setPictureIdcardA("picture_idcard_a");
|
|
|
+ dto.setPictureIdcardB("picture_idcard_b");
|
|
|
+ dto.setPictureBankcardA("picture_bankcard_a");
|
|
|
+ dto.setPictureBankcardB("picture_bankcard_b");
|
|
|
+ dto.setPictureOutside("picture_outside");
|
|
|
+ dto.setPictureInside("picture_inside");
|
|
|
+ dto.setLeaseContract("lease_contract");
|
|
|
+ dto.setPictureTripartite("picture_tripartite");
|
|
|
+ dto.setPictureIdcardHander("picture_idcard_hander");
|
|
|
+ dto.setPictureOpenAccount("picture_open_account");
|
|
|
+ dto.setPictureOrganization("picture_organization");
|
|
|
+ dto.setPictureTaxRegistration("picture_tax_registration");
|
|
|
+ dto.setPictureUnincorporated("picture_unincorporated");
|
|
|
+ dto.setPictureBankIdcardA("picture_bank_idcard_a");
|
|
|
+ dto.setPictureBankIdcardB("picture_bank_idcard_b");
|
|
|
+ dto.setPictureOther(null);
|
|
|
+ dto.setShareScene("share_scene");
|
|
|
+ dto.setShareCertificate("share_certificate");
|
|
|
+ dto.setTripartiteFlowCertificateUrl("tripartite_flow_certificate_url");
|
|
|
+ dto.setPCashier("p_cashier");
|
|
|
+ return dto;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 在 {@link #merchantDto()}(全填)上改一项。 */
|
|
|
+ private static LsyMerchantCreateDTO merchantDto(Consumer<LsyMerchantCreateDTO> mutation) {
|
|
|
+ LsyMerchantCreateDTO dto = merchantDto();
|
|
|
+ mutation.accept(dto);
|
|
|
+ return dto;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 出站参数的**字面期望表**:59 个键({@code trace_no} 另算 —— 它每次新生成,
|
|
|
+ * 见 {@link #createMerchant_traceNo_isFreshPerCall})。
|
|
|
+ *
|
|
|
+ * <p>★ <b>手写字面表,不从 {@code LsyMerchantCreateDTO} 反射生成</b>(Task 2 评审的裁定 h1):
|
|
|
+ * 反射生成的期望集是**自洽式断言** —— 实现改错它跟着一起错,恒绿。手写这 59 行换来的正是
|
|
|
+ * 「实现漏发一个键 ⇒ 红」。
|
|
|
+ *
|
|
|
+ * <p>★ <b>它覆盖 DTO 的全部 54 个字段</b>,其中 18 个是必填矩阵不管的「只带不判」字段
|
|
|
+ * ({@code license_stt} / {@code legal_cert_stt} / {@code account_idcard_stt};
|
|
|
+ * {@code reg_capital} / {@code fSettle_account_type} / {@code actual_addr} /
|
|
|
+ * {@code business_scope} / {@code check_code};{@code lease_contract} /
|
|
|
+ * {@code picture_tripartite} / {@code picture_organization} / {@code picture_tax_registration};
|
|
|
+ * {@code picture_other};{@code share_scene} / {@code share_certificate} /
|
|
|
+ * {@code tripartite_flow_certificate_url};{@code p_cashier};{@code rate_code})——
|
|
|
+ * 这 18 个在**全仓没有别的断言**证明它们真的在 DTO 上(Task 2 的用例钉的是矩阵报得出的 36 个)
|
|
|
+ * ⇒ 本表是它们唯一的钉子。
|
|
|
+ *
|
|
|
+ * <p>★ 值的类型也在表里:{@code channel_id} / {@code bd_id} 是 {@code Integer}
|
|
|
+ * (上游声明 {@code Integer(11)},而请求体是 JSON ⇒ {@code 1001} 与 {@code "1001"} 在**报文**上不同),
|
|
|
+ * 其余是字符串。
|
|
|
+ */
|
|
|
+ private static Map<String, Object> expectedMerchantParams() {
|
|
|
+ Map<String, Object> p = new LinkedHashMap<>();
|
|
|
+ // ---- 非 DTO 的 5 个:配置 4(rate_code 见下)+ 类别派生 2 ----
|
|
|
+ p.put("notify_url", CFG_ONBOARDING_NOTIFY_URL);
|
|
|
+ p.put("channel_id", 1001); // ★ Integer:配置里是字符串 "1001",上送时必须转成 Integer(11)
|
|
|
+ p.put("bd_id", 2002);
|
|
|
+ p.put("merchant_type", "3"); // LsyMerchantCategory.MICRO 的两个码值
|
|
|
+ p.put("account_type", "4");
|
|
|
+ // ---- DTO 的 54 个:值就是它自己的上游键名(rate_code 这里取 DTO 的值 ⇒ 覆盖配置)----
|
|
|
+ p.put("merchant_name", "merchant_name");
|
|
|
+ p.put("merchant_name_short", "merchant_name_short");
|
|
|
+ p.put("merchant_name_business", "merchant_name_business");
|
|
|
+ p.put("merchant_mcc_code", "merchant_mcc_code");
|
|
|
+ p.put("contact_name", "contact_name");
|
|
|
+ p.put("contact_phone", "contact_phone");
|
|
|
+ p.put("contact_email", "contact_email");
|
|
|
+ p.put("contact_service", "contact_service");
|
|
|
+ p.put("address_province", "address_province");
|
|
|
+ p.put("address_city", "address_city");
|
|
|
+ p.put("address_county", "address_county");
|
|
|
+ p.put("address_street", "address_street");
|
|
|
+ p.put("license_type", "license_type");
|
|
|
+ p.put("license_no", "license_no");
|
|
|
+ p.put("license_stt", "license_stt");
|
|
|
+ p.put("license_expire", "license_expire");
|
|
|
+ p.put("legal_name", "legal_name");
|
|
|
+ p.put("legal_cert_no", "legal_cert_no");
|
|
|
+ p.put("legal_cert_stt", "legal_cert_stt");
|
|
|
+ p.put("legal_cert_expire", "legal_cert_expire");
|
|
|
+ p.put("account_idcard_num", "account_idcard_num");
|
|
|
+ p.put("account_idcard_stt", "account_idcard_stt");
|
|
|
+ p.put("account_idcard_expire", "account_idcard_expire");
|
|
|
+ p.put("account_card_name", "account_card_name");
|
|
|
+ p.put("account_card_num", "account_card_num");
|
|
|
+ p.put("account_card_phone", "account_card_phone");
|
|
|
+ p.put("account_bank_code", "account_bank_code");
|
|
|
+ p.put("reg_capital", "reg_capital");
|
|
|
+ p.put("fSettle_account_type", "fSettle_account_type");
|
|
|
+ p.put("actual_addr", "actual_addr");
|
|
|
+ p.put("business_scope", "business_scope");
|
|
|
+ p.put("check_code", "check_code");
|
|
|
+ p.put("rate_code", "rate_code");
|
|
|
+ p.put("picture_license", "picture_license");
|
|
|
+ p.put("picture_idcard_a", "picture_idcard_a");
|
|
|
+ p.put("picture_idcard_b", "picture_idcard_b");
|
|
|
+ p.put("picture_bankcard_a", "picture_bankcard_a");
|
|
|
+ p.put("picture_bankcard_b", "picture_bankcard_b");
|
|
|
+ p.put("picture_outside", "picture_outside");
|
|
|
+ p.put("picture_inside", "picture_inside");
|
|
|
+ p.put("lease_contract", "lease_contract");
|
|
|
+ p.put("picture_tripartite", "picture_tripartite");
|
|
|
+ p.put("picture_idcard_hander", "picture_idcard_hander");
|
|
|
+ p.put("picture_open_account", "picture_open_account");
|
|
|
+ p.put("picture_organization", "picture_organization");
|
|
|
+ p.put("picture_tax_registration", "picture_tax_registration");
|
|
|
+ p.put("picture_unincorporated", "picture_unincorporated");
|
|
|
+ p.put("picture_bank_idcard_a", "picture_bank_idcard_a");
|
|
|
+ p.put("picture_bank_idcard_b", "picture_bank_idcard_b");
|
|
|
+ p.put("picture_other", null); // 夹具里这一项没填 ⇒ 原样 null(客户端按 null 剔除)
|
|
|
+ p.put("share_scene", "share_scene");
|
|
|
+ p.put("share_certificate", "share_certificate");
|
|
|
+ p.put("tripartite_flow_certificate_url", "tripartite_flow_certificate_url");
|
|
|
+ p.put("p_cashier", "p_cashier");
|
|
|
+ return p;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ 出站参数**恰好**是那 60 个键(59 个字面期望键 + 服务自己生成的 {@code trace_no}),
|
|
|
+ * 且**除 trace_no 外每个键的值(含类型)都对得上**。
|
|
|
+ *
|
|
|
+ * <p>两件事分开断言,因为它们的失效方式不同:
|
|
|
+ * <ul>
|
|
|
+ * <li><b>键集相等</b>(不是 {@code containsKey})挡漏发 / 多发 —— 多发的字段会进签名原文
|
|
|
+ * (本仓签名规则:null 不参与、**空串参与**),上游可能因此判「参数不合法」,
|
|
|
+ * 而失败形态只是一句笼统的话,根因不可见;</li>
|
|
|
+ * <li><b>值相等</b>挡「键发对了、值取错了」—— 夹具里每个字段的值就是它自己的上游键名,
|
|
|
+ * 所以「A 字段取了 B 的 getter」在这里必然显示为两个值对不上(裁定 d)。</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p>★ {@code trace_no} 在比较前被摘掉:它每次新生成,值本身不可预期;但它的**形状**照
|
|
|
+ * {@code createShop_sendsExactlyTheSevenServiceOwnedParams} 的做法单独钉(32 位字符串)。
|
|
|
+ * 另:本用例的夹具里 {@code rate_code} 有值 ⇒ 同时钉住「DTO 有值时以 DTO 为准」(计划 §0.8);
|
|
|
+ * 「DTO 没值 ⇒ 用配置」由 {@link #createMerchant_sendsConfiguredCredentials} 钉。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void createMerchant_sendsExactlyTheExpectedParams() {
|
|
|
+ withOnboardingCredentials();
|
|
|
+ stubUpstream(MERCHANT_CREATE_OK);
|
|
|
+
|
|
|
+ service.createMerchant(merchantDto());
|
|
|
+
|
|
|
+ Map<String, Object> sent = sentParams("/api/merchant/create");
|
|
|
+ Set<String> expectedKeys = new LinkedHashSet<>(expectedMerchantParams().keySet());
|
|
|
+ expectedKeys.add("trace_no");
|
|
|
+ assertEquals(expectedKeys, sent.keySet(),
|
|
|
+ "出站参数键集必须恰好是 60 个(59 个字面期望键 + trace_no):" + sent.keySet());
|
|
|
+ Object traceNo = sent.get("trace_no");
|
|
|
+ assertInstanceOf(String.class, traceNo, "trace_no 必须是字符串,实际: " + traceNo);
|
|
|
+ assertEquals(32, ((String) traceNo).length(),
|
|
|
+ "trace_no 必须是 IdUtil.fastSimpleUUID() 的形状(32 位),实际: " + traceNo);
|
|
|
+ Map<String, Object> actual = new LinkedHashMap<>(sent);
|
|
|
+ actual.remove("trace_no");
|
|
|
+ assertEquals(expectedMerchantParams(), actual,
|
|
|
+ "每个键的值(含类型)都要对得上 —— 夹具里每个字段的值就是它自己的上游键名,"
|
|
|
+ + "所以「A 字段取了 B 的 getter」也会在这里红");
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ 凭据闸:五个键缺任意一个就 400,且**一次上游调用都不发**。
|
|
|
+ *
|
|
|
+ * <p>两个子场景:
|
|
|
+ * <ol>
|
|
|
+ * <li>一个都没配 ⇒ 报「共 5 项」;</li>
|
|
|
+ * <li>只把 {@code bd-id} 置成空白(其余四个齐备)⇒ 报「共 1 项」——
|
|
|
+ * 钉住数的是**真的缺失数**,不是恒等于 5 的常数。</li>
|
|
|
+ * </ol>
|
|
|
+ *
|
|
|
+ * <p>★ 对外文案只给**中文固定文案 + 缺失项数**,明细走日志(R23 裁定):那串明细逐条含环境变量名
|
|
|
+ * {@code LESHOUYING_*},是**上游名号的罗马字**,拼进 {@link BusinessException} 的 message 就是越线
|
|
|
+ * (GC 1「文案零上游痕迹」/ spec §8.5 第 5 条)。这条 message 经 {@code GlobalExceptionHandler}
|
|
|
+ * 是 {@code Result.of(code, message)},**原样**回给调用方 ⇒ 它是会到租户眼前的那一类串。
|
|
|
+ * 明细的出口见 {@link #createMerchant_missingCredentials_detailGoesToTheLog}。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void createMerchant_missingCredentials_throws400ListingAll() {
|
|
|
+ // 整个方法**一次都不打桩**:凭据闸必须在开桩之前就把请求拦下
|
|
|
+ BusinessException none = assertThrows(BusinessException.class,
|
|
|
+ () -> service.createMerchant(merchantDto()), "凭据全缺必须被拒");
|
|
|
+ assertEquals(400, none.getCode());
|
|
|
+ assertCredentialsMessage(none.getMessage(), 5);
|
|
|
+
|
|
|
+ withOnboardingCredentials();
|
|
|
+ props.setBdId(" ");
|
|
|
+ BusinessException one = assertThrows(BusinessException.class,
|
|
|
+ () -> service.createMerchant(merchantDto()), "只缺 bd-id 也必须被拒");
|
|
|
+ assertEquals(400, one.getCode());
|
|
|
+ assertCredentialsMessage(one.getMessage(), 1);
|
|
|
+
|
|
|
+ verifyNoInteractions(client);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 凭据缺失的对外文案:中文固定文案 + **缺失项数**,且**不含任何明细**。
|
|
|
+ *
|
|
|
+ * <p>「不含明细」逐串断言(而不是只断言「不含 LESHOUYING」一条):明细里还会出现
|
|
|
+ * {@code leshouying.channel-id} 这种**配置键名**与 {@code onboarding-notify-url} 这种
|
|
|
+ * 内部地址名 —— 它们同样在教调用方猜服务端配置。
|
|
|
+ */
|
|
|
+ private void assertCredentialsMessage(String message, int missingCount) {
|
|
|
+ assertTrue(message.contains("共 " + missingCount + " 项"),
|
|
|
+ "对外文案要给出缺失**项数** " + missingCount + ",实际: " + message);
|
|
|
+ for (String leaked : new String[]{"LESHOUYING", "leshouying", "乐收赢",
|
|
|
+ "channel-id", "bd-id", "rate-code", "onboarding-notify-url"}) {
|
|
|
+ assertFalse(message.contains(leaked),
|
|
|
+ "凭据缺失的对外文案不得含「" + leaked + "」(GC 1;明细只进日志),实际: " + message);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 「明细走日志」的那一半:日志里**必须**有配置键名与环境变量名,运维才照得着去配。 */
|
|
|
+ @Test
|
|
|
+ void createMerchant_missingCredentials_detailGoesToTheLog() {
|
|
|
+ withLogCapture(LsyOnboardingService.class, events -> {
|
|
|
+ BusinessException ex = assertThrows(BusinessException.class,
|
|
|
+ () -> service.createMerchant(merchantDto()));
|
|
|
+ assertEquals(400, ex.getCode());
|
|
|
+ List<String> logged = messages(events);
|
|
|
+ // ★ 先断言「确实捕获到了事件」(正对照):挂错 logger 时,下面那条「日志里有明细」的
|
|
|
+ // 断言会在**空列表**上失败 —— 但若是把它写成「不含某串」的否定式断言,就会静默绿。
|
|
|
+ assertFalse(logged.isEmpty(), "凭据缺失必须留日志 —— 明细只有这一条出口");
|
|
|
+ String all = String.join("\n", logged);
|
|
|
+ for (String key : new String[]{"leshouying.channel-id", "LESHOUYING_CHANNEL_ID",
|
|
|
+ "leshouying.bd-id", "leshouying.rate-code", "leshouying.onboarding-notify-url"}) {
|
|
|
+ assertTrue(all.contains(key), "日志缺配置键名 / 环境变量名「" + key + "」,实际: " + all);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ 必填缺失 ⇒ 400,文案含**全部**缺失字段的**中文标签**(不是上游参数名),且一次上游调用都不发。
|
|
|
+ *
|
|
|
+ * <p>① 全空 + 小微 ⇒ 18 个无条件 + 小微那 8 个 = **26** 个,逐个点名;同时反向断言文案里
|
|
|
+ * 不得出现上游参数名(GC 1「文案零上游痕迹」:{@code fSettle_account_type}、
|
|
|
+ * {@code picture_idcard_hander} 这种键名出现在对外 400 文案里就是暴露上游词汇)。
|
|
|
+ * 依据还有本模块既有的 {@code requireText} javadoc:报键名的话,操作者得在文档里做一次心算
|
|
|
+ * 才能对上是界面上的哪个输入框 ⇒ <b>首要理由是人话</b>,安全性只是第二理由。
|
|
|
+ *
|
|
|
+ * <p>② 全填 + 类别为空 ⇒ 只缺两个派生字段。这条**顺带**钉住「18 个无条件字段真的按 DTO 取值」:
|
|
|
+ * 全填就该一个都不缺,若实现漏判某个字段的 getter(恒读 null),这里会多报一项。
|
|
|
+ *
|
|
|
+ * <p>★ 期望的标签是**字面中文**,不是 {@code LABELS.get("merchant_name")}(裁定 h2)——
|
|
|
+ * 后者是恒真式断言(表改了它跟着改,什么也钉不住)。Task 2 的用例只**抽查**了 6/62 个标签值,
|
|
|
+ * 本用例点到的这 26 个就是它们的第一批真钉子。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void createMerchant_missingRequiredFields_throws400ListingAll() {
|
|
|
+ withOnboardingCredentials();
|
|
|
+
|
|
|
+ LsyMerchantCreateDTO blank = new LsyMerchantCreateDTO();
|
|
|
+ blank.setCategory(LsyMerchantCategory.MICRO);
|
|
|
+ BusinessException ex = assertThrows(BusinessException.class,
|
|
|
+ () -> service.createMerchant(blank), "全空必须被拒");
|
|
|
+ assertEquals(400, ex.getCode());
|
|
|
+ assertTrue(ex.getMessage().contains("共 26 项"),
|
|
|
+ "小微商户的必填集是 18 + 8 = 26,实际: " + ex.getMessage());
|
|
|
+ for (String label : MERCHANT_MICRO_MISSING_LABELS) {
|
|
|
+ assertTrue(ex.getMessage().contains(label),
|
|
|
+ "文案缺中文标签「" + label + "」,实际: " + ex.getMessage());
|
|
|
+ }
|
|
|
+ for (String key : new String[]{"merchant_name", "merchant_mcc_code", "account_idcard_num",
|
|
|
+ "fSettle_account_type", "picture_idcard_hander", "mcht_Landuse"}) {
|
|
|
+ assertFalse(ex.getMessage().contains(key),
|
|
|
+ "文案出现了上游参数名 " + key + "(GC 1):" + ex.getMessage());
|
|
|
+ }
|
|
|
+
|
|
|
+ BusinessException noCategory = assertThrows(BusinessException.class,
|
|
|
+ () -> service.createMerchant(merchantDto(d -> d.setCategory(null))),
|
|
|
+ "类别为空必须被拒");
|
|
|
+ assertEquals(400, noCategory.getCode());
|
|
|
+ assertTrue(noCategory.getMessage().contains("共 2 项"),
|
|
|
+ "类别为空时只有两个派生字段填不出来,实际: " + noCategory.getMessage());
|
|
|
+ assertTrue(noCategory.getMessage().contains("商户类型"), noCategory.getMessage());
|
|
|
+ assertTrue(noCategory.getMessage().contains("账户类型"), noCategory.getMessage());
|
|
|
+ assertFalse(noCategory.getMessage().contains("商户名称"),
|
|
|
+ "18 个无条件字段全填了,不该报它们中的任何一个:" + noCategory.getMessage());
|
|
|
+
|
|
|
+ verifyNoInteractions(client);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 小微商户那一类的 26 个必填字段的**中文标签**,顺序照 {@code LsyMerchantRequiredFields}
|
|
|
+ * 的声明顺序(18 个无条件在前、小微独有的 8 个在后)。
|
|
|
+ *
|
|
|
+ * <p>断言用的是 {@code contains},所以顺序不影响结果 —— 排成这个顺序只为读起来
|
|
|
+ * 能跟矩阵那张表逐行对照。
|
|
|
+ */
|
|
|
+ private static final String[] MERCHANT_MICRO_MISSING_LABELS = {
|
|
|
+ "商户名称",
|
|
|
+ "商户简称",
|
|
|
+ "商户注册名称 小微填姓名,个体企业填营业执照",
|
|
|
+ "商户行业类目code",
|
|
|
+ "商户签约负责人",
|
|
|
+ "商户签约负责人电话",
|
|
|
+ "商户签约负责人邮箱",
|
|
|
+ "商户客服电话",
|
|
|
+ "商户地址省code",
|
|
|
+ "商户地址市code",
|
|
|
+ "商户地址区县code",
|
|
|
+ "商户地址具体街道门牌号",
|
|
|
+ "持卡人身份证号",
|
|
|
+ "持卡人身份证结束时间(格式YYYYMMDD)长期传20991231",
|
|
|
+ "结算卡账户名",
|
|
|
+ "结算卡卡号",
|
|
|
+ "结算卡预留手机号",
|
|
|
+ "结算银行支行联行号",
|
|
|
+ "负责人身份证正面",
|
|
|
+ "银行卡正面",
|
|
|
+ "银行卡反面",
|
|
|
+ "门头照片",
|
|
|
+ "内部前台照片",
|
|
|
+ "手持身份证",
|
|
|
+ "对私账户身份证正面照片",
|
|
|
+ "对私账户身份证反面照片"};
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 配置里的 {@code channel_id} / {@code bd_id} / {@code rate_code} 都要上送,且
|
|
|
+ * {@code channel_id} / {@code bd_id} 是 **{@code Integer}**。
|
|
|
+ *
|
|
|
+ * <p>★ 类型本身就是断言的一部分:上游参数表把这两个声明成 {@code Integer(11)},
|
|
|
+ * 而请求体是 **JSON**({@code LeshouyingClient} 用 Jackson 序列化这个 map)⇒
|
|
|
+ * {@code Integer} 发出去是 {@code 1001}、{@code String} 发出去是 {@code "1001"},
|
|
|
+ * 两者在**报文**上不同。
|
|
|
+ *
|
|
|
+ * <p>本用例让 DTO 的 {@code rate_code} 留空 ⇒ 钉住「DTO 没值就用配置」那一支;
|
|
|
+ * 反支(DTO 有值 ⇒ 以 DTO 为准)在 {@link #createMerchant_sendsExactlyTheExpectedParams} 里。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void createMerchant_sendsConfiguredCredentials() {
|
|
|
+ withOnboardingCredentials();
|
|
|
+ stubUpstream(MERCHANT_CREATE_OK);
|
|
|
+
|
|
|
+ service.createMerchant(merchantDto(d -> d.setRateCode(null)));
|
|
|
+
|
|
|
+ Map<String, Object> sent = sentParams("/api/merchant/create");
|
|
|
+ Object channelId = sent.get("channel_id");
|
|
|
+ Object bdId = sent.get("bd_id");
|
|
|
+ assertInstanceOf(Integer.class, channelId, "channel_id 必须是 Integer(11) 形态,实际: " + channelId);
|
|
|
+ assertInstanceOf(Integer.class, bdId, "bd_id 必须是 Integer(11) 形态,实际: " + bdId);
|
|
|
+ assertEquals(1001, ((Integer) channelId).intValue());
|
|
|
+ assertEquals(2002, ((Integer) bdId).intValue());
|
|
|
+ assertEquals(CFG_RATE_CODE, sent.get("rate_code"),
|
|
|
+ "DTO 没给费率模板 ⇒ 用配置里的(计划 §0.8)");
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * {@code notify_url} 取**进件审核**通知地址({@link LeshouyingProperties#getOnboardingNotifyUrl()}),
|
|
|
+ * **不是**收款那个 {@code notify-url}(spec §6.5 的两个地址之分)。
|
|
|
+ *
|
|
|
+ * <p>两个地址都在位、且逐字不同:只设一个的话,「取错了那个」在断言下不可见。
|
|
|
+ * 正反两条断言都给(相等 + 不相等),让失败信息自己说清取的是哪一个。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void createMerchant_sendsNotifyUrlFromConfig() {
|
|
|
+ withOnboardingCredentials();
|
|
|
+ props.setNotifyUrl(CFG_PAY_NOTIFY_URL);
|
|
|
+ stubUpstream(MERCHANT_CREATE_OK);
|
|
|
+
|
|
|
+ service.createMerchant(merchantDto());
|
|
|
+
|
|
|
+ Object notifyUrl = sentParams("/api/merchant/create").get("notify_url");
|
|
|
+ assertEquals(CFG_ONBOARDING_NOTIFY_URL, notifyUrl,
|
|
|
+ "进件上送的必须是**进件审核**通知地址");
|
|
|
+ assertNotEquals(CFG_PAY_NOTIFY_URL, notifyUrl,
|
|
|
+ "不得取收款通知地址 leshouying.notify-url(spec §6.5)");
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ 取商户号的键**两个都读**:返回参数表写 {@code shoubei_merchant_no}、返回示例写
|
|
|
+ * {@code merchant_no}(spec §6.2(c):只有真跑一次才知道哪个是准的)⇒ 两个都读,
|
|
|
+ * 谁给值就用谁。
|
|
|
+ *
|
|
|
+ * <p>三个子场景:
|
|
|
+ * <ol>
|
|
|
+ * <li>只有示例那个键({@code merchant_no});</li>
|
|
|
+ * <li>只有参数表那个键({@code shoubei_merchant_no});</li>
|
|
|
+ * <li>示例那个键给了**空串**(与没给一样不可用 —— 判据是空白,不是「键在不在」)⇒ 仍要取到参数表那个键。</li>
|
|
|
+ * </ol>
|
|
|
+ *
|
|
|
+ * <p>★ 这里读的两个键名是**上游的**,属于内部代码,不触 GC 1(GC 1 只管网页面 / 错误消息 /
|
|
|
+ * 应答字段名)—— 不要为了「避讳」把它们从解析代码里去掉,那会直接把取号打断。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void createMerchant_readsMerchantNoFromEitherKey() {
|
|
|
+ withOnboardingCredentials();
|
|
|
+ String[][] cases = {
|
|
|
+ {ok(",\"merchant_no\":\"851901070001\""), "851901070001", "返回示例的键"},
|
|
|
+ {ok(",\"shoubei_merchant_no\":\"851901070002\""), "851901070002", "返回参数表的键"},
|
|
|
+ {ok(",\"merchant_no\":\"\",\"shoubei_merchant_no\":\"851901070003\""), "851901070003",
|
|
|
+ "示例那个键给了空串 ⇒ 与没给一样,仍要取到参数表那个键"}};
|
|
|
+ for (String[] c : cases) {
|
|
|
+ stubUpstream(c[0]);
|
|
|
+ assertEquals(c[1], service.createMerchant(merchantDto()), c[2]);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ 上游说成功却**没给商户号** ⇒ 502,**绝不让 null / 空白一路往外走**:调用方拿它去存库
|
|
|
+ * 就是一条查不回来的记录(商户在上游**确实建了**,本地却没有号)。
|
|
|
+ *
|
|
|
+ * <p>三种输入:整段没有那两个键、示例那个键给空串、参数表那个键给全空格。
|
|
|
+ * 分开不是因为形状不同 —— 是因为「只判键在不在」与「判空白」两种实现只在前两种下分得开。
|
|
|
+ *
|
|
|
+ * <p>★ 文案**既不含上游名号、也不含上游专有字段名**(GC 1 / spec §8.5 第 5 条,两个都禁)。
|
|
|
+ * 「上游专有」的判据是两查(R32):① 建表语句 {@code V1.8__create_pay_lsy_tables.sql} 里
|
|
|
+ * 没有这一列;② 没有对外 DTO / 实体把这一串回给租户。<b>两条都不成立才叫专有</b> ——
|
|
|
+ * {@code merchant_no} / {@code shoubei_merchant_no} 两条都不成立(我们自己的字段是
|
|
|
+ * {@code mchNo} ⇒ {@code mch_no}),所以这里**可以**断言它们不出现;
|
|
|
+ * 而 {@code mch_no} / {@code shop_no} 是我们自己的,断言里不列。
|
|
|
+ *
|
|
|
+ * <p>★ 还要**含行为指令**「不得当作失败直接重试」:商户可能已在上游建了,直接重试就是重复进件。
|
|
|
+ * 只照结构的形状照 {@code createShop} 那条 502(「成功但缺号 ⇒ 502 + 行为指令」),
|
|
|
+ * 不照它的字段名(那一条里 {@code shop_no} 是我们自己的列名,本身没问题)。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void createMerchant_noMerchantNoInResponse_throws502() {
|
|
|
+ withOnboardingCredentials();
|
|
|
+ for (String json : new String[]{
|
|
|
+ "{\"result_code\":\"SUCCESS\"}",
|
|
|
+ ok(",\"merchant_no\":\"\""),
|
|
|
+ ok(",\"shoubei_merchant_no\":\" \"")}) {
|
|
|
+ stubUpstream(json);
|
|
|
+
|
|
|
+ BusinessException ex = assertThrows(BusinessException.class,
|
|
|
+ () -> service.createMerchant(merchantDto()),
|
|
|
+ json + " ⇒ 必须抛,不得把 null / 空白当商户号返回");
|
|
|
+ assertEquals(502, ex.getCode(), json);
|
|
|
+ assertTrue(ex.getMessage().contains("不得当作失败直接重试"),
|
|
|
+ json + " 的文案必须给出行为指令(商户可能已在上游建了),实际: " + ex.getMessage());
|
|
|
+ for (String forbidden : new String[]{"乐收赢", "merchant_no", "shoubei"}) {
|
|
|
+ assertFalse(ex.getMessage().contains(forbidden),
|
|
|
+ json + " 的文案含「" + forbidden + "」(GC 1:不含上游名号、也不含上游专有字段名),"
|
|
|
+ + "实际: " + ex.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 本任务新写的**全部三条**对外文案一次过一遍,都不含上游名号(用户硬约束 / GC 1)。
|
|
|
+ *
|
|
|
+ * <p>三条 = 凭据缺失(①)、必填缺失(②)、成功但没商户号(③)。前两条的其余断言在各自的用例里
|
|
|
+ * ({@link #createMerchant_missingCredentials_throws400ListingAll} /
|
|
|
+ * {@link #createMerchant_missingRequiredFields_throws400ListingAll}),第三条还有一条
|
|
|
+ * 「不含上游专有字段名」的断言在 {@link #createMerchant_noMerchantNoInResponse_throws502}。
|
|
|
+ * 这里集中一次,是为了**保证「新写的文案」这个集合被整批覆盖** —— 将来再加一条文案时,
|
|
|
+ * 漏掉它的方式是「没往这里加」,而那是看得见的。
|
|
|
+ *
|
|
|
+ * <p>为什么三条都要:它们都是 {@link BusinessException},走到
|
|
|
+ * {@code GlobalExceptionHandler} 时 {@code Result.of(code, message)} 把 message **原样**回给调用方
|
|
|
+ * ⇒ 三条都会到租户眼前。解法照 {@code createShop_missingShopNo_throws} 那条断言。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void createMerchant_message_neverNamesUpstream() {
|
|
|
+ BusinessException credentials = assertThrows(BusinessException.class,
|
|
|
+ () -> service.createMerchant(merchantDto()));
|
|
|
+
|
|
|
+ withOnboardingCredentials();
|
|
|
+ LsyMerchantCreateDTO blank = new LsyMerchantCreateDTO();
|
|
|
+ BusinessException required = assertThrows(BusinessException.class,
|
|
|
+ () -> service.createMerchant(blank));
|
|
|
+
|
|
|
+ stubUpstream("{\"result_code\":\"SUCCESS\"}");
|
|
|
+ BusinessException noMerchantNo = assertThrows(BusinessException.class,
|
|
|
+ () -> service.createMerchant(merchantDto()));
|
|
|
+
|
|
|
+ Map<String, String> messages = new LinkedHashMap<>();
|
|
|
+ messages.put("凭据缺失", credentials.getMessage());
|
|
|
+ messages.put("必填缺失", required.getMessage());
|
|
|
+ messages.put("成功但没商户号", noMerchantNo.getMessage());
|
|
|
+ for (Map.Entry<String, String> e : messages.entrySet()) {
|
|
|
+ assertFalse(e.getValue().contains("乐收赢"),
|
|
|
+ e.getKey() + " 的文案出现了上游名号(用户硬约束 / GC 1):" + e.getValue());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ 配置里的 {@code channel_id} / {@code bd_id} 必须是**规范十进制**写法。这不是洁癖:
|
|
|
+ * 这一步是**唯一**会把配置文本改写成另一个文本的地方 —— {@code "001001"} 若被静默接受,
|
|
|
+ * 线上发出去的就是 {@code 1001},而通道 id 决定商户开到**哪条通道**上,
|
|
|
+ * 改一个数字的后果是「开到别的通道」且**不报错**。
|
|
|
+ *
|
|
|
+ * <p>三条输入各钉一件事:{@code "abc"}(根本不是数)、{@code "1001.0"}(不是整数写法)、
|
|
|
+ * {@code "001001"}(**能解析成数,但改写了数字** —— 拒绝式过滤器的正主)。
|
|
|
+ * 正对照(放行)在 {@link #createMerchant_sendsConfiguredCredentials}:{@code "1001"}。
|
|
|
+ *
|
|
|
+ * <p>★ 文案与日志都**不得出现那个值**(GC 5「秘密只打印长度,不打印值」;{@code channel_id} 一族
|
|
|
+ * 按 GC 3 就是凭据)。这也是不许 {@code NumberFormatException} 逃出去的原因:它的 message 会把
|
|
|
+ * 原值整串带出来。两处都断言:对外文案(这里)+ 日志(捕获着看),
|
|
|
+ * 并**正对照**「日志里必须有键名」—— 只断言「不含值」的话,空日志也满足。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void createMerchant_nonCanonicalIntegerConfig_rejected400() {
|
|
|
+ withOnboardingCredentials();
|
|
|
+ for (String bad : new String[]{"abc", "1001.0", "001001"}) {
|
|
|
+ props.setChannelId(bad);
|
|
|
+ withLogCapture(LsyOnboardingService.class, events -> {
|
|
|
+ BusinessException ex = assertThrows(BusinessException.class,
|
|
|
+ () -> service.createMerchant(merchantDto()), "channel_id=" + bad + " 必须被拒");
|
|
|
+ assertEquals(400, ex.getCode(), bad);
|
|
|
+ assertFalse(ex.getMessage().contains(bad),
|
|
|
+ "对外文案出现了配置值(GC 5),实际: " + ex.getMessage());
|
|
|
+
|
|
|
+ List<String> logged = messages(events);
|
|
|
+ assertFalse(logged.isEmpty(), "这条拒绝必须留日志(否则运维无从定位)");
|
|
|
+ assertTrue(logged.stream().anyMatch(m -> m.contains("channel_id")),
|
|
|
+ "日志要点出是哪个配置项(只报长度、不报值),实际: " + logged);
|
|
|
+ for (String line : logged) {
|
|
|
+ assertFalse(line.contains(bad), "日志出现了配置值(GC 5:只打印长度),实际: " + line);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+ verifyNoInteractions(client);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ 每次请求一个新流水号。{@code trace_no} 的定义就是「请求流水号」——**唯一标识一次请求**。
|
|
|
+ * 两笔进件共用一个流水号,日志与上游对账都对不上;**若**上游按流水号去重,第二笔进件会被
|
|
|
+ * 静默丢弃(商户没建成而我们以为建成了)。「若」是条件不是断言上游行为 —— 文档没写去重,
|
|
|
+ * 但「流水号唯一」是这个词自己的定义。
|
|
|
+ *
|
|
|
+ * <p>反面对照:本用例**不**断言「同一个流水号在两次调用间复用」—— 那正是
|
|
|
+ * {@code IdUtil.fastSimpleUUID()} 每次新生成的性质;若哪天有人把它提到字段上缓存,
|
|
|
+ * 两条读数会相同而本用例红。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void createMerchant_traceNo_isFreshPerCall() {
|
|
|
+ withOnboardingCredentials();
|
|
|
+ stubUpstream(MERCHANT_CREATE_OK);
|
|
|
+
|
|
|
+ service.createMerchant(merchantDto());
|
|
|
+ service.createMerchant(merchantDto());
|
|
|
+
|
|
|
+ List<Map<String, Object>> sent = allSentParams("/api/merchant/create", 2);
|
|
|
+ Object first = sent.get(0).get("trace_no");
|
|
|
+ Object second = sent.get(1).get("trace_no");
|
|
|
+ for (Object traceNo : new Object[]{first, second}) {
|
|
|
+ assertInstanceOf(String.class, traceNo, "trace_no 必须是字符串,实际: " + traceNo);
|
|
|
+ assertEquals(32, ((String) traceNo).length(),
|
|
|
+ "trace_no 是 32 位 UUID 形状,实际: " + traceNo);
|
|
|
+ }
|
|
|
+ assertNotEquals(first, second, "两笔请求必须是两个流水号(trace_no 定义就是唯一标识一次请求)");
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 把 {@link ListAppender} 挂到指定 logger 上、跑完 body、**无论成败都摘掉**
|
|
|
+ * —— 与 {@code LsyNotifyServiceTest} / {@code LsyQrPayTest} 同款(本仓库按测试文件各自持有一份,
|
|
|
+ * 不抽公共类)。摘不掉的后果是后续测试的日志被这个 appender 吃进来,故障表现会跑到别的用例上。
|
|
|
+ */
|
|
|
+ private static void withLogCapture(Class<?> type, Consumer<List<ILoggingEvent>> body) {
|
|
|
+ Logger logger = (Logger) LoggerFactory.getLogger(type);
|
|
|
+ ListAppender<ILoggingEvent> appender = new ListAppender<>();
|
|
|
+ appender.start();
|
|
|
+ logger.addAppender(appender);
|
|
|
+ try {
|
|
|
+ body.accept(appender.list);
|
|
|
+ } finally {
|
|
|
+ logger.detachAppender(appender);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 断言失败时把实际捕获到的日志正文全列出来 —— 否则只看到「没匹配上」,看不到实际打了什么。 */
|
|
|
+ private static List<String> messages(List<ILoggingEvent> events) {
|
|
|
+ return events.stream().map(ILoggingEvent::getFormattedMessage).toList();
|
|
|
+ }
|
|
|
}
|