Procházet zdrojové kódy

fix(安全): 出站日志遮罩键名单 7 → 11(R42 修复轮 1)

按裁定把键层名单扩到 11 个:新增 manager_phone / shop_phone / contact_service /
check_code 四个进件腿键名;判据补成两段式 —— (a) 值的形态本身就是标识符、
(b) 值是凭据;并落定三条边界(sign / license_no·business_licence / code_url 都不收)。

机制一个字未动:只加交替项。前缀的引号可选段、(?i)、键名后 [:]= 分隔段、值尾
排除集逐字未变 —— 实测把交替名单抹掉后两侧正则逐字节相同(三段各有正对照)。

测试(先红后绿,红先跑):
- T1 LeshouyingSensitiveTest#keyLayer_masksTheFourNewNames_keepsOurOwnTraceNos
  4 个名字 × 两种形态逐个断言,并在同一份报文里带对账号正对照(out_trade_no /
  pay_trace_no 两个 18 位必须原样生还)
- T2 LsyOnboardingServiceTest#createShop_doesNotLogPii
  真客户端 + mock RestTemplate + TRACE 捕获 + 捕获装置正对照(先证明捕到了那条
  出站行);另断言**发出去的报文体**里两个手机号仍是明文 —— 这一条是刻意保持绿
  的不变量,实测它单独可红(把脱敏施在报文体上即红,见报告 M-W)

读数:T1/T2 写好、名单未扩时跑一次 —— 566 跑 2 红(两条红的正是 T1/T2);
扩名单后 566 全绿。两条护栏用例一个字未改、仍绿。
javadoc 里复述名单的每一处都跟上(名单表 7 → 11 行、两处计数、判据两段式)。
alphaH před 3 týdny
rodič
revize
8f88759a68

+ 62 - 23
java/src/main/java/com/payment/platform/module/payment/leshouying/client/LeshouyingSensitive.java

@@ -8,10 +8,12 @@ import java.util.regex.Pattern;
  * 【★ 覆盖哪些秘密 —— 2026-09-23 扩过一次,它**不再只是付款码**】
  * 两层各管一摊,只读其中一层就下结论会错:
  *   - **值层** {@link #LONG_DIGITS}:不看键名,只认「连续 ≥12 位数字」。
- *   - **键层** {@link #MASKED_KEYS}(原名 `AUTH_CODE_KEY`):按**键名**认,名单现在 7 个 ——
- *     付款码 `auth_code` 之外,另有 6 个**进件腿的 PII 键名**:`legal_cert_no`(法人证件号)/
+ *   - **键层** {@link #MASKED_KEYS}(原名 `AUTH_CODE_KEY`):按**键名**认,名单现在 11 个 ——
+ *     付款码 `auth_code` 之外,另有 10 个**进件腿的键名**:`legal_cert_no`(法人证件号)/
  *     `account_idcard_num`(结算人证件号)/ `principal_cert_no`(身份证)/ `account_card_num`(结算卡号)/
- *     `contact_phone`(联系电话)/ `account_card_phone`(结算卡手机号)。
+ *     `contact_phone`(联系电话)/ `account_card_phone`(结算卡手机号)/
+ *     `manager_phone`(门店管理员手机号)/ `shop_phone`(门店电话)/ `contact_service`(商户客服电话)/
+ *     `check_code`(安心签验证码)。
  *     **为什么这一类只能落在键层**:出站那一行 trace 用的是 {@link #maskByKey},而值层是 `\d{12,}`
  *     —— 18 位证件号接得住,**11 位手机号接不住**。理由与收/不收的判据见 {@link #MASKED_KEYS} 的 javadoc。
  *
@@ -22,7 +24,7 @@ import java.util.regex.Pattern;
  * {@code LsyNotifyService} 落库的 `payload` 是 `LeshouyingSensitive.mask(rawBody)` 的产物
  * ⇒ 含这批键名的回调报文,**入库文本会因此改变**(多遮几个值)。拿旧报文比对会看到差异。
  *
- * 【2026-09-23 那次改动**只动了名单**,没动结构】判据、明确**不**遮的名单(含代价)、
+ * 【2026-09-23 的两次改动**都只动了名单**,没动结构】判据、明确**不**遮的名单(含代价)、
  * 明确**不得**加的名字、前缀约束、「必须是按键名」这条硬边界 —— 全在 {@link #MASKED_KEYS}
  * 的 javadoc 里。**改名单前先读那一段。**
  *
@@ -89,31 +91,65 @@ public final class LeshouyingSensitive {
      * 便于排障时仍能看懂报文结构。
      *
      * <p>【★ 名字从 {@code AUTH_CODE_KEY} 改成 {@code MASKED_KEYS}(2026-09-23)】它早已不只认
-     * {@code auth_code}:名单现在有 7 个键名,管两类凭据 —— 付款码({@code auth_code})与
-     * **进件腿的 PII**。旧名字在说一件不再成立的事,而「PII 键管不管」正是读这个文件的人
+     * {@code auth_code}:名单现在有 11 个键名,管**两类** —— 判据 (a) 的**标识符** 9 个
+     * (进件腿的证件号 / 卡号 / 电话:`legal_cert_no`、`account_idcard_num`、`principal_cert_no`、
+     * `account_card_num`、`contact_phone`、`account_card_phone`、`manager_phone`、`shop_phone`、
+     * `contact_service`),判据 (b) 的**凭据** 2 个(付款码 `auth_code`、安心签验证码 `check_code`)。
+     * 旧名字在说一件不再成立的事,而「PII 键管不管」正是读这个文件的人
      * 要问的第一个问题;留着一个失实的名字,等于用一行代码把答案说反。
      * 改名成本实测为零:全仓引用只有本文件的声明处与 {@link #maskByKey} 里那一处,
      * 测试文件里零引用。
      *
-     * <p>【★★ 一个键名收不收,判据只有一条 —— 改名单前先读这段】
+     * <p>【★★ 一个键名收不收,判据是**两段式** —— 改名单前先读这段】
      * <blockquote>
-     *   <b>值的形态<u>本身就是标识符</u>(证件号 / 卡号 / 手机号)⇒ 收;
-     *   值是<u>业务语义</u>(名称 / 地址 / 邮箱)⇒ 不收。</b>
+     *   <b>收,两类:</b><br>
+     *   <b>(a) 值的形态<u>本身就是标识符</u></b> —— 证件号 / 卡号 / 手机号(含**电话形态的值**);<br>
+     *   <b>(b) 值是<u>凭据</u></b> —— 一次泄漏即可被冒用(付款码 / 验证码)。<br>
+     *   <b>不收:</b>值是<u>业务语义</u>(名称 / 地址 / 邮箱)、<u>公开登记信息</u>
+     *   (营业执照号 / 统一社会信用代码),以及<u>我们自己的对账号</u>
+     *   ({@code mch_no} / {@code out_trade_no} / {@code trade_no} / {@code pay_trace_no} /
+     *   {@code trace_no} —— 遮了等于删掉这条日志)。
      * </blockquote>
-     * 名单里那 7 个就是按这条判据逐条核过的,不是「看着像敏感就加」:
+     * ★ (a) 是 2026-09-23 原有的那一条;(b) 是**同日修复轮 1 补的**,补的理由不是修饰:
+     * **旧句子今天就不完全成立** —— 表里 {@code auth_code} 那一行的理由自己写的是
+     * 「不是 PII,是能直接发起支付的凭据」,{@code check_code} 同理。一段式装不下这两行。
+     * 名单里那 11 个就是按这两段逐条核过的,不是「看着像敏感就加」:
      * <table border="1">
-     *   <caption>键层名单</caption>
+     *   <caption>键层名单(11 个)</caption>
      *   <tr><th>键名</th><th>是什么</th><th>为什么收</th></tr>
      *   <tr><td>{@code auth_code}</td><td>付款码</td>
-     *       <td>不是 PII,是**能直接发起支付的凭据**,与「付款码刻意不落库」的决策同源</td></tr>
-     *   <tr><td>{@code legal_cert_no}</td><td>法人证件号</td><td>证件号</td></tr>
-     *   <tr><td>{@code account_idcard_num}</td><td>结算人证件号</td><td>证件号</td></tr>
-     *   <tr><td>{@code principal_cert_no}</td><td>身份证(安心签两腿)</td><td>证件号</td></tr>
-     *   <tr><td>{@code account_card_num}</td><td>结算卡号</td><td>卡号</td></tr>
-     *   <tr><td>{@code contact_phone}</td><td>联系电话</td><td>手机号</td></tr>
-     *   <tr><td>{@code account_card_phone}</td><td>结算卡手机号</td><td>手机号</td></tr>
+     *       <td>不是 PII,是**能直接发起支付的凭据**,与「付款码刻意不落库」的决策同源 —— (b)</td></tr>
+     *   <tr><td>{@code legal_cert_no}</td><td>法人证件号</td><td>证件号 (a)</td></tr>
+     *   <tr><td>{@code account_idcard_num}</td><td>结算人证件号</td><td>证件号 (a)</td></tr>
+     *   <tr><td>{@code principal_cert_no}</td><td>身份证(安心签两腿)</td><td>证件号 (a)</td></tr>
+     *   <tr><td>{@code account_card_num}</td><td>结算卡号</td><td>卡号 (a)</td></tr>
+     *   <tr><td>{@code contact_phone}</td><td>联系电话</td><td>手机号 (a)</td></tr>
+     *   <tr><td>{@code account_card_phone}</td><td>结算卡手机号</td><td>手机号 (a)</td></tr>
+     *   <tr><td>{@code manager_phone}</td><td>门店管理员手机号({@code createShop} 必填)</td>
+     *       <td>手机号 (a)</td></tr>
+     *   <tr><td>{@code shop_phone}</td><td>门店电话({@code createShop} 必填)</td>
+     *       <td>电话形态的值 (a)</td></tr>
+     *   <tr><td>{@code contact_service}</td><td>商户客服电话</td>
+     *       <td>电话形态的值 (a) —— **键名里没有 "phone"**:判据管的是**值的形态**,不是拼写。
+     *           若它实为 400 热线,遮掉也只是这条诊断日志少一个字段值,**代价方向安全**</td></tr>
+     *   <tr><td>{@code check_code}</td><td>安心签验证码</td><td>凭据 (b):一次泄漏即可被冒用</td></tr>
      * </table>
      *
+     * <p>【★ 三条已判定的边界 —— 写在这里,免得后来者反复摇摆】
+     * {@code sign} / {@code license_no}(含 {@code business_licence})/ {@code code_url}
+     * **都不收**,三条理由各不相同:
+     * <ol>
+     *   <li>{@code sign} ⇒ 不收。它是**派生值**,不是凭据本身;而且它恰恰是验签排障**唯一可比对**
+     *       的值 —— 上游约束「验签绝不跳过」把这条排障路径设为**必须可用**。
+     *       <b>残余风险如实写明:{@code sign} + 报文在原理上可重放。接受。</b></li>
+     *   <li>{@code license_no} / {@code business_licence} ⇒ 不收。**公开登记信息**。
+     *       {@code business_licence} 的值域文档未说明({@code LsyAnxinSignCompanyDTO} 自己写了这句),
+     *       但它的两种可能读法(营业执照号 / 证照文件)**都不落在 (a)(b) 里**
+     *       ⇒ 这条裁定**与那个未知无关**。</li>
+     *   <li>{@code code_url} ⇒ 不收。它**已落库**({@code LsyCancelService} 从 order 取
+     *       {@code getCodeUrl()})—— 我们本来就不当它机密。</li>
+     * </ol>
+     *
      * <p>【★ 为什么这件事只能落在键层(出站路径)】出站那一行 trace 用
      * {@link #maskByKey},而它**不能**改用 {@link #mask}(值层会把 {@code out_trade_no} /
      * {@code pay_trace_no} 这些**我们自己的对账号**一起遮掉 —— 那等于把这条日志删了,
@@ -136,13 +172,15 @@ public final class LeshouyingSensitive {
      * 换句话说:**这是拿 PII 的可见性换可诊断性的一次明账,不是漏网。**
      *
      * <p>【★ 明确<u>不得</u>收进来的】{@code mch_no} / {@code out_trade_no} / {@code trade_no} /
-     * {@code pay_trace_no} —— 这四个是**我们自己的对账号**({@code mch_no} 还是我们自己
-     * 发布给运维的应答字段名),收进来 = 删掉这条日志,正是上面那条判据要防的。
+     * {@code pay_trace_no}(同类的还有 {@code trace_no})—— 这些是**我们自己的对账号**
+     * ({@code mch_no} 还是我们自己发布给运维的应答字段名),收进来 = 删掉这条日志,
+     * 正是上面那两段判据里「不收」那半句要防的。
      * {@code license_no}(营业执照号,统一社会信用代码,**可公开查询**)也不收。
-     * <b>要加,先来问</b>,别顺手。
+     * 另有 {@code sign} / {@code business_licence} / {@code code_url} 三个边界,
+     * 逐条理由见上面那段「三条已判定的边界」。<b>要加,先来问</b>,别顺手。
      *
      * <p>【★ 名单的形态约束:两两之间不得有前缀关系、不得重复】
-     * 当前 7 个两两之间 **0 对前缀关系**、无重复(判定器带正对照:对
+     * 当前 **11 个**两两之间 **0 对前缀关系**、无重复(判定器带正对照:对
      * {@code ['auth_code','auth_code_v2']} 确实报得出 1 对)。
      * 有了这条,alternation 的**书写顺序**就是无关变量(无害改动:换个顺序行为不变)。
      * 将来若要加名字,先重跑这个检查再落地。
@@ -196,7 +234,8 @@ public final class LeshouyingSensitive {
      */
     private static final Pattern MASKED_KEYS = Pattern.compile(
             "(?i)([\"']?(?:auth_?code|legal_cert_no|account_idcard_num|principal_cert_no"
-                    + "|account_card_num|contact_phone|account_card_phone)[\"']?"
+                    + "|account_card_num|contact_phone|account_card_phone"
+                    + "|manager_phone|shop_phone|contact_service|check_code)[\"']?"
                     + "\\s*[:=]\\s*[\"']?)[^\"'&\\s,}]*");
 
     private static final String MASK = "***";

+ 69 - 6
java/src/test/java/com/payment/platform/module/payment/leshouying/client/LeshouyingSensitiveTest.java

@@ -290,22 +290,32 @@ class LeshouyingSensitiveTest {
                 "maskByKey 遮不住 —— 这正是它只配用于「我们自己构造的报文」的原因");
     }
 
-    // ── H. 键层扩到 PII 键名(R42):进件腿的出站 trace 会带上这些字段 ──────────
+    // ── H. 键层扩到进件腿的键名(R42 + 修复轮 1):出站 trace 会带上这些字段 ─────
     //
     // 【为什么只能扩在键层】出站那一行 trace 用的是 maskByKey,判据见它的 javadoc
     //   (出站**不能**用值层:out_trade_no / pay_trace_no 是我们自己的对账号,遮掉 = 删掉这条日志)。
     //   而值层 LONG_DIGITS 是 `\d{12,}` —— 18 位证件号接得住,**11 位手机号接不住**。
     //   ⇒ 手机号这一类在出站路径上**只有键层能接住**,没有第二条路。
     //
-    // 【判据(名单怎么来的)】值的形态**本身就是标识符**(证件号 / 卡号 / 手机号)⇒ 遮;
-    //   值是**业务语义**(名称 / 地址 / 邮箱)⇒ 不遮 —— 后者的正对照是
-    //   keyLayer_masksNothingButTheListedKeys(同一份报文里那些键必须原样存活)。
+    // 【判据(名单怎么来的)—— 两段式,与 LeshouyingSensitive 键层 javadoc 里的那份是同一份】
+    //   (a) 值的形态**本身就是标识符**(证件号 / 卡号 / 手机号,含电话形态的值)⇒ 收;
+    //   (b) 值是**凭据**(付款码 / 验证码 —— 一次泄漏即可被冒用)⇒ 收。
+    //   不收:**业务语义**(名称 / 地址 / 邮箱)、**公开登记信息**(营业执照号 / 统一社会信用代码),
+    //   以及**我们自己的对账号**(mch_no / out_trade_no / trade_no / pay_trace_no / trace_no)。
+    //   「不收」那半句的正对照是 keyLayer_masksNothingButTheListedKeys(同一份报文里那些键必须原样存活)。
+    //   ★ (b) 是修复轮 1 补的,不是修饰:旧的一段式**装不下** auth_code 与 check_code
+    //     —— 表里这两行的理由写的就是「它是凭据」,而不是「它是标识符」。
     //
     // 【扩键层必须仍然是「按键名」】`:268` 那条(out_trade_no 的 18 位必须原样存活)就是守卫:
     //   谁要是顺手加一条「位数规则」,它立刻红。下面这些用例只按键名遮,**不加任何位数判据**。
 
     /**
-     * 6 个新增 PII 键名**逐个**:值被遮成 {@code ***}、**键名保留**。
+     * R42 那批 **7 个**键名(付款码 {@code auth_code} + 6 个 PII)**逐个**:值被遮成 {@code ***}、**键名保留**。
+     *
+     * <p>★ 这不是名单的全量:修复轮 1 又加了 4 个({@code manager_phone} / {@code shop_phone} /
+     * {@code contact_service} / {@code check_code}),它们逐个钉在下面那条
+     * {@link #keyLayer_masksTheFourNewNames_keepsOurOwnTraceNos} 里。
+     * <b>两条用例合起来才是名单的全量</b> —— 只读这一条会把「7」当成名单大小。
      *
      * <p>两列 = 出站报文在真实世界里的**两种形态**:左边是 JSON(上游报文形态),
      * 右边是 Java {@code Map.toString()}({@code LeshouyingClient.traceOutbound} 打进日志的
@@ -313,7 +323,8 @@ class LeshouyingSensitiveTest {
      * 因为把日志改成打 JSON 是随时可能发生的一次改动,而两种形态的引号不同 ——
      * 只钉一种的话,另一种静默失效(键层里那两处 {@code ["']?} 就是为它们存在的)。
      *
-     * <p>★ 陪跑的 {@code auth_code} 是既有的第 7 个键名:扩名单不能把它挤掉。
+     * <p>★ 陪跑的 {@code auth_code} 是名单里**最早的那一个**({@code AUTH_CODE_KEY} 时代唯一的一个):
+     * 扩名单不能把它挤掉。
      *
      * <p>★ <b>两个 11 位手机号是刻意选的</b>:值层 {@code \d{12,}} 看不见它们
      * (下界是闭区间 12),所以这两行**只有键层能接住**。若把它们换成 18 位数字,
@@ -344,6 +355,58 @@ class LeshouyingSensitiveTest {
         }
     }
 
+    /**
+     * ★ <b>修复轮 1 新增的 4 个键名</b>(名单 7 → 11)逐个:值被遮成 {@code ***}、键名保留。
+     *
+     * <p>四个名字各自命中不同的入册理由,所以表里逐行注了是哪一条:
+     * <ul>
+     *   <li>{@code manager_phone} / {@code shop_phone} —— 判据 (a),值的形态就是手机号。
+     *       两者都是 {@code createShop} 的**必填**项,即这条腿**必然**会把它们交给出站日志。</li>
+     *   <li>{@code contact_service} —— 判据 (a),但**键名里没有 "phone"**:判据管的是值的形态,
+     *       不是拼写。它是按值收进来的第一个键名。</li>
+     *   <li>{@code check_code} —— 判据 (b):值是**凭据**(安心签验证码),一次泄漏即可被冒用。</li>
+     * </ul>
+     *
+     * <p>★ <b>四个值刻意都不是 12 位连续数字</b>:值层 {@code \d{12,}} 对它们全都不命中
+     * ⇒ 本用例不可能由值层满足,四个断言都只可能由**键层**变绿(这就是选 11 位手机号
+     * 与 4 位验证码的原因;换成 18 位数字,用例对「键层在不在工作」就失去分辨力)。
+     *
+     * <p>★ <b>第二条断言与主断言是同一份报文,它是正对照</b>:同一输入里我们自己的对账号
+     * ({@code out_trade_no} 与 {@code pay_trace_no},都是 18 位)必须**原样生还**。
+     * 少了它,本用例分不清「名单扩对了」与「键层的值尾排除集 / 前缀段被改坏了」——
+     * 后一种改法会让四个新名字照样被遮(遮到空串),却同时毁掉整条出站日志。
+     * 这两个 18 位值还顺带钉住「出站这一路走的是键层」:它们在 {@code mask()} 下会被值层遮掉。
+     */
+    @Test
+    void keyLayer_masksTheFourNewNames_keepsOurOwnTraceNos() {
+        String[][] cases = {
+                {"manager_phone", "13566668881"},   // (a) 手机号
+                {"shop_phone", "13566668882"},      // (a) 手机号
+                {"contact_service", "4001234567"},  // (a) 电话形态的值 —— 键名里没有 "phone"
+                {"check_code", "8421"},             // (b) 凭据
+        };
+        for (String[] c : cases) {
+            String key = c[0];
+            String value = c[1];
+
+            assertEquals("{\"" + key + "\":\"***\"}",
+                    LeshouyingSensitive.maskByKey("{\"" + key + "\":\"" + value + "\"}"),
+                    "JSON 形态:" + key + " 的值必须被遮、键名必须保留");
+
+            assertEquals("{" + key + "=***, inst_no=900075}",
+                    LeshouyingSensitive.maskByKey("{" + key + "=" + value + ", inst_no=900075}"),
+                    "Map.toString 形态(出站日志的真实形态):" + key + " 的值必须被遮、键名必须保留");
+        }
+
+        String body = "{manager_phone=13566668881, shop_phone=13566668882, "
+                + "contact_service=4001234567, check_code=8421, "
+                + "out_trade_no=202609210000000123, pay_trace_no=202609210000000456}";
+        assertEquals("{manager_phone=***, shop_phone=***, contact_service=***, check_code=***, "
+                        + "out_trade_no=202609210000000123, pay_trace_no=202609210000000456}",
+                LeshouyingSensitive.maskByKey(body),
+                "同一份报文里:4 个新名字被遮,而我们自己的两个 18 位对账号原样生还");
+    }
+
     /**
      * ★ <b>正对照:证明「是按名单遮的」,不是「遮多了」。</b>
      *

+ 80 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyOnboardingServiceTest.java

@@ -2950,6 +2950,86 @@ class LsyOnboardingServiceTest {
         });
     }
 
+    /**
+     * ★★ <b>{@code createShop} 这条腿的出站日志里不得出现两个手机号</b>(修复轮 1 的重点用例)。
+     *
+     * <p>★ <b>为什么 R42 那条不够、必须单独补这一条:</b>
+     * {@link #anxinSign_doesNotLogPii} 覆盖的是**安心签两条腿**;而 {@code createShop} 是
+     * Task 5 自己新加的腿,它**正好要发两个手机号**({@code shop_phone} 与 {@code manager_phone}
+     * 都是必填项,见 {@link #createShop_blankRequiredField_throws400})。
+     * ⇒ 只做键层单测({@code LeshouyingSensitiveTest#keyLayer_masksTheFourNewNames_keepsOurOwnTraceNos})
+     * 证明不了**这一腿**的日志干净:那一层量的是**脱敏器本身**,
+     * 量不到「这条腿到底把什么交给了脱敏器」。换言之这不是给 R42 收尾,是给 Task 5 的新腿补覆盖。
+     *
+     * <p>装置与 {@link #anxinSign_doesNotLogPii} **逐项同款**(真 {@link LeshouyingClient} +
+     * {@code RestTemplate} 打桩不出网 + {@link #withTraceLogCapture} + **先**证明捕获装置
+     * 确实捕到了那条出站行)—— 那一条的 javadoc 已经把「为什么必须用真客户端」与
+     * 「断言顺序不许颠倒」写透了,此处不重复。**本用例不新造装置。**
+     *
+     * <p>★ <b>两个手机号取不同的值</b>({@code 13566668881} / {@code 13566668882},
+     * 也都不同于 {@link #validShopDto()} 里那个 {@code 13566668888}):若两者同值,
+     * 「遮住了 shop_phone 却漏了 manager_phone」这种实现也能绿 —— 值一样,读不出是谁漏的。
+     * 两个值都是 **11 位**,值层 {@code \d{12,}} 接不住 ⇒ 这一腿**只有键层能接住**它们。
+     *
+     * <p>★ <b>第 ③ 步与第 ② 步方向相反,是刻意<u>要保持绿</u>的不变量</b>(不是判别器):
+     * 它断言**发出去的报文体**里两个手机号仍是明文 —— 「遮的是日志副本,不是报文」。
+     * 少了它,一个把脱敏施在**报文体本身**的实现(mask 之后再塞进 {@code HttpEntity})
+     * 会让第 ② 步照样绿,而那种实现会把上游的入参改坏。
+     * 实测这两步各自都能单独变红(变异见本轮报告 M-W)。
+     */
+    @Test
+    @SuppressWarnings("unchecked")
+    void createShop_doesNotLogPii() {
+        RestTemplate restTemplate = mock(RestTemplate.class);
+        when(restTemplate.postForEntity(any(String.class), any(HttpEntity.class), eq(String.class)))
+                .thenReturn(new ResponseEntity<>(SHOP_CREATE_OK, HttpStatus.OK));
+
+        LeshouyingProperties realProps = new LeshouyingProperties();
+        realProps.setBaseUrl("https://test.example.com/yfpay");
+        realProps.setInstNo("900112");
+        // ★ 占位密钥,**故意不是**任何真实凭据(与 anxinSign_doesNotLogPii 同款)。
+        realProps.setKey("t5-placeholder-key-not-a-credential");
+        LeshouyingClient realClient = new LeshouyingClient(restTemplate, new LeshouyingSigner(),
+                new LeshouyingResponseParser(), realProps);
+        LsyOnboardingService realService = new LsyOnboardingService(realClient, realProps);
+
+        String shopPhone = "13566668881";
+        String managerPhone = "13566668882";
+        LsyShopCreateDTO shop = validShopDto();
+        shop.setShopPhone(shopPhone);
+        shop.setManagerPhone(managerPhone);
+
+        // 抓真的出站 entity:日志那一行脱的是**副本**,这个才是发出去的东西。
+        ArgumentCaptor<HttpEntity> wireEntity = ArgumentCaptor.forClass(HttpEntity.class);
+
+        withTraceLogCapture(LeshouyingClient.class, events -> {
+            realService.createShop(shop);
+
+            List<String> msgs = messages(events);
+
+            // ① 正对照:捕获装置**确实捕到了**这一腿的出站行。没有这一步,下面的断言是空断言。
+            assertTrue(msgs.stream().anyMatch(m -> m.contains("/api/shop/create")),
+                    "没捕到 /api/shop/create 的出站 trace 行 ⇒ 下面的 PII 断言是空断言。实际捕获: " + msgs);
+
+            // ② 两个手机号不得出现在任何一行日志里。逐行 × 逐值,命中即报出那一行。
+            for (String phone : new String[]{shopPhone, managerPhone}) {
+                for (String msg : msgs) {
+                    assertFalse(msg.contains(phone),
+                            "出站日志里出现了手机号(GC 5):" + phone + " —— 该行: " + msg);
+                }
+            }
+
+            // ③ 「遮的是日志副本、报文没变」——**读数**,不是推断:同一份 payload,
+            //    日志里是 ***,交到 RestTemplate 手上的 entity 里必须是明文。
+            verify(restTemplate).postForEntity(any(String.class), wireEntity.capture(), eq(String.class));
+            Map<?, ?> wireBody = (Map<?, ?>) wireEntity.getValue().getBody();
+            assertEquals(shopPhone, wireBody.get("shop_phone"),
+                    "发出去的报文体必须仍是明文手机号(本轮只动日志副本):" + wireBody);
+            assertEquals(managerPhone, wireBody.get("manager_phone"),
+                    "发出去的报文体必须仍是明文手机号(本轮只动日志副本):" + wireBody);
+        });
+    }
+
     /**
      * 把 {@link ListAppender} 挂到指定 logger 上、跑完 body、**无论成败都摘掉**
      * —— 与 {@code LsyNotifyServiceTest} / {@code LsyQrPayTest} 同款(本仓库按测试文件各自持有一份,