Explorar el Código

fix(乐收赢): 收款链接 token 换 CSPRNG —— 与 HAR 同一处订正

用户令:「lsy 那个也改成一样的」(指上一轮 HAR 收款链接的 token 生成器订正,见 b97a0e93)。
乐收赢侧同一处问题:LsyCheckoutLinkService:86 用 IdUtil.fastSimpleUUID(),
而 LeshouyingCheckoutController 的类 javadoc 写着同一个失实的「122 bit 熵」论证。
五个文件。

改了什么
- LsyCheckoutLinkService.issue:IdUtil.fastSimpleUUID() → IdUtil.simpleUUID(),就地写清理由
  (fastSimpleUUID 走 RandomUtil.getRandom() = ThreadLocalRandom,48 位 LCG;simpleUUID 走
  SecureRandom;两者产出的字符串**形态一模一样**,这正是当初写错还看不出来的原因)。
  ★ 已发出的 token 不受影响:库里旧值不动、照用;这里只影响新插入的行。
- LeshouyingCheckoutController 类 javadoc:订正「IdUtil.fastSimpleUUID,122 bit 熵」→
  simpleUUID/SecureRandom,并留一段「2026-10-06 订正」说明 122 是 UUIDv4 的格式位、
  本身不是不可预测性论证。
- LsyCheckoutLinkEntity.token 的字段注释:同步成 IdUtil.simpleUUID()(CSPRNG)。
- LsyCheckoutLinkServiceTest:新增守卫用例 + 订正形状断言的措辞。
- frontend/src/plugins/permission.ts 注释:那里原写「不要把前端钉死在后端 IdUtil.fastSimpleUUID()
  这一种形状上」—— 换生成器后这句话点名的函数两边都不存在了。改成
  「令牌生成器**当下**的形状(现在是 IdUtil.simpleUUID() 的 32 位十六进制)」,
  既不点名到会腐烂的具体函数,也保留了它原本的论点(别按形状收紧)。

测试
- 新增 issue_tokenComesFromSimpleUuid_notFastSimpleUuid(形态同 HAR 那条):
  形状断言对「换错生成器」永远是绿的(两者都是 32 位 hex),所以不测形状,
  用 mockStatic 直接钉住**调的是哪一个方法**。

验证读数(本机实跑)
- RED(修复前):mvn -Dtest=LsyCheckoutLinkServiceTest test → Tests run: 17, Failures: 1
  (只有新守卫那条红;这正是它该有的样子)logs/_t-lsy-token-1-red.txt
- GREEN(修复后):同命令 → Tests run: 17, Failures: 0,BUILD SUCCESS logs/_t-lsy-token-2-green.txt
- 全量:mvn test → Tests run: 1152, Failures: 0, Errors: 0,BUILD SUCCESS
  (上一轮 HAR 修复后是 1151 ⇒ +1,正好这条守卫)logs/_t-lsy-token-3-full.txt
- jar 已重打:target/payment-platform-1.0.0.jar,mtime 2026-10-06 13:41:27,
  sha256 90d2cb65bdb3ec31c5d7a5801971b4be3ad59f76ff63e931939990a9b8eeb5af,143117153 字节。
  ★ 正对照做在**产物本身**上:从 jar 里取出 class 反汇编,LsyCheckoutLinkService 与
  HarCheckoutLinkService 都显示 invokestatic IdUtil.simpleUUID(不是只信源码)。
  (上一版 942d3be9… 作废。)
- frontend/dist.zip **不变**:改了 permission.ts 的注释后重跑 pnpm build(✓ built in 58.27s,
  产物 mtime 13:41:46 = 本次构建),277 个文件的 sha256 与改前逐个相同(0 差异)
  ⇒ 注释不进产物,dist.zip 仍是当前源码的正确构建,sha256 1e9716e3…。
  ※ 第一次比对得到「0 差异」是**假读数**:那次跑在构建仍在编译时(dist 还是旧树,等于拿基线跟
    自己比)。上面这组是在构建结束后复量的 —— 记在这里,免得下次又把它当成结论。

有意不动的
- 乐收赢其它 fastSimpleUUID 用处(pay_trace_no / trace_no / refund_trace_no / close_trace_no /
  cancel_trace_no / query_trace_no,遍布 checkout/cashier/open/refund/query/cancel/onboarding 各腿)
  **不改**:这些是我们发给上游的对账号,随请求体出网、由上游原样回传,不是任何免登录页面的定位键。
  TenantInnerInterceptor:185 已钉住「要按业务键做未认证读必须先补唯一约束(trade_no / pay_trace_no 均无)」
  ⇒ 它们不构成匿名定位原语。换成 SecureRandom 不买任何东西,只是更慢。
- .claude/plan/2026-09-30-lsy-checkout-link.md:73 仍写着「token = fastSimpleUUID」:那是**当日**的
  设计文档(历史记录,且本就与代码有出入 —— 它写的是 resolve(),代码里是 requireByToken()),
  不改历史文档,故只在此说明。

未验证(如实记)
- .claude/plan/ops-manual.md 读取被权限闸拒 ⇒ **没有**检查它是否也点名了这个生成器;
  若点名了,那一处仍是旧的。
alphaH hace 1 semana
padre
commit
ed24480f4a

+ 2 - 1
frontend/src/plugins/permission.ts

@@ -11,7 +11,8 @@ import { usePermissionStore, useUserStore, useEnterpriseStore } from "@/store";
  *   哪天有人在 `/checkout/...` 下加一个管理端页面,它会在无人察觉的情况下对未登录用户开放。
  *   按名字放行时,「公开」必须是一次**显式**的声明(路由表里那个 `name`)。
  * ★ 也不要按 token 形状收紧(`/^\/checkout\/[0-9a-f]{32}$/`):那会把前端钉死在后端
- *   `IdUtil.fastSimpleUUID()` 这一种令牌形状上,换一种生成方式就等于「所有链接都打不开」。
+ *   令牌生成器**当下**的形状上(现在是 `IdUtil.simpleUUID()` 的 32 位十六进制),
+ *   换一种生成方式就等于「所有链接都打不开」。
  * ★ 只放**顾客页**,`/login` 仍走下面的精确清单 —— 它在已登录时要重定向到首页,
  *   语义不同,别合并。
  *

+ 8 - 2
java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingCheckoutController.java

@@ -53,8 +53,14 @@ import java.time.format.DateTimeFormatter;
  *       {@code uk_lsy_checkout_token} 单独建在 {@code token} 上(**不是** {@code (tenant_id, token)})
  *       ⇒ 「同一个 token 命中两行」在 schema 层建不出来。这是本仓通则的 **(甲)·预防**,
  *       论证与「别把它收窄」写在 {@link LsyCheckoutLinkService} 的类 javadoc 与建表脚本里。</li>
- *   <li><b>token 是我们生成的 32 位随机十六进制</b>({@code IdUtil.fastSimpleUUID},
- *       122 bit 熵),不是可枚举的序号 ⇒ 拿到别人的链接只能靠拿到那一串字符。</li>
+ *   <li><b>token 是 CSPRNG 生成的 32 位随机十六进制</b>({@code IdUtil.simpleUUID}
+ *       = {@code UUID.randomUUID().toString(true)},走 {@code SecureRandom}),
+ *       不是可枚举的序号 ⇒ 拿到别人的链接只能靠拿到那一串字符。
+ *       <br>★ 2026-10-06 订正(与 HAR 收款链接同一处):此前这里写的是
+ *       {@code IdUtil.fastSimpleUUID}「122 bit 熵」,两头都失实 —— 那个方法走
+ *       {@code RandomUtil.getRandom()} = {@code ThreadLocalRandom}(48 位 LCG),
+ *       而 122 是 UUIDv4 的**格式位**,本身不是不可预测性论证。两者产出的字符串
+ *       **形态一模一样**(这正是当初写错还看不出来的原因),生成器已换成 simpleUUID。</li>
  * </ol>
  * <p>★ 一条同层的硬约束:{@code pay_lsy_checkout_link} <b>必须</b>登记在
  * {@code TenantInnerInterceptor.CONDITIONAL_TABLES}。漏登记 ⇒ 拦截器给这条查询追加

+ 1 - 1
java/src/main/java/com/payment/platform/module/payment/leshouying/entity/LsyCheckoutLinkEntity.java

@@ -41,7 +41,7 @@ public class LsyCheckoutLinkEntity extends PaymentTenantBaseEntity {
     /** 钱进哪个乐收赢商户,由它决定。 */
     private String mchNo;
 
-    /** 链接令牌(32 位十六进制,{@code IdUtil.fastSimpleUUID()})。全局唯一。 */
+    /** 链接令牌(32 位十六进制,{@code IdUtil.simpleUUID()},CSPRNG)。全局唯一。 */
     private String token;
 
     /** 商户显示名快照(发放/重取时刷新)。 */

+ 7 - 1
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyCheckoutLinkService.java

@@ -83,7 +83,13 @@ public class LsyCheckoutLinkService {
             LsyCheckoutLinkEntity row = new LsyCheckoutLinkEntity();
             row.setTenantId(tenantId);
             row.setMchNo(mchNo);
-            row.setToken(IdUtil.fastSimpleUUID());
+            // ★ 必须是 CSPRNG 那一支(2026-10-06 订正,与 HAR 收款链接同一处):
+            //   simpleUUID() = UUID.randomUUID().toString(true) —— 走 SecureRandom,32 位 hex;
+            //   而 fastSimpleUUID() 走 RandomUtil.getRandom() = **ThreadLocalRandom**(48 位 LCG),
+            //   两者**字符串形态完全一样**(这正是当初写错还看不出来的原因),但免登录路径全靠
+            //   这串字符猜不到(类 javadoc「(甲)·预防」第 2 条)。别改回 fastSimpleUUID。
+            //   ★ 已发出的 token 不受影响:库里旧值不动、照用,这里只影响新插入的行。
+            row.setToken(IdUtil.simpleUUID());
             row.setMerchantName(LsyStrings.blankToNull(merchantName));
             // ★ 显式写状态,不靠建表默认值:不写的话实体里 status=null,而调用方紧接着
             //   要拿这一行去拼 URL / 序列化给我方页面,null 会被读成「不可用」。

+ 28 - 1
java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyCheckoutLinkServiceTest.java

@@ -1,5 +1,6 @@
 package com.payment.platform.module.payment.leshouying.service;
 
+import cn.hutool.core.util.IdUtil;
 import com.baomidou.mybatisplus.core.MybatisConfiguration;
 import com.baomidou.mybatisplus.core.conditions.AbstractWrapper;
 import com.baomidou.mybatisplus.core.conditions.Wrapper;
@@ -13,6 +14,7 @@ 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.MockedStatic;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
 import org.springframework.mock.web.MockHttpServletRequest;
@@ -89,11 +91,36 @@ class LsyCheckoutLinkServiceTest {
         assertEquals("测试商户", inserted.getMerchantName());
         assertEquals("ENABLED", inserted.getStatus(), "新链接必须是可用状态(status 从 DB 默认值改成显式写,见服务注释)");
         assertNotNull(inserted.getToken());
-        assertEquals(32, inserted.getToken().length(), "fastSimpleUUID 形态:32 位十六进制");
+        assertEquals(32, inserted.getToken().length(), "simpleUUID 形态:32 位十六进制");
         assertTrue(inserted.getToken().matches("[0-9a-f]{32}"), "实际: " + inserted.getToken());
         assertSame(inserted, created, "返回值必须是落库的那一行(调用方要拿它的 token 拼 URL)");
     }
 
+    /**
+     * token 的随机源必须是 CSPRNG(2026-10-06,与 HAR 收款链接同一处订正)。
+     *
+     * <p>这是**唯一**能钉住这件事的形态:{@code fastSimpleUUID()}(ThreadLocalRandom,48 位 LCG)
+     * 与 {@code simpleUUID()}(SecureRandom)产出的字符串**一模一样**(都是 32 位 hex)——
+     * 上面那个形状断言对「换错生成器」永远是绿的,而免登录路径的安全性全押在
+     * 「这串字符猜不到」上(类 javadoc「(甲)·预防」第 2 条)。所以这里不测形状,
+     * 直接钉住**调的是哪一个方法**(形态同 HarCheckoutLinkServiceTest)。
+     */
+    @Test
+    void issue_tokenComesFromSimpleUuid_notFastSimpleUuid() {
+        when(linkMapper.selectList(any())).thenReturn(List.of());
+
+        try (MockedStatic<IdUtil> idUtil = mockStatic(IdUtil.class)) {
+            idUtil.when(IdUtil::simpleUUID).thenReturn("0123456789abcdef0123456789abcdef");
+
+            LsyCheckoutLinkEntity created = service.issue(TENANT, MCH_NO, "测试商户");
+
+            idUtil.verify(IdUtil::simpleUUID);
+            idUtil.verify(IdUtil::fastSimpleUUID, never());
+            assertEquals("0123456789abcdef0123456789abcdef", created.getToken(),
+                    "生成器的返回值必须原样落到那一行上");
+        }
+    }
+
     @Test
     void issue_whenRowExists_returnsSameToken_andDoesNotInsert() {
         LsyCheckoutLinkEntity existing = row(1L, "abcd1234abcd1234abcd1234abcd1234", "ENABLED");