# 乐收赢支付通道 Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 把乐收赢接入为上游支付通道,并包装成下游支付 API 供平台租户使用,覆盖被扫、主扫、H5 三种支付场景与完整收款生命周期。 **Architecture:** 在 `module/payment/leshouying/` 下新建自包含模块,沿用 HAR 模块已建立的分层(client / config / controller / dto / entity / enums / mapper / scheduler / service)。我们→乐收赢用 MD5 签名,乐收赢→我们用免登录回调入口 + 强制验签;下游→我们复用现有 `TenantApiKeyAuthFilter` + HMAC-SHA256,协议不改。状态机用条件更新(受影响行数判定)实现幂等,不依赖唯一约束。 **Tech Stack:** Java 21、Spring Boot 3.2.5、MyBatis-Plus 3.5.5、PostgreSQL、Hutool 5.8.34、JUnit 5 + Mockito、Vue 3 + Element Plus **Spec:** `docs/superpowers/specs/2026-09-21-leshouying-payment-channel-design.md` **上游接口摘录:** `D:\project2\leshouying-api-docs.md`(30 节,含完整目录树与逐页参数 —— 实现时查参数以此为准) ## Global Constraints - **一行都不 commit。** 全部改动只落工作区,HEAD 必须停在 `dbe364c`。每个任务末尾用 `git status --short` 确认,**不执行任何 `git add` / `git commit`**。 - **SQL 不执行。** `V1.8__create_pay_lsy_tables.sql` 与 `lsy-menu.sql` 只写文件,不对任何数据库执行(建表/写菜单不可逆)。 - **验签不可跳过。** 回调入口的 MD5 验签是唯一身份证明,**不实现任何 skip / disable / 开关 / 测试后门**。见 Task 12。 - **迁移版本号占 `V1.8`。** 当前最新已应用是 `V1.7`;HAR 的 `V1.8` 在 stash 里未执行,恢复时需改名 `V1.9`。 - **`mvnw` 已损坏**(内容是 `404: Not Found`)。用 `/c/Users/1/tools/apache-maven-3.9.16/bin/mvn`,在 `java/` 目录下执行。 - **凡步骤写了 `Expected: Tests run: N` 的,命令不要加 `-q`。** `-q` 会把 surefire 的 `Tests run:` 计数行一起吞掉(stdout 为空、只剩 exit code 0),于是那一步自己写的 `Expected:` **无法被观察** —— 一个要求你核对数字的步骤,却用了看不见数字的命令。 (Task 3 评审实测指出;此处统一订正,不逐个改 17 条命令。) - **实体字段必须与建表列一致,一个字段都不能多。** MP 按**实体**生成 SQL 列清单: 实体有、表里没有的字段不编译报错、写入时也被 NOT_NULL 策略掩盖,只在**读**时抛 PostgreSQL 42703。`LsyEntityMappingTest.everyEntityFieldHasAColumnInTheMigration` 钉住它。 - **实体继承 `PaymentTenantBaseEntity`**,它已提供 `id`(ASSIGN_ID 雪花)、`status`、`description`、`createdTime`、`updatedTime` —— **不要重复声明这五个字段**。 - **金额一律 `bigint`,单位分。** 上游所有金额字段都是"分"的字符串。 - **上游时间格式 `yyyyMMddHHmmss`(14 位)。** 项目全局时区已是 `Asia/Shanghai`。 - **包路径** `com.payment.platform.module.payment.leshouying`,**表名前缀** `pay_lsy_`。 - **`client` 包里的类不得*依赖*容器,但必须*可被*容器装配。** 判据是两条:不注入任何 bean、 不调用任何容器 API(`ApplicationContext` / `@Autowired` / `@Value` 等);但**要**带 `@Component`, 因为 `LsyOrderService`(`@Service`)的构造器需要一个 `LeshouyingResponseParser` 参数, 而全计划**没有任何 `@Configuration` / `@Bean`** —— 所有 bean 都靠组件扫描装配。 删掉 `@Component` 会让上下文启动时抛 `NoSuchBeanDefinitionException`,**注解是承重的**。 (订正:本条原先被写成「纯函数层…不碰 Spring 容器」,措辞把「不依赖容器」和「不可被容器装配」 混成了一件事,导致一次评审误报 `LeshouyingResponseParser` 的 `@Component` 违规。 裁定:保留注解,改措辞。) - **下游回调报文**沿用 `OpenapiService.sendCallback` 的表单格式(`notify_id` / `timestamp` / `content`)。 - **乐收赢的 MD5 只用于「我们 → 乐收赢」这一段**;下游面向用户的签名仍是现有 HMAC-SHA256,不改协议。 - **值序列化契约(Task 5-10 构造 `client.post` 入参 map 时一律遵守)。** 签名原文里每个值都走 `String.valueOf`(见 `LeshouyingSigner`),所以**进 map 的每个值都必须是最终字符串形状**, `client.post` 只负责「把调用方给的 map 原样签名」,在那里做类型转换只会让「签的」与「发的」 两处形状各自演化,最后对不上: - 日期一律 `yyyyMMddHHmmss` 字符串(14 位,与上游一致,全局时区 `Asia/Shanghai`)。 在**拼 map 之前**就格式化好。 - `LocalDateTime` / `Date` **一律不得进 map** —— `String.valueOf` 会得到 `2026-09-21T10:00` 或 `Mon Sep 21 10:00:00 CST 2026`,两种都不是上游要的格式, 而失败会推迟到上游回「参数不合法」,根因不可见。 - 金额一律「分」的字符串(无小数点、无千分位);下游实体字段是 `bigint` 分。 (订正:本条原先**只存在于 `LeshouyingClient.post()` 的 javadoc 里**,计划和 brief 都没有 —— 实测 `值序列化契约` 一词在 brief 命中 0、计划正文命中 0、源码命中 1。于是 Task 4 的审查者 会在 diff 里看到一个 brief 从未提过的段落,而 Task 5-10 的实现者根本看不到这条约束。 提到此处是它的正确归属:它约束的是**构造入参的调用方**,不是 `post` 本身。 也正因为 `task-brief` 只抽任务正文、不含 Global Constraints(已三次独立实测), 在这里追加**不会改变任何已生成的 brief**。) --- ## File Structure **新建(后端)** | 文件 | 职责 | |---|---| | `leshouying/client/LeshouyingSigner.java` | MD5 签名生成 + 入站验签。纯函数,无状态 | | `leshouying/client/LeshouyingResponseParser.java` | 解析两套报文信封(`v3/*` 与 `api/*`) | | `leshouying/client/LeshouyingApiException.java` | 上游业务异常 | | `leshouying/client/LeshouyingClient.java` | 上游 HTTP 调用:签名 → POST JSON → 解析 → 异常包装 | | `leshouying/client/dto/*.java` | 上游请求/响应对象,按接口一进一出 | | `leshouying/config/LeshouyingProperties.java` | `inst_no` / 机构密钥 / `baseUrl` / 各超时窗口 | | `leshouying/enums/{LsyPayScene,LsyPayType,LsyOrderStatus,LsyRefundStatus,LsyNotifyStatus}.java` | 枚举 | | `leshouying/entity/{LsyMerchant,LsyOrder,LsyRefund,LsyNotifyLog}Entity.java` | 4 张表实体 | | `leshouying/mapper/*.java` | 4 个 Mapper | | `leshouying/service/LsyOrderService.java` | 三种下单 + 状态机条件更新 | | `leshouying/service/LsyQueryService.java` | 交易查询 | | `leshouying/service/LsyCancelService.java` | 撤销/关单(先查后撤) | | `leshouying/service/LsyRefundService.java` | 退款 + 退款查询 | | `leshouying/service/LsyNotifyService.java` | 回调验签 + 幂等 + 状态回写 | | `leshouying/service/LsyCallbackService.java` | 回调我们的下游(含持久化重试) | | `leshouying/scheduler/LsyOrderPollScheduler.java` | 被扫密轮询 + 超时撤销 | | `leshouying/scheduler/LsyReconcileScheduler.java` | 主扫/H5 兜底对账 + 关单 + 下游回调重试 | | `leshouying/controller/LeshouyingNotifyController.java` | 乐收赢回调入口(免登录) | | `leshouying/controller/LeshouyingOrderController.java` | 管理端订单 | | `leshouying/controller/LeshouyingRefundController.java` | 管理端退款 | | `leshouying/controller/LeshouyingMerchantController.java` | 管理端商户映射 | | `leshouying/controller/open/LeshouyingOpenController.java` | 下游 7 个 OpenAPI 端点 | | `resources/db/migration/V1.8__create_pay_lsy_tables.sql` | 建表(**不执行**) | | `resources/db/lsy-menu.sql` | 菜单权限(**不执行**) | **修改(后端,三处必改 —— 漏改即静默故障)** | 文件 | 改什么 | 漏改后果 | |---|---|---| | `core/security/TenantApiKeyAuthFilter.java:92-96` | 7 个下游路径逐条加入 `OPENAPI_ACCOUNT_PATHS` | `path.equals(p)` 精确匹配不命中 → **过滤器整体跳过 → 完全无鉴权** | | `core/security/SecurityConfig.java:38-70` | 加入 `/payment/leshouying/notify/**` | 回调被 Spring Security 挡成 403 | | `core/tenant/TenantInnerInterceptor.java:53-76` | 4 张新表加入 `CONDITIONAL_TABLES` | 无认证场景查询被追加 `tenant_id=0` → **回调回写静默失效** | **新建(前端)**: `frontend/src/api/module_payment/leshouying.ts`、`frontend/src/views/module_payment/leshouying/{order,refund,merchant}/index.vue` --- ## Task 1: LeshouyingSigner —— MD5 签名与验签 整个模块的安全基石。规则来自上游「接入指引」:**按参数名 ASCII 升序,拼 `k=v&k=v`,尾部接 `&key=<机构密钥>`,MD5 取 32 位小写;null 不参与,空字符串参与。** **Files:** - Create: `java/src/main/java/com/payment/platform/module/payment/leshouying/client/LeshouyingSigner.java` - Test: `java/src/test/java/com/payment/platform/module/payment/leshouying/client/LeshouyingSignerTest.java` **Interfaces:** - Consumes: 无 - Produces: - `String LeshouyingSigner.sign(Map params, String key)` - `boolean LeshouyingSigner.verify(Map params, String key)` - [ ] **Step 1: 写失败的测试** 创建 `LeshouyingSignerTest.java`: ```java package com.payment.platform.module.payment.leshouying.client; import org.junit.jupiter.api.Test; import java.util.LinkedHashMap; import java.util.Map; import static org.junit.jupiter.api.Assertions.*; class LeshouyingSignerTest { private final LeshouyingSigner signer = new LeshouyingSigner(); private static final String KEY = "1c4d83f0c3c24a7d9fcaa1fed2682076"; private Map params(Object... kv) { Map m = new LinkedHashMap<>(); for (int i = 0; i < kv.length; i += 2) m.put((String) kv[i], kv[i + 1]); return m; } @Test void sign_matchesDocumentedRule_asciiSortedWithKeySuffix() { // 乱序传入 d,b,a,c,期望被排成 a,b,c,d 后拼接 String actual = signer.sign(params("d", "1", "b", "1", "a", "1", "c", "1"), KEY); String raw = "a=1&b=1&c=1&d=1&key=" + KEY; String expected = cn.hutool.crypto.digest.DigestUtil.md5Hex(raw).toLowerCase(); assertEquals(expected, actual); assertEquals(32, actual.length()); assertEquals(actual.toLowerCase(), actual); } @Test void sign_matchesDocumentedParamSet() { // 上游文档 §「签名规则」第 2 步给出的字符串 A 实例,逐字抄录: // auth_code=134573607383541818&inst_no=900075&mch_no=Y52211090004 // &pay_time=20190101125959&pay_trace_no=18588dbd1c6e4572b915a2b33fcdc62e // &pay_type=400&total_amount=1 // 文档只给了 A,没给最终 sign,所以这里断言的是「A 的拼装结果」—— // 用 md5(A + "&key=" + KEY) 反推,验证排序与拼接格式与文档一致。 Map p = params( "auth_code", "134573607383541818", "total_amount", "1", "mch_no", "Y52211090004", "pay_time", "20190101125959", "inst_no", "900075", "pay_type", "400", "pay_trace_no", "18588dbd1c6e4572b915a2b33fcdc62e"); String docA = "auth_code=134573607383541818" + "&inst_no=900075" + "&mch_no=Y52211090004" + "&pay_time=20190101125959" + "&pay_trace_no=18588dbd1c6e4572b915a2b33fcdc62e" + "&pay_type=400" + "&total_amount=1"; assertEquals(cn.hutool.crypto.digest.DigestUtil .md5Hex(docA + "&key=" + KEY).toLowerCase(), signer.sign(p, KEY)); } @Test void sign_nullValueExcluded() { String withNull = signer.sign(params("a", "1", "b", null), KEY); String without = signer.sign(params("a", "1"), KEY); assertEquals(without, withNull); } @Test void sign_emptyStringParticipates() { // 空字符串参与签名 → b= 出现在拼串里,与"b 不存在"结果不同 String withEmpty = signer.sign(params("a", "1", "b", ""), KEY); String withoutB = signer.sign(params("a", "1"), KEY); assertNotEquals(withoutB, withEmpty); assertEquals(cn.hutool.crypto.digest.DigestUtil .md5Hex("a=1&b=&key=" + KEY).toLowerCase(), withEmpty); } @Test void sign_keyIsNotPartOfInputMap() { // sign() 与 verify() 对称地剥离 sign 键:带 sign 的 map 与不带的 map 必须算出 // **同一个**签名。否则重签一份已带 sign 的参数(重试复用同一份 map、调用方就地 // 改写)会把那个陈旧值算进原文,上游回报「签名错误」而根因不可见。 String plain = signer.sign(params("a", "1"), KEY); Map withSign = params("a", "1", "sign", "deadbeef"); assertEquals(plain, signer.sign(withSign, KEY)); } @Test void sign_insensitiveToInsertionOrder() { assertEquals(signer.sign(params("a", "1", "b", "2", "c", "3"), KEY), signer.sign(params("c", "3", "a", "1", "b", "2"), KEY)); } @Test void verify_acceptsCorrectSignature() { Map p = params("inst_no", "900112", "mch_no", "Y52211090004", "total_amount", "1"); p.put("sign", signer.sign(p, KEY)); assertTrue(signer.verify(p, KEY)); } @Test void verify_rejectsTamperedField() { Map p = params("inst_no", "900112", "total_amount", "1"); p.put("sign", signer.sign(p, KEY)); p.put("total_amount", "999999"); // 篡改金额 assertFalse(signer.verify(p, KEY)); } @Test void verify_rejectsWrongKey() { Map p = params("inst_no", "900112"); p.put("sign", signer.sign(p, KEY)); assertFalse(signer.verify(p, "wrong-key")); } @Test void verify_rejectsMissingOrBlankSign() { assertFalse(signer.verify(params("inst_no", "900112"), KEY)); Map blank = params("inst_no", "900112"); blank.put("sign", ""); assertFalse(signer.verify(blank, KEY)); Map nullSign = params("inst_no", "900112"); nullSign.put("sign", null); assertFalse(signer.verify(nullSign, KEY)); } @Test void verify_signIsCaseInsensitiveOnInput() { Map p = params("inst_no", "900112"); p.put("sign", signer.sign(p, KEY).toUpperCase()); assertTrue(signer.verify(p, KEY)); } } ``` - [ ] **Step 2: 运行测试确认失败** ```bash cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LeshouyingSignerTest ``` Expected: 编译失败 —— `LeshouyingSigner` 不存在。 - [ ] **Step 3: 实现 LeshouyingSigner** ```java package com.payment.platform.module.payment.leshouying.client; import cn.hutool.core.util.StrUtil; import cn.hutool.crypto.digest.DigestUtil; import org.springframework.stereotype.Component; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.util.LinkedHashMap; import java.util.Map; import java.util.stream.Collectors; /** * 乐收赢 MD5 签名器 —— 对应上游「接入指引」[196236523] * * 规则(逐字来自文档): * 1. 参数值为 null → 不参与;为空字符串 → 参与 * 2. 参数名按 ASCII 码顺序(字典序)排序,以 k=v&k=v 拼接 → 字符串 A * 3. String B = A + "&key=" + 机构密钥 * 4. sign = md5(B) → 32 位小写 * 5. 验签:先取出 sign,剩余参数按同一规则重算并比对 * * 文档给出的 A 的实例(见 LeshouyingSignerTest#sign_matchesDocumentedParamSet): * auth_code=134573607383541818&inst_no=900075&mch_no=Y52211090004 * &pay_time=20190101125959&pay_trace_no=18588dbd1c6e4572b915a2b33fcdc62e * &pay_type=400&total_amount=1 * * 【安全约束】本类不提供任何跳过/禁用签名的入口。回调入口的验签是唯一身份证明。 */ @Component public class LeshouyingSigner { private static final String SIGN_FIELD = "sign"; private static final String KEY_SUFFIX = "&key="; /** * 生成签名。 * 与 verify() 对称:**先把 sign 键剥掉**再拼原文 —— 重签一份已带 sign 的参数 * (重试复用同一份 map、调用方就地改写)时,不剥会把陈旧值算进去, * 上游回报「签名错误」而根因不可见。 * * 【本方法一个字都不许打日志】—— 它是纯函数,两条路径都会走到它,而出站与入站 * 的内容来源安全等级**完全不同**: * 1. `verify()` 会调它,而入站回调参数由**攻击者控制**:参数值里塞 CRLF * 就能伪造日志行,把审计轨迹搅乱(CWE-117)——支付纠纷取证时正好要用这份轨迹。 * 2. 回调参数含 `auth_code`(付款码),那是一张能直接发起支付的凭据。 * 本计划已明确决定「付款码(auth_code)刻意不落库 —— 一次性支付凭证等同卡号」, * 从日志侧门把它留下,等于把那条决策绕过去。 * 3. 一旦有人图省事把 String B(含 `&key=<机构密钥>`)也记下来,就等于交出 * 伪造回调的能力(CWE-532)。密钥同样只在这一个方法里出现。 * * 出站方向的调试日志放 `LeshouyingClient.post()` —— 那里的参数由我们自己构造, * 既不接受外部输入,也清楚知道哪些字段该脱敏。见 Task 4。 */ public String sign(Map params, String key) { Map clean = new LinkedHashMap<>(); if (params != null) { clean.putAll(params); clean.remove(SIGN_FIELD); } return DigestUtil.md5Hex(buildSource(clean) + KEY_SUFFIX + key).toLowerCase(); } /** * 验签:从入参取出 sign,对其余参数重算并做常量时间比较。 * 任一步不满足(缺 sign / 空 sign / 不匹配)都返回 false。 */ public boolean verify(Map params, String key) { if (params == null || params.isEmpty()) { return false; } Object signObj = params.get(SIGN_FIELD); if (signObj == null || StrUtil.isBlank(String.valueOf(signObj))) { return false; } String actual = String.valueOf(signObj).trim().toLowerCase(); Map rest = new LinkedHashMap<>(params); rest.remove(SIGN_FIELD); String expected = sign(rest, key); return constantTimeEquals(expected, actual); } /** null 排除、空字符串保留、按 key 自然序(ASCII)升序 */ private String buildSource(Map params) { if (params == null || params.isEmpty()) { return ""; } return params.entrySet().stream() .filter(e -> e.getKey() != null) .filter(e -> e.getValue() != null) .sorted(Map.Entry.comparingByKey()) .map(e -> e.getKey() + "=" + e.getValue()) .collect(Collectors.joining("&")); } /** 常量时间比较,避免按字符提前返回导致的时序侧信道 */ private boolean constantTimeEquals(String a, String b) { if (a == null || b == null) { return false; } return MessageDigest.isEqual( a.getBytes(StandardCharsets.UTF_8), b.getBytes(StandardCharsets.UTF_8)); } } ``` - [ ] **Step 4: 运行测试确认全绿** ```bash cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LeshouyingSignerTest ``` Expected: `Tests run: 11, Failures: 0, Errors: 0` - [ ] **Step 5: 确认工作区状态(不提交)** ```bash git status --short ``` Expected: 两个文件显示为 `??`(未跟踪)。**不要 `git add`。** --- ## Task 2: LeshouyingResponseParser —— 两套报文信封 上游有两套返回结构,混用会导致状态判断错误。**这是本项目已确认的坑(spec §2)。** | 体系 | 成功判定 | 业务结果字段 | |---|---|---| | 支付类 `/v3/*` | `return_code == "SUCCESS"` | `result_code` = `PAY_SUCCESS` / `PAY_IN_PROCESS` / `PAY_FAIL` | | 进件类 `/api/*` | `result_code == "SUCCESS"` | `result_msg` | **Files:** - Create: `java/src/main/java/com/payment/platform/module/payment/leshouying/client/LeshouyingResponseParser.java` - Create: `java/src/main/java/com/payment/platform/module/payment/leshouying/client/LeshouyingApiException.java` - Create: `java/src/main/java/com/payment/platform/module/payment/leshouying/client/LeshouyingSensitive.java`(报文脱敏。**Task 4 出站日志与 Task 12 回调落库都要复用它**,不要各写一份正则) - Test: `java/src/test/java/com/payment/platform/module/payment/leshouying/client/LeshouyingResponseParserTest.java` - Test: `java/src/test/java/com/payment/platform/module/payment/leshouying/client/LeshouyingApiExceptionTest.java` - Test: `java/src/test/java/com/payment/platform/module/payment/leshouying/client/LeshouyingSensitiveTest.java` **Interfaces:** - Consumes: 无(纯字符串解析) - Produces: - `Map LeshouyingResponseParser.parseV3(String json)` —— 通信失败抛 `LeshouyingApiException` - `Map LeshouyingResponseParser.parseApi(String json)` - `boolean LeshouyingResponseParser.isPaySuccess(Map resp)` - `boolean LeshouyingResponseParser.isPayInProcess(Map resp)` - **`boolean LeshouyingResponseParser.isPayFail(Map resp)`** —— 只有**显式** `PAY_FAIL` 才为 true。 调用方(Task 6/7/9)**必须**用它而非 `!isPaySuccess(...)` 判定失败:若上游某天回了 `result_code: "SUCCESS"` 这类 未预期的码,`!isPaySuccess` 会把**成功当失败**落库(钱已收、单记失败)。未知码应走「既不成功也不失败」的异常分支, 记日志 + 待人工核对,不得静默归类。 **同一规则也管回调路径(Task 12),尽管它根本不调这三个谓词** —— 回调体不经 parser, `LsyNotifyService` 直接拿 `Map` 里的 `result_code` 比较。那里是同一个坑的另一半, 而且退款侧的后果更重(未知码落 FAIL 会放开重复退款的两道锁)。 - **`String LeshouyingSensitive.mask(String body)`** —— 对**外部来源**的任意文本尽力脱敏(键层 + 值层两层)。 Task 12 用它处理回调 `rawBody` 与验签失败的 `log.error`,Task 2 自己用它处理解析失败的响应。 - **`String LeshouyingSensitive.maskByKey(String text)`** —— 只按键名遮,**不碰无关长数字**。 Task 4 出站 trace 日志用它。**判据:内容由我们构造 → `maskByKey`;内容来自外部 → `mask`。** 反用有代价:出站用 `mask` 会把 `out_trade_no` 一起遮掉、日志失去诊断价值; 入站用 `maskByKey` 则丢掉值层,**XML 标签**与**嵌套转义 JSON**这 2 类会原样泄漏(实测)。 - `LeshouyingApiException extends RuntimeException`,提供 `getRawReturnCode()` / `getRawReturnMsg()` / `getRawJson()` —— 三个都带 `Raw`:**逐字回显上游原文,不可信,不要直接打日志**(要打用 `getMessage()`,已 sanitize)。 `getMessage()` 里两个字段都被压平了换行(含 U+2028 / U+0085)并封顶 512 字符;`\t` 故意保留。 **这四个行为每条都有断言**(`LeshouyingApiExceptionTest`)—— 它是 CWE-117 控制,不是排版。 - [ ] **Step 1: 写失败的测试** ```java package com.payment.platform.module.payment.leshouying.client; import ch.qos.logback.classic.Logger; import ch.qos.logback.classic.spi.ILoggingEvent; import ch.qos.logback.core.read.ListAppender; import cn.hutool.json.JSONObject; import org.junit.jupiter.api.Test; import org.slf4j.LoggerFactory; import java.util.Map; import java.util.stream.Collectors; import static org.junit.jupiter.api.Assertions.*; class LeshouyingResponseParserTest { private final LeshouyingResponseParser parser = new LeshouyingResponseParser(); @Test void parseV3_successEnvelope_returnsFields() { String json = "{\"return_code\":\"SUCCESS\",\"return_msg\":\"支付成功\"," + "\"result_code\":\"PAY_SUCCESS\",\"trade_no\":\"200001102F46230306\"," + "\"total_amount\":\"100\",\"sign\":\"abc\"}"; Map r = parser.parseV3(json); assertEquals("SUCCESS", r.get("return_code")); assertEquals("PAY_SUCCESS", r.get("result_code")); assertEquals("200001102F46230306", r.get("trade_no")); assertEquals("100", r.get("total_amount")); // ★ 这一条不能省。此前 4 处 isPaySuccess 断言全是 assertFalse, // 于是一个「恒返回 false」的实现(每一笔成功支付都判为未成功)能通过全部用例。 // 而 isPaySuccess 正是支付状态机将要分支的那个谓词。 assertTrue(parser.isPaySuccess(r)); assertFalse(parser.isPayInProcess(r)); assertFalse(parser.isPayFail(r)); } @Test void parseV3_payInProcess_isNotSuccessButNotException() { String json = "{\"return_code\":\"SUCCESS\",\"return_msg\":\"支付中\"," + "\"result_code\":\"PAY_IN_PROCESS\"}"; Map r = parser.parseV3(json); assertFalse(parser.isPaySuccess(r)); assertTrue(parser.isPayInProcess(r)); } @Test void parseV3_communicationFailure_throws() { String json = "{\"return_code\":\"FAIL\",\"return_msg\":\"参数不合法\"}"; LeshouyingApiException ex = assertThrows(LeshouyingApiException.class, () -> parser.parseV3(json)); assertEquals("FAIL", ex.getRawReturnCode()); assertEquals("参数不合法", ex.getRawReturnMsg()); assertEquals(json, ex.getRawJson()); } @Test void parseV3_payFail_isNotException_onlyBusinessFailure() { String json = "{\"return_code\":\"SUCCESS\",\"result_code\":\"PAY_FAIL\"," + "\"return_msg\":\"余额不足\"}"; Map r = parser.parseV3(json); assertFalse(parser.isPaySuccess(r)); assertFalse(parser.isPayInProcess(r)); assertEquals("PAY_FAIL", r.get("result_code")); } @Test void parseV3_nonJson_throws() { // 注意实际走的分支:Hutool 对 `502` **不抛**,而是解析成 {"html":502}, // 于是 return_code 缺失。故必须断言 PARSE_ERROR —— 若只断言异常类型, // 一个把它错分类成 EMPTY 或 null 的回归照样能通过。 LeshouyingApiException ex = assertThrows(LeshouyingApiException.class, () -> parser.parseV3("502")); assertEquals("PARSE_ERROR", ex.getRawReturnCode()); assertEquals("502", ex.getRawJson()); } @Test void parseV3_missingReturnCode_isParseErrorNotNullCode() { // 网关 HTML 错误页 / {} / 被截断后恰好可解析的片段,都会走到这里。 // 若不单独判定,会得到「乐收赢接口失败[null]: null」—— 联调期最需要原文时恰好丢掉原文。 for (String body : new String[]{"{\"html\":502}", "{}"}) { LeshouyingApiException ex = assertThrows(LeshouyingApiException.class, () -> parser.parseV3(body)); assertEquals("PARSE_ERROR", ex.getRawReturnCode(), body); assertEquals(body, ex.getRawJson()); } } @Test void parseApi_missingResultCode_isParseError() { LeshouyingApiException ex = assertThrows(LeshouyingApiException.class, () -> parser.parseApi("{\"html\":502}")); assertEquals("PARSE_ERROR", ex.getRawReturnCode()); } @Test void parseV3_topLevelJsonNullBecomesJavaNull() { // 实测(NullProbe / NullProbe2 探针,非推理): // · `parseObj("…null…").get(k)` 返回 `cn.hutool.json.JSONNull`,**不是** Java null, // 所以 `r.get("receipt_amount") == null` 为 false。 // · `String.valueOf(jsonNull)` 得到的是**字符串** "null"(长度 4,equals("null") 为真), // `Long.parseLong` 喂它 → NumberFormatException: For input string: "null"。 // (注意分叉:`Convert.toStr(jsonNull)` 反而给 Java null,走的是 NPE 而不是 NFE。 // 下游 payPatch 用的是 String.valueOf 那条路,所以是「炸」不是「空指针」。) Map r = parser.parseV3( "{\"return_code\":\"SUCCESS\",\"receipt_amount\":null}"); assertNull(r.get("receipt_amount")); } @Test void parseV3_normalization_isTopLevelOnly_nestedJsonNullSurvives() { // ★ 这条是**给未来的读者的警告**,同时钉住当前的真实边界:归一化只走顶层 // (实现对 `obj.keySet()` 逐键替换,不递归)。 // 实测(NullProbe):嵌套对象仍是 `JSONObject`,其内部取出的 null 仍是 `JSONNull`。 // 当前所有消费方(Task 6 payPatch、Task 12 回调)只读顶层字段,所以够用; // 但**别以为这是递归的** —— 谁要开始读嵌套字段,必须先把这里改成递归或在这里补归一化。 Map r = parser.parseV3( "{\"return_code\":\"SUCCESS\",\"data\":{\"outer\":null},\"arr\":[null]}"); Object nested = r.get("data"); assertNotNull(nested, "嵌套对象本身原样透传,不转 Map"); assertInstanceOf(JSONObject.class, nested, "实测:嵌套仍是 JSONObject,其内的 null 仍是 JSONNull —— 顶层归一化够不着它"); assertFalse(((JSONObject) nested).get("outer") == null, "嵌套里的 JSON null **没有**被归一化成 Java null,这是当前已接受的边界"); } @Test void isPayFail_trueOnlyForExplicitPayFail() { // 只有显式 PAY_FAIL 才算失败。上游某天回了 result_code="SUCCESS"(未预期的码)时, // isPaySuccess 与 isPayFail 都必须为 false —— 调用方据此走「未知码」分支, // 而不是用 !isPaySuccess 把一笔成功支付记成失败。 assertTrue(parser.isPayFail(Map.of("result_code", "PAY_FAIL"))); assertFalse(parser.isPayFail(Map.of("result_code", "PAY_SUCCESS"))); assertFalse(parser.isPayFail(Map.of("result_code", "SUCCESS"))); assertFalse(parser.isPayFail(null)); Map unknown = parser.parseV3( "{\"return_code\":\"SUCCESS\",\"result_code\":\"SUCCESS\"}"); assertFalse(parser.isPaySuccess(unknown)); assertFalse(parser.isPayFail(unknown)); } @Test void parseApi_successEnvelope_usesResultCodeNotReturnCode() { // 进件类报文正常形态:没有 return_code,成功看 result_code。 String json = "{\"result_code\":\"SUCCESS\",\"result_msg\":\"创建成功\"," + "\"mch_no\":\"Y52211090004\"}"; Map r = parser.parseApi(json); assertEquals("SUCCESS", r.get("result_code")); assertEquals("Y52211090004", r.get("mch_no")); // ★ 上面那段**区分不了**这条测试的名字 —— 报文里压根没有 return_code, // 所以「用 result_code 而不是 return_code」这个命题它一个字都没验证。 // 真正能区分的用例:两个码**同时存在且互相矛盾**,且必须往「不抛」的方向走。 // 如果哪天有人把 parseApi 改成读 return_code,这条会立刻变红。 Map contradictory = parser.parseApi( "{\"return_code\":\"FAIL\",\"result_code\":\"SUCCESS\",\"mch_no\":\"Y52211090004\"}"); assertEquals("SUCCESS", contradictory.get("result_code"), "存在矛盾的 return_code=FAIL 时,parseApi 必须只看 result_code 并放行"); } @Test void parseApi_businessFailure_throws() { String json = "{\"result_code\":\"FAIL\",\"result_msg\":\"商户已存在\"}"; LeshouyingApiException ex = assertThrows(LeshouyingApiException.class, () -> parser.parseApi(json)); assertEquals("FAIL", ex.getRawReturnCode()); assertEquals("商户已存在", ex.getRawReturnMsg()); } @Test void isPaySuccess_handlesNullAndEmptyGracefully() { assertFalse(parser.isPaySuccess(null)); assertFalse(parser.isPaySuccess(Map.of())); assertFalse(parser.isPayInProcess(null)); assertFalse(parser.isPayInProcess(Map.of())); } // ── catch 分支(解析失败路径)。★ 下面这四条是解析器那一行 // `LeshouyingSensitive.mask(...)` 的**唯一**守卫。 // // 补这四条之前,本类**没有任何输入能到达 catch** —— 上面所有畸形报文 // (`502`、`{}`)Hutool 都能解析成功,走的是「缺 return_code」那条。 // 后果是:18 位付款码的遮挡、以及 initCause 的因果链,两个都没人守。 // 实测(P6/P7 探针,直跑本类):这几个断言的值都是量出来的,不是推的。 @Test void parseFailure_pathIsReachable_truncatedJsonWithAuthCode() { String json = "{\"auth_code\":\"134573607383541001\""; // 截断,缺收尾 } LeshouyingApiException ex = assertThrows(LeshouyingApiException.class, () -> parser.parseV3(json)); assertEquals("PARSE_ERROR", ex.getRawReturnCode()); assertNotNull(ex.getCause(), "catch 分支必须 initCause —— 否则调用方程序化拿不到 Hutool 的根因"); assertEquals(json, ex.getRawJson(), "原文要逐字留下(排障时唯一的线索)"); } @Test void parseFailure_doesNotLeakAuthCodeIntoTheLog() { // ★ 这是**变异测试**:把那一行的 `LeshouyingSensitive.mask(` 去掉(只留 StrUtil.brief), // 本方法立刻变红。只断言「异常抛出了」是钉不住它的 —— 异常照抛,码照漏。 // 实测(Probe7):不加本方法时,删掉 mask 后日志里就是完整的 18 位付款码。 Logger logger = (Logger) LoggerFactory.getLogger(LeshouyingResponseParser.class); ListAppender appender = new ListAppender<>(); appender.start(); logger.addAppender(appender); try { assertThrows(LeshouyingApiException.class, () -> parser.parseV3("{\"auth_code\":\"134573607383541001\"")); String logged = appender.list.stream() .map(ILoggingEvent::getFormattedMessage) .collect(Collectors.joining("\n")); assertFalse(logged.contains("134573607383541001"), "18 位付款码不得出现在日志里;实际=" + logged); assertTrue(logged.contains("***"), "但报文本身仍要留痕 —— 否则这条日志失去排障价值;实际=" + logged); } finally { logger.detachAppender(appender); // 不摘会把后续测试的日志也吃进来 } } @Test void twoParseErrorBranches_areDistinguishable_byCause() { // 两条分支产出的 rawReturnCode 与 rawJson **完全相同**,只有 cause 能分辨。 // 不区分的话「走的到底是哪条」就没人知道 —— 这正是 catch 分支长期零覆盖却没被发现的原因。 LeshouyingApiException missingCode = assertThrows(LeshouyingApiException.class, () -> parser.parseV3("502")); // Hutool 解得动,但缺 return_code assertEquals("PARSE_ERROR", missingCode.getRawReturnCode()); assertNull(missingCode.getCause(), "网关 HTML 走的是「缺 return_code」分支,没进过 JSON 解析,不该有 cause"); LeshouyingApiException unparseable = assertThrows(LeshouyingApiException.class, () -> parser.parseV3("{\"a\":1")); // Hutool 解不动 assertEquals("PARSE_ERROR", unparseable.getRawReturnCode()); assertNotNull(unparseable.getCause(), "解不动才有 cause —— 这条断言把两条分支分开"); } @Test void blankBody_isClassifiedEmpty_notParseError() { // EMPTY 与 PARSE_ERROR 必须分得开:前者是「根本没拿到报文」,后者是「拿到了但不是 v3 响应」。 // 混成一类会让重试策略失去判据。实测(P6):三者都归 EMPTY,且都不带 cause。 for (String blank : new String[]{null, "", " "}) { LeshouyingApiException ex = assertThrows(LeshouyingApiException.class, () -> parser.parseV3(blank)); assertEquals("EMPTY", ex.getRawReturnCode(), "input=[" + blank + "]"); assertNull(ex.getCause(), "空报文没进过 JSON 解析,不该有 cause"); } } } ``` 同一步的第二个文件 —— **`LeshouyingApiExceptionTest`**: ```java package com.payment.platform.module.payment.leshouying.client; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; /** * 这个类测的**不是**「异常能不能抛」,是**上游文本会不会伪造出日志行**(CWE-117)。 * 上游文本被拼进 message,而 GlobalExceptionHandler 打栈时会带上 message —— * 所以这层压平是**安全控制**,不是美化排版。删掉 `sanitize` 里的任何一个 replace, * 下面就有断言变红。 * * 断言成对写:「压掉了什么」与「原文还在不在」。只断言前者,会把 * 「直接丢弃字段内容」也判成合格实现。 */ class LeshouyingApiExceptionTest { @Test void message_flattensCrAndLf_soUpstreamTextCannotForgeALogLine() { LeshouyingApiException ex = new LeshouyingApiException( "FAIL", "第一行\r\n2026-09-21 10:00:00 ERROR 伪造的日志行", "{}"); assertFalse(ex.getMessage().contains("\n"), "裸 \\n 会让上游文本变成一条新日志行"); assertFalse(ex.getMessage().contains("\r")); assertTrue(ex.getMessage().contains("2026-09-21 10:00:00 ERROR 伪造的日志行"), "是**压平**不是删除:内容仍要能读到,只是不再换行"); } @Test void message_flattensUnicodeLineSeparators_too() { // U+2028 / U+0085 在部分查看器(编辑器、日志检索界面)里也表现为换行。 // ★ 这里两个字符必须写成**转义**(反斜杠 + u + 恰好四位十六进制),例如 \u2028 和 \u0085: // 贴真字符的话它们不可见,把 U+2028 误写成 U+0085(或顺手删掉)都不会被任何人看出来。 // // 【别在注释里写占位式的转义记号】本行原来写的是「反斜杠 u X X X X」当作占位符, // 结果**整模块 test 编译失败**:javac 在词法分析**之前**就处理转义(JLS §3.3), // 注释里也不例外,而 XXXX 不是四位十六进制 → 非法的 Unicode 转义。 // 实测(javac 21):反斜杠 u 后接四位十六进制在注释里编译通过; // 四位位置放非十六进制字符、或反斜杠 u 后面不接十六进制,都直接编译失败。 // 所以:**本文件里凡是出现反斜杠加 u,后面必须紧跟恰好四位十六进制**,没有例外。 LeshouyingApiException ex = new LeshouyingApiException("FAIL", "A\u2028B\u0085C", "{}"); assertFalse(ex.getMessage().contains("\u2028")); assertFalse(ex.getMessage().contains("\u0085")); assertTrue(ex.getMessage().contains("A B C"), "两个都变成空格"); } @Test void message_sanitizesReturnCode_notJustReturnMsg() { // ★ 只堵 return_msg 是可绕过的:两个字段来自同一份 JSON、同一条连接、进同一行日志。 LeshouyingApiException ex = new LeshouyingApiException("FAIL\n伪造行", "正常", "{}"); assertFalse(ex.getMessage().contains("\n"), "return_code 里的 \\n 同样能伪造日志行"); assertTrue(ex.getMessage().contains("FAIL 伪造行")); } @Test void message_keepsTab_becauseItIsLegitimateContent() { // 故意不压 \t:它在正常报文里是合法内容,压掉只毁可读性; // 而在本项目 logback 的 `%msg%n` 下,它不构成伪造行。 LeshouyingApiException ex = new LeshouyingApiException("FAIL", "a\tb", "{}"); assertTrue(ex.getMessage().contains("a\tb"), "压掉 tab = 毁掉对齐过的上游报文,换来零安全收益"); } @Test void message_isCapped_soAMalformedResponseCannotInflateTheLog() { // 不封顶的话,上游文本会经 GlobalExceptionHandler 原样进日志,长度由上游说了算。 // 实测(P9 探针):单个 20000 字符字段 → message 长 **530** // (= 8 的前缀「乐收赢接口失败[」+ 4 的 "FAIL" + 3 的 "]: " + 512 + "..."); // 两个字段各 20000 → **1041**,即一条日志行的上界。 LeshouyingApiException ex = new LeshouyingApiException("FAIL", "x".repeat(20000), "{}"); assertTrue(ex.getMessage().endsWith("..."), "封顶要留可见标记,不能静默截断"); assertEquals(530, ex.getMessage().length(), "封顶后的精确长度(实测,不是量级估计)"); } @Test void rawFields_keepTheOriginalBytes_includingTheDangerousOnes() { // ★ 成对断言的另一半:message 被压平了,但字段**保留原文** —— 排障要看上游到底回了什么。 // 这也正是 getter 带 Raw 的原因:想打日志用 getMessage(); // 用 getRaw* 就是在往日志里写不可信文本,那一步必须由调用方自己想清楚。 String nastyCode = "FAIL\n伪造行"; String nastyMsg = "第一行\r\n第二行"; LeshouyingApiException ex = new LeshouyingApiException(nastyCode, nastyMsg, "{\"raw\":1}"); assertEquals(nastyCode, ex.getRawReturnCode()); assertEquals(nastyMsg, ex.getRawReturnMsg()); assertEquals("{\"raw\":1}", ex.getRawJson()); assertTrue(ex.getRawReturnMsg().contains("\n"), "字段保留原文,换行还在 —— 所以别直接打它"); } @Test void nullFields_doNotBlowUp() { LeshouyingApiException ex = new LeshouyingApiException(null, null, null); assertTrue(ex.getMessage().contains("乐收赢接口失败[null]: null")); assertNull(ex.getRawReturnCode()); } @Test void message_flattensParagraphSeparator_andVtFf_too() { // U+2029 是 U+2028 的**成对字符**(二者合起来才是「行/段分隔」这一对), // 只堵前一个等于只堵了一半。VT(U+000B)/ FF(U+000C)同理:Java 的日志读取方 // 不会在它们上面分行(只认 CR/LF),但在部分编辑器 / 日志检索界面里仍表现为换行 —— // 也正是本类压平 U+2028 时给出的那条理由,对它们逐字成立。 // // ★ 写法警告:下面这些字符**必须写成转义形式**(反斜杠 + u + 恰好四位十六进制), // 贴真字符的话它们不可见,被顺手删掉不会被任何人看出来。 // 实测(javac 21):U+2028 / U+2029 / U+0085 / U+000B / U+000C 的转义形式在 // char 字面量与字符串里**都编译通过**。 // 但 U+000A / U+000D **绝不能**写成转义形式:javac 在词法分析前就还原它们, // 会直接把源码行拆断、整模块编译失败。 LeshouyingApiException ex = new LeshouyingApiException( "A\u2029B\u000BC\u000CD", "x", "{}"); assertFalse(ex.getMessage().contains("\u2029")); assertFalse(ex.getMessage().contains("\u000B")); assertFalse(ex.getMessage().contains("\u000C")); assertTrue(ex.getMessage().contains("A B C D"), "三个都变成空格(实测 len=7)"); assertTrue(ex.getRawReturnCode().contains("\u2029"), "成对断言的另一半:字段保留原文,只有 message 被压平"); } @Test void noTwoArgConstructor_byDesign() { // 没有 (String, Throwable) 构造器是**故意的**,不是忘了写: // 它会绕过 sanitize(那层防线只长在 3 参路径上),并把调用方消息塞进 rawReturnMsg, // 破坏「三个 Raw 字段 = 逐字上游原文」的契约。 // 用例名加这条断言一起把设计钉住 —— 否则将来「顺手补个方便的构造器」会无声重开 CWE-117。 assertThrows(NoSuchMethodException.class, () -> LeshouyingApiException.class.getDeclaredConstructor(String.class, Throwable.class)); } } ``` - [ ] **Step 2: 运行测试确认失败** ```bash cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest='LeshouyingResponseParserTest,LeshouyingApiExceptionTest' ``` Expected: 编译失败 —— `LeshouyingApiException` 与 `LeshouyingResponseParser` 都不存在 (两个测试类都要引它俩;`LeshouyingSensitive` 只被待实现的解析器依赖,不在测试的编译面上)。 - [ ] **Step 3: 实现 LeshouyingApiException** ```java package com.payment.platform.module.payment.leshouying.client; import lombok.Getter; /** * 乐收赢上游异常。 * 两种触发场景: * 1. 通信/参数层失败(v3 的 return_code != SUCCESS,或 api 的 result_code != SUCCESS) * 2. 报文无法解析 * 业务层失败(result_code = PAY_FAIL)**不抛此异常** —— 那是正常的业务结果, * 需要落库记录,不能当成异常处理。 * * 【getter 为什么都带 Raw】三个字段都是上游报文的**逐字回显**,而上游文本是不可信输入。 * `log.error("...{}", ex.getRawReturnMsg())` 等于把上游可控文本写进日志,与 message 那条路径 * 同一类风险(CWE-117)。字段保留原文是对的(排障要看原样),所以把警告放进**名字**里: * 想打日志请用 `getMessage()`,它已经过 sanitize。命名与既有的 `getRawJson()` 一致。 */ @Getter public class LeshouyingApiException extends RuntimeException { /** * **每个字段**的长度上限 —— 不是 message 整体的上限。 * 实测(Probe:两个字段各 20000 字符):`getMessage().length() == 1041`,即一条日志行的上界约 1KB。 * 分解(对得上才写):`乐收赢接口失败[` 8 字符 + `]: ` 3 字符 = **11** 固定前缀; * 两个字段各封顶成 `512 + "..."` = 515,故 11 + 515 × 2 = **1041**。 * 防护仍然成立(畸形响应撑不爆日志),但别把它当 512 —— 原措辞说的是 message,量的是字段。 * ★ 写数字时把算式一起写出来:上一版这里写的是「18 前缀 + 512 + "..." × 2」, * 算式本身算不出 1041(=536),而它恰恰出现在一次「为了纠正不实数字」的改动里。 * 光写结论的数字没人能当场验,写算式就一眼看得出对不对。 */ private static final int MSG_CAP = 512; private final String rawReturnCode; private final String rawReturnMsg; private final String rawJson; public LeshouyingApiException(String returnCode, String returnMsg, String rawJson) { // 上游文本(或中间盒文本)被拼进 message,而 GlobalExceptionHandler 的 // log.error("系统异常 [{}]", uri, e) 打出栈时会带上 message —— 若其中含 \n,即可**伪造日志行**(CWE-117)。 // 本项目在入站侧已明确防这一点(见 LeshouyingSigner 为何一个字都不打日志),出站侧不能例外。 // // ★ 两个字段都要 sanitize,不能只做 return_msg:它们来自同一份 JSON、同一条连接, // 进的是同一行日志。只堵一个,换个字段就绕过(实测 return_code 里的裸 \n 原样进 getMessage)。 // 同时封顶长度防日志膨胀。 super("乐收赢接口失败[" + sanitize(returnCode) + "]: " + sanitize(returnMsg)); this.rawReturnCode = returnCode; this.rawReturnMsg = returnMsg; this.rawJson = rawJson; } /** * 仅供拼 message 用:压平行终止符 + 封顶。对象上的三个字段**一律保留原文**。 * * \r \n 是必须压的(CWE-117 的伪造成立与否就看这两个);\u2028 / \u2029 / \u0085 * 在部分查看器里也表现为换行,一并压掉 —— **U+2029 是 U+2028 的成对字符** * (二者合起来才是「行/段分隔」这一对),只堵前一个等于只堵了一半。 * VT(U+000B)与 FF(U+000C)同理:Java 的日志读取方不会在它们上面分行(只认 CR/LF), * 但在部分编辑器 / 日志检索界面里仍表现为换行 —— 也就是上面这条理由对它们逐字成立。 * **故意不动 \t**:它在正常报文里是合法内容,压掉反而毁可读性, * 而在本项目的 logback `%msg%n` 下不构成伪造行。 * * 【写法警告】上面这几个字符**必须写成转义形式**(反斜杠 + u + 恰好四位十六进制), * 不要贴真字符 —— 它们在多数编辑器里不可见,被当成普通空格顺手删掉就静默失去这层防护 * (本 plan 第一次写就踩了)。 * 反过来,**绝不能**把 U+000A / U+000D 也写成同样的转义形式:javac 在词法分析**之前** * 就还原它们(JLS §3.3),会直接把源代码行拆断、整模块编译失败。 * 实测(javac 21):U+2028 / U+2029 / U+0085 / U+000B / U+000C 的转义形式在 * char 字面量与字符串里**都编译通过**,可以放心写。 */ private static String sanitize(String msg) { if (msg == null) { return null; } String flat = msg.replace('\r', ' ') .replace('\n', ' ') .replace('\u2028', ' ') .replace('\u2029', ' ') .replace('\u0085', ' ') .replace('\u000B', ' ') .replace('\u000C', ' '); return flat.length() <= MSG_CAP ? flat : flat.substring(0, MSG_CAP) + "..."; } // 故意**不提供** (String message, Throwable cause) 这个构造器。 // 它会绕过 sanitize(那层防线只长在 3 参路径上),并把调用方消息塞进 rawReturnMsg, // 破坏「三个 Raw 字段 = 逐字上游原文」的契约 —— 将来 HTTP 层顺手写 // `new LeshouyingApiException("HTTP 请求失败: " + responseBody, e)` 就把 CWE-117 的口子 // 重新打开了(responseBody 里带 \r\n 就能伪造日志行)。 // 需要带 cause 时:3 参构造器 + initCause(e)(见 LeshouyingResponseParser 的 catch 分支)。 // 钉住它不被加回来的测试:noTwoArgConstructor_byDesign。 } ``` - [ ] **Step 4: 写 LeshouyingSensitiveTest(先跑一遍确认失败)** **这份语料不是想出来的,是量出来的。** 把下面这 9 类写法逐一喂给被替换掉的那版正则 (`("auth_code"\s*:\s*")[^"]*`),数出 **9 类全部漏网**。下面每条断言对应一次实测。(这里的 9 数的是**列出来的这 9 类写法**,不是语料总数;原写的「14 种语料里漏 9 种」引用了一份未保存的语料) 任何一条变红,都说明脱敏覆盖被削弱了。 (数字更正:此处原写「12 种里 10 种漏网」,来源是审查报告里的**可达性**计数 「10 of 12 shapes reach the catch block」被误当成了**泄漏**计数 —— 两个不同的量。 已按原正则逐条实跑改正。) ```java package com.payment.platform.module.payment.leshouying.client; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; class LeshouyingSensitiveTest { // ── A. 实测泄漏的 6 种形状:必须被遮蔽 ────────────────────────────────── // 共同点:这 6 种都是**旧正则** `("auth_code"\s*:\s*")[^"]*` 实测漏掉的形状 // (旧正则共漏 9 种,另 3 种是 XML 标签、驼峰 AuthCode / authCode)。 // // ★ 但别把这句话读成「靠值层兜住」—— 实测(Layer 探针): // 新键层**自己就能接住这 6 种里的 5 种**(大小写、单双引号、`=` 分隔、无引号值、空白分隔 // 全在它的覆盖里)。本组里**只有 masks_nestedEscapedJson 是值层独撑**。 // 值层不是这 6 条的兜底,它是这 6 条里**其中 1 条**(以及 C 组 XML 标签)的唯一防线。 // 把归属写清楚,是因为「键层是兜底、值层是主」这句话会让人以为删掉某一层只是减损覆盖率, // 而实际是:删掉值层 = 这两种形状直接裸奔。 @Test void masks_formEncoded() { assertEquals("auth_code=***&inst_no=900075", LeshouyingSensitive.mask("auth_code=134573607383541002&inst_no=900075")); } @Test void masks_upperCaseKey() { assertEquals("{\"AUTH_CODE\":\"***\"", LeshouyingSensitive.mask("{\"AUTH_CODE\":\"134573607383541005\"")); } @Test void masks_singleQuotedKeyAndValue() { assertEquals("{'auth_code':'***'", LeshouyingSensitive.mask("{'auth_code':'134573607383541006'")); } @Test void masks_unquotedValue() { assertEquals("{\"auth_code\":***,\"x\":1", LeshouyingSensitive.mask("{\"auth_code\":134573607383541007,\"x\":1")); } @Test void masks_nestedEscapedJson() { // 键名前有转义反斜杠,键层够不着它;值层不看键,照样命中。 assertEquals("{\"data\":\"{\\\"auth_code\\\":\\\"***\\\"}\"", LeshouyingSensitive.mask("{\"data\":\"{\\\"auth_code\\\":\\\"134573607383541008\\\"}\"")); } @Test void masks_spaceSeparated() { assertEquals("inst_no=900075 auth_code=***", LeshouyingSensitive.mask("inst_no=900075 auth_code=134573607383541011")); } // ── B. 本来就遮住的 4 种:不得回退 ───────────────────────────────────── @Test void keepsMasking_truncatedJson() { // ★ 期望值**没有**收尾的引号 —— 输入里本来就没有。 // 输入 `{"auth_code":"134573607383541001` 共 32 字符,值未闭合, // 任何「保留输入结构」的遮蔽都只能产出 32-18+3=17 字符的 `{"auth_code":"***`。 // 写成 `{"auth_code":"***"`(多一个引号)是**断言了一个输入提供不了的字符**,必定失败。 // 这条断言的要害是「18 位数字没了」,不是「引号配对」。 assertEquals("{\"auth_code\":\"***", LeshouyingSensitive.mask("{\"auth_code\":\"134573607383541001")); } @Test void keepsMasking_htmlWrapped() { assertEquals("502
req={\"auth_code\":\"***\"}", LeshouyingSensitive.mask("502
req={\"auth_code\":\"134573607383541003\"}")); } @Test void keepsMasking_whitespaceAroundColon() { assertEquals("{\"auth_code\" : \"***\" , \"x\":1", LeshouyingSensitive.mask("{\"auth_code\" : \"134573607383541009\" , \"x\":1")); } @Test void keepsMasking_unterminatedValue() { // 同 keepsMasking_truncatedJson:用例名自称 unterminatedValue,期望值就不该带收尾引号。 assertEquals("{\"auth_code\":\"***", LeshouyingSensitive.mask("{\"auth_code\":\"134573607383541010")); } // ── C. 仅在脱敏器层面成立(走不到日志路径)──────────────────────────── // 这两种 Hutool 其实**能**解析成功,因此永远不会进 catch 分支、永远不会被记日志。 // 在这里断言,是为了把「脱敏器本身的覆盖」和「实际被走到的路径」分开记录: // 前者是能力,后者才是风险敞口。 @Test void maskerCovers_xmlTag() { assertEquals("***", LeshouyingSensitive.mask("134573607383541004")); } @Test void maskerCovers_trailingGarbage() { assertEquals("{\"auth_code\":\"***\"} trailing", LeshouyingSensitive.mask("{\"auth_code\":\"134573607383541012\"} trailing")); } // ── D. 出现多次要全遮(replaceAll 而非 replaceFirst)────────────────── @Test void masks_everyOccurrence() { String out = LeshouyingSensitive.mask( "{\"auth_code\":\"134573607383541013\",\"auth_code\":\"134573607383541014\"}"); assertEquals("{\"auth_code\":\"***\",\"auth_code\":\"***\"}", out); assertFalse(out.contains("134573607383541014"), "只遮第一处就是漏"); } // ── E. 诊断字段的存活与代价:这条测试是「成本」的书面记录 ───────────── // 值层按长度遮,不看字段名 —— 所以它会连**无关的长数字**一起遮掉。 // 这是刻意接受的代价:本行的职责是看「回来的是 HTML 还是被截断的 JSON」,不是看具体字段值。 @Test void masksLongDigits_evenWhenUnrelated_preservingShortAndAlphanumeric() { String body = "{\"return_code\":\"SUCCESS\",\"return_msg\":\"支付成功\"," + "\"result_code\":\"PAY_SUCCESS\",\"trade_no\":\"200001102F46230306\"," + "\"receipt_amount\":\"100\",\"pay_time\":\"20260921143025\"," + "\"out_trade_no\":\"134573607383541015\"}"; String out = LeshouyingSensitive.mask(body); // 必须存活:这四样是排障时真正要读的 assertTrue(out.contains("\"return_code\":\"SUCCESS\"")); assertTrue(out.contains("\"return_msg\":\"支付成功\"")); assertTrue(out.contains("\"result_code\":\"PAY_SUCCESS\"")); assertTrue(out.contains("\"trade_no\":\"200001102F46230306\""), "字母数字混合的 trade_no 不受值层影响"); assertTrue(out.contains("\"receipt_amount\":\"100\""), "短数字不受影响"); // 被牺牲:14 位的 pay_time 与 12 位及以上的 out_trade_no 会一起被遮。 // 这不是缺陷,是「宁可少看几个字段,不可留下一个付款码」的兑换。 assertTrue(out.contains("\"pay_time\":\"***\"")); assertTrue(out.contains("\"out_trade_no\":\"***\"")); } @Test void valueLayer_thresholdBoundary_11Survives_12IsMasked() { // 阈值是**闭区间下界 12**(`\d{12,}`)。「12 位以上」在中文里既能读成 ≥12 也能读成 >12, // 所以边界必须写成可执行的断言,而不是留在注释里靠读者理解。 // 用 return_msg 承载,避免键层插手 —— 这两条只考察值层。 assertTrue(LeshouyingSensitive.mask("{\"return_msg\":\"12345678901\"}").contains("12345678901"), "11 位不该被遮(否则是在无谓地毁诊断信息)"); assertFalse(LeshouyingSensitive.mask("{\"return_msg\":\"123456789012\"}").contains("123456789012"), "12 位必须被遮 —— 这是下界的另一侧"); } @Test void valueLayer_isPrefixAgnostic_notAnchoredToThe10_15Range() { // ★ 这条测试是**设计决策的钉子**,不是覆盖率凑数。 // 计划记录付款码「18 位、以 10-15 开头」,所以 `1[0-5]\d{16}` 看起来更精确。 // 我们**故意不用**它:前缀锚点朝危险方向失败 —— 上游换号段就静默恢复泄漏。 // 实测(Pin 探针)用 99 开头的码喂这两种形状: // `\d{12,}` → 遮住;`1[0-5]\d{16}` → **原样泄漏**。 // 所以下面两条断言在有人把阈值改回前缀锚点时会**因为漏码而变红**, // 而不是因为「遮多了」这种可商量的代价变红 —— 这正是要钉住的方向。 // // 两个 body 都选**值层独撑**的形状(键层够不着,实测见 Layer 探针), // 否则键层会把码遮掉,这条测试就退化成不区分任何东西的空断言。 assertFalse(LeshouyingSensitive.mask("{\"data\":\"{\\\"auth_code\\\":\\\"994573607383541008\\\"}\"}") .contains("994573607383541008"), "嵌套转义 + 非 10-15 号段:只有值层能接住"); assertFalse(LeshouyingSensitive.mask("994573607383541004") .contains("994573607383541004"), "XML 标签 + 非 10-15 号段:同上"); } @Test void mask_null_isNull() { assertNull(LeshouyingSensitive.mask(null)); } // ── F. 具名的、已接受的残留 ──────────────────────────────────────────── // 值层对**键的写法**不变,但对**值的再编码**不变。下面几条**故意断言「没遮住」**: // 这不是认可泄漏,而是把边界写成可执行的记录 —— 谁要是哪天真的补上了, // 这条测试会变红,提醒他回来更新类注释里的残留清单,而不是默默改变了对外承诺。 // // ★ 断言风格:残留用例**一律用 `contains`,不用 `assertEquals(body, mask(body))`**。 // 因为键层即使遮不住值,也可能把 `auth_code=` 这段标签重写成 `auth_code=***`(值匹配为空串), // 输出与输入并不逐字节相等 —— 那不是「遮住了」,但等式断言会失败。 // 要断言的是**敏感内容是否还在**,不是输出是否等于输入。 @Test void covered_urlEncodedValue_whenKeySurvives() { // ★ 这一条原先被我错放进「残留」列表,实测它是**被覆盖**的: // 键层的值尾巴是 `[^"'&\s,}]*`,而 `%` **不在**排除集里, // 所以 `auth_code=` 之后的 `%31%33…%36` 被整段消费并替换。 // (值层确实看不见它 —— 没有 12 位连续数字 —— 但键层把它兜住了。我原先只想到值层。) String body = "auth_code=%31%33%34%35%37%33%36%30%37%33%38%33%35%34%31%30%31%36"; assertEquals("auth_code=***", LeshouyingSensitive.mask(body)); } @Test void covered_base64Value_whenKeySurvives() { // 上面那条残留用例的注释里**声称**过这个形态(`auth_code=MTM0NTcz…`,base64 值但键名还在) // 「键层能遮住」—— 但原先只有注释、没有断言。而「注释里声称覆盖了、实际没人守」 // 正是本轮评审在别处抓出来的那类问题,同一把尺子在这里也适用。 // // 实测(Probe8):`mask` 与 `maskByKey` 都输出 `auth_code=***`。 // 兜住它的是**键层**(值尾巴 `[^"'&\s,}]*` 把整段 base64 吃掉); // 值层在这里是瞎的 —— 实测该串不匹配 \\d{12,}(base64 里没有 12 位连续数字)。 assertEquals("auth_code=***", LeshouyingSensitive.mask("auth_code=MTM0NTczNjA3MzgzNTQxMDE2")); } // ── C. 键层「接住 7 类」这个数字的钉子 ─────────────────────────────────── // 类注释里数出 `9 = 键层接住 7 + 只有值层接得住 2`。那 7 类里原本只有 5 类具名测过 // (masks_formEncoded / masks_upperCaseKey / masks_singleQuotedKeyAndValue / // masks_unquotedValue / masks_spaceSeparated),**驼峰这 2 类没有断言** —— // 「注释里声称覆盖、实际没人守」与 base64 那条是同一类问题,同一把尺子量到底。 @Test void keyLayer_catchesPascalCaseKey() { // 兜住它的是键层里那个 `(?i)`:`auth` 匹配 `Auth`、`_?` 匹配空、`code` 匹配 `Code`。 // ★ 必须断言 `maskByKey` 而不是 `mask`:这一条的值是裸 18 位数字, // 值层 `\d{12,}` 也能命中它 —— 用 `mask` 断言时,键层的 `(?i)` 整个被删掉 // 测试照样绿(改成值层在兜),那就钉不住这个数字。 assertEquals("{\"AuthCode\":\"***\"}", LeshouyingSensitive.maskByKey("{\"AuthCode\":\"134573607383541020\"}")); // 两层都上时结果一致(值层在此无额外贡献) assertEquals("{\"AuthCode\":\"***\"}", LeshouyingSensitive.mask("{\"AuthCode\":\"134573607383541020\"}")); } @Test void keyLayer_catchesCamelCaseKey() { assertEquals("{\"authCode\":\"***\"}", LeshouyingSensitive.maskByKey("{\"authCode\":\"134573607383541021\"}")); assertEquals("{\"authCode\":\"***\"}", LeshouyingSensitive.mask("{\"authCode\":\"134573607383541021\"}")); } @Test void namedResidual_htmlEntityValue_survives() { // 真残留:`&` **在**键层的排除集里,所以 `auth_code=` 匹配到**空值**就停了, // 标签被重写成 `auth_code=***`,而实体编码的数字原样留在后面。 // 这也正是这里**不能**用 assertEquals(body, ...) 的原因 —— 输出与输入并不逐字节相等。 String out = LeshouyingSensitive.mask( "auth_code=134573607383"); assertTrue(out.contains("134573607383"), "HTML 实体:值层认不出 1 这种写法,键层被 & 截断 —— 真残留"); } @Test void namedResidual_interiorSpaces_surviveInPart() { // 键层只吃掉了第一段(1345),后三段仍在。**部分遮蔽 ≠ 遮蔽。** String out = LeshouyingSensitive.mask("auth_code=1345 7360 7383 5418"); assertTrue(out.startsWith("auth_code=***"), "标签本身会被重写(键层匹配到空串就停)—— 所以本节一律 contains,不用等式"); assertTrue(out.contains("7360 7383 5418"), "分隔符把连续数字切成短片段,值层也接不住"); } @Test void namedResidual_base64WithoutKeyContext_isNotMasked() { // 注意区分:`auth_code=MTM0NTcz...` 这种「键还在」的形态,键层**能**遮住。 // 遮不住的是键名本身也被编码掉的情形,例如整包 base64。 // 断言用 contains(本节风格):要问的是「码还在不在」,不是「输出等不等于输入」。 String body = "{\"d\":\"MTM0NTczNjA3MzgzNTQxMDE2\"}"; assertTrue(LeshouyingSensitive.mask(body).contains("MTM0NTczNjA3MzgzNTQxMDE2"), "键名被编码掉:键层无从下手,值层也认不出(base64 里没有 12 位连续数字)"); } // ── G. 出站用 maskByKey:只按键名遮,不误伤我们自己的长数字 ───────────── @Test void maskByKey_masksAuthCode_butLeavesOurOwnLongNumbersAlone() { // 出站那一行 trace 日志的意义就是「这次请求发了什么参数」。 // out_trade_no / pay_trace_no 是我们自己的对账号,遮掉它 = 删掉这条日志。 String payload = "{auth_code=134573607383541001, out_trade_no=202609210000000123, " + "inst_no=900075, total_amount=100}"; assertEquals("{auth_code=***, out_trade_no=202609210000000123, inst_no=900075, total_amount=100}", LeshouyingSensitive.maskByKey(payload)); } @Test void maskByKey_isStrictlyWeakerThanMask() { // ★ 「严格更弱」必须**在同一个输入上给出两个不同的结论**才算证明。 // 只断言 maskByKey 在某输入上恒等,证明不了它比 mask 弱 —— 那两条断言合起来 // 在 mask 也是恒等的输入上同样成立,什么都没区分。 // 这个 body 是实测挑的(Layer 探针):mask 遮得住(值层独撑),maskByKey 原样返回 // (键层够不着嵌套转义的键)。这才是两层分道扬镳的那一点。 // 它同时是「别把出站的 maskByKey 挪到入站用」的哨兵:入站是任意报文形态, // 一旦改用 maskByKey,这条路径立刻退回裸奔。 String body = "{\"data\":\"{\\\"auth_code\\\":\\\"134573607383541008\\\"}\"}"; assertFalse(LeshouyingSensitive.mask(body).contains("134573607383541008"), "mask 必须遮住 —— 由值层完成,键层在这个形状上是瞎的"); assertTrue(LeshouyingSensitive.maskByKey(body).contains("134573607383541008"), "maskByKey 遮不住 —— 这正是它只配用于「我们自己构造的报文」的原因"); } } ``` 运行确认失败: ```bash cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LeshouyingSensitiveTest ``` Expected: 编译失败 —— `LeshouyingSensitive` 不存在。 - [ ] **Step 5: 实现 LeshouyingSensitive** ```java package com.payment.platform.module.payment.leshouying.client; import java.util.regex.Pattern; /** * 乐收赢报文脱敏 —— Task 2 / Task 4 / Task 12 共用同一份实现,不要各写一份正则。 * * 【这不是安全边界】它是对**任意畸形文本**的尽力而为,**不保证完整**。 * 任何人都不得据此认为「这里打日志 / 落库是安全的」。真正的边界是: * 付款码不进任何实体、不进任何结构化列(见建表脚本与实体的守卫测试)。 * * 【为什么按「值的形态」而不是按字段名匹配】这条是实测结论,不是推理。 * 被替换掉的那版正则 `("auth_code"\s*:\s*")[^"]*` 要求畸形文本里恰好命中 `"auth_code":` 这一种拼法。 * 实测(把那版正则逐条喂给下面这 9 类写法):**9 类全部漏网** —— form-encoded、XML 标签、大写键、单引号、 * 值无引号、双层转义嵌套、空格分隔、驼峰 `AuthCode` / `authCode`。 * ★ 这里的「9」数的是**上面列出的这 9 类**,不是某份语料的总数。 * 之前这里写的是「14 种形态语料里漏 9 种」,而那份语料**没有被保存下来**, * 导致该数字当场无法复现(控制方重构语料跑出来的是 11,两者并不矛盾, * 只是语料不同)。不可复现的数字**比没有数字更坏**,因为它看起来像证据。 * 改成数**下面这九类**后,每一类都可当场用那版正则验。 * 补完这几种还有 HTML 实体 / 键名编码 / 码内插分隔符,是必输的 whack-a-mole。 * 而付款码的**形态**在改变写法时是不变量:键名可以换大小写、换引号、被嵌套转义, * 18 位数字仍然是 18 位数字。 * * 【值层不是锦上添花,是唯一防线】上面那 9 类里,新键层能自己接住 7 类 * (这 7 类现在**每一类都有具名测试**:5 类见 B 组,驼峰 2 类见 `keyLayer_catchesPascalCaseKey` / * `keyLayer_catchesCamelCaseKey` —— 后者断言的是 `maskByKey`,不是 `mask`,因为这两条的值是裸长数字, * 用 `mask` 断言时删掉键层的 `(?i)` 测试照样绿,钉不住「键层接住」这个说法); * **剩下 2 种只有值层接得住**:XML 标签(`…`)、嵌套转义 JSON(`\"auth_code\":\"…`)。 * 实测:把值层去掉,这两种立刻把 18 位付款码原样打出来。所以「两层都要」不是保守, * 是这两条路径的**唯一**拦截点。这 2 类各有一条具名测试兜着:`valueLayer_isPrefixAgnostic_notAnchoredToThe10_15Range`(XML 标签 + 非 10-15 号段)与 `maskByKey_isStrictlyWeakerThanMask`(嵌套转义)——**删掉值层,这两条立刻变红**。 * * 【阈值为什么取长度而不锚定前缀】计划记录付款码是 18 位、以 10-15 开头。 * 但 `1[0-5]\d{16}` 这类「前缀+长度」锚点会**朝危险方向失败**:上游一旦换号段或改位数, * 它静默恢复泄漏。长度阈值则朝安全方向退化(多遮几个无关长数字),代价只是这条诊断日志少几个字段值。 * * 【代价(已实测,见 LeshouyingSensitiveTest)】值层不看字段名,所以 14 位的 pay_time、 * 12 位及以上的 out_trade_no 会一起被遮;`trade_no`(字母数字混合)、`receipt_amount`(短)、 * `return_code` / `return_msg` / `result_code` 不受影响。这是刻意的兑换: * 这条日志的职责是回答「回来的是 HTML 还是被截断的 JSON」,不是复现字段值。 * 边界是**闭区间下界 12**:11 位不遮、12 位遮。这条边界有专属断言 * (`valueLayer_thresholdBoundary_11Survives_12IsMasked`)——「12 位以上」在中文里 * 既可读成 ≥12 也可读成 >12,而代码是前者。 * * 【具名的、已接受的残留(勿当成已覆盖)】值层对「键的写法」不变,对「值的再编码」不变。 * 下面三条是**实测确认遮不住**的(每条都有对应的断言测试): * 1. HTML 实体(13…)—— `&` 在键层的排除集里,键层匹配到空值就停, * 所以标签会被重写成 `auth_code=***`,而实体编码的数字原样留在后面。 * 2. 码内被插入分隔符(1345 7360 7383 5418)—— 连续数字被打断,值层接不住,只遮得住第一段。 * 3. 键名本身也被编码掉的情形(如整包 base64)。 * * 【几条**看起来**像残留、实测其实被覆盖的(别误加进上面的清单)】 * - `auth_code=%31%33%34…`:**能遮**。键层的值尾巴 `[^"'&\s,}]*` 不含 `%`,故整段被消费。 * (值层看不见它 —— 没有 12 位连续数字 —— 但键层兜住了。只想到值层就会误判成残留。) * - `auth_code=MTM0NTcz…`(base64 但键还在):同上,**能遮**。只有键名也被编码才漏。 * * 之所以接受:这些只能在「任意畸形文本」上才可能可靠识别,而那种识别本身不可靠。 * 真正的防线始终是付款码不落结构化存储,不是这一层正则。 */ public final class LeshouyingSensitive { /** 值层(主):连续 12 位及以上数字(`{12,}` 是闭区间下界)。编码无关,故不受键名写法影响。 */ private static final Pattern LONG_DIGITS = Pattern.compile("\\d{12,}"); /** * 键层(兜底):覆盖规范 JSON 与最常见的写法变体 —— 大小写、单双引号、`:` 或 `=` 分隔、 * 冒号两侧空白。挡住「非数字」的将来形态(值层只认数字)。保留键名本身, * 便于排障时仍能看懂报文结构。 * * 【三条要知道的边界,别按直觉推广】 * 1. **没有词边界**(实测,同 `Rx` 探针):加 `\b` 会挡掉 `_auth_code` 与 `xauth_code` * (`_` 是词字符,前面没有边界),只有 `authcode` 还能命中 —— 即「加 `\b` 让匹配行为 * 在 `authcode` 和 `auth_code` 之间变得不一致」。不加则三种都命中; * 代价是 `myauth_code=` 这种也会命中 —— 方向安全,接受。 * 2. **`replaceAll("$1" + MASK)` 的风险在 `MASK`,不在 group 1**(实测,见 `Rx` 探针): * `$1` 是**按引用**解析的,group 1 捕获到的文本原样拷进结果,永远不会被二次解析成 * 替换语法 —— 输入里就算有 `$1`,输出里还是 `$1`。所以 group 1 的字符集与安全性无关。 * 真正会被解析的是 `MASK` 拼在 `$1` 后面的那一段: * · `MASK` 含反斜杠 → **静默吞掉**:实测用替换串「`$1` + 反斜杠 + 空格 + `x`」 * 跑 `Pattern.compile("(a)").matcher("a")`,输出 **`a x`** —— 反斜杠没了, * 而 group 1 的内容与那个空格**都还在**。最危险的一种,因为它无声。 * · `MASK` 含 `$` → 抛 `IllegalArgumentException: Illegal group reference`(实测)。 * · `MASK` 以数字开头 → **安全**:Java 会回退到「最大的合法组号」, * `"$1" + "2xx"` 实测输出 **`a2xx`**(组 1 = `a`,`2xx` 当字面量),不是组 12。 * 只有组 1 捕到空串时输出才是 `2xx` —— 那是个特例,别当成通例。 * 当前 `MASK = "***"`,不含 `$` 也不含 `\`,安全。**改 `MASK` 时必须重查这两条。** * 3. **尾巴 `[^"'&\s,}]*` 是「过度吃进」的**:它会把 `%31%33…`(URL 编码)整段吃掉。 * 这正是我们要的 —— 见类注释里那条「看起来像残留、其实被覆盖」。 * 代价是遇到 `auth_code=abc123def` 时会把整串非空白内容当值消费掉; * 在「值不含空白」的假设下这是对的,在含空白的畸形文本里会提前停(已被测试具名记录)。 */ private static final Pattern AUTH_CODE_KEY = Pattern.compile( "(?i)([\"']?auth_?code[\"']?\\s*[:=]\\s*[\"']?)[^\"'&\\s,}]*"); private static final String MASK = "***"; private LeshouyingSensitive() { } /** * 【用于我们**无法预知内容**的文本】解析失败的响应、入站回调整文。 * 先按键名遮(保留键名,便于看懂报文结构),再按长数字遮(兜住所有键名变体)。 * 两层都做,因为**两层的失效模式互相独立**:键层败于写法变体,值层败于值的再编码。 */ public static String mask(String body) { if (body == null) { return null; } return LONG_DIGITS.matcher(maskByKey(body)).replaceAll(MASK); } /** * 【只用于我们**自己构造**的报文】仅按键名遮,**不碰无关的长数字**。 * * 为什么出站日志不能用 `mask()`:值层不看字段名,会把 `out_trade_no` / `pay_trace_no` * 这类**我们自己的、用来对账的**长数字一起遮掉。而出站那一行 trace 日志的全部意义 * 就是回答「这次请求到底发了什么参数」—— 把参数遮掉,等于把这条日志删了。 * 出站参数由本模块构造,键名叫什么我们**确定知道**,键层足够,不需要值层兜底。 * * 判据一句话:**内容由我们构造 → `maskByKey`;内容来自外部 → `mask`。** * 反过来用会出问题:出站用 `mask` 日志失去诊断价值,入站用 `maskByKey` 则丢掉值层,**XML 标签**与**嵌套转义 JSON**这 2 类会原样泄漏 * (实测:拿**11 形态语料**跑 `Probe3` —— 上面那 9 类 + HTML 实体 + 码内插空格 —— * `maskByKey` 漏 3、`mask` 漏 1,差集正是这 2 类。 * ★ 语料必须点名:紧邻的上一段讲的是 **9 类**,若读者把「同一份语料」的先行词理解成那 9 类, * 就会读到「`mask` 漏 1」,与本类开头「`mask` 对 9 类漏 0」直接冲突。数字对不上不是矛盾,是语料不同 —— 所以才要写清是哪一份。) */ public static String maskByKey(String text) { if (text == null) { return null; } return AUTH_CODE_KEY.matcher(text).replaceAll("$1" + MASK); } } ``` - [ ] **Step 6: 实现 LeshouyingResponseParser** ```java package com.payment.platform.module.payment.leshouying.client; import cn.hutool.core.util.StrUtil; import cn.hutool.json.JSONNull; import cn.hutool.json.JSONObject; import cn.hutool.json.JSONUtil; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import java.util.LinkedHashMap; import java.util.Map; /** * 乐收赢两套报文信封解析器。 * * 支付类 /v3/*:return_code(SUCCESS/FAIL) 表通信,result_code 表业务(PAY_SUCCESS/PAY_IN_PROCESS/PAY_FAIL) * 进件类 /api/*:result_code(SUCCESS/FAIL) 表成败,result_msg 表消息 * * 混用两套判定会导致「支付中」被误判为失败或反之,必须分开。 */ @Slf4j @Component public class LeshouyingResponseParser { private static final String SUCCESS = "SUCCESS"; private static final String PAY_SUCCESS = "PAY_SUCCESS"; private static final String PAY_IN_PROCESS = "PAY_IN_PROCESS"; private static final String PAY_FAIL = "PAY_FAIL"; /** 解析 /v3/* 报文。通信层失败抛异常;业务层失败(PAY_FAIL)正常返回。 */ public Map parseV3(String json) { Map map = toMap(json); String returnCode = str(map.get("return_code")); if (StrUtil.isBlank(returnCode)) { // /v3/* 报文必有 return_code。没有它,说明这根本不是乐收赢的 v3 响应 // (网关 HTML 错误页、{}、被截断后恰好仍可解析的片段),而不是「上游回了 FAIL」。 // 实测 Hutool 对 `502` **不抛**,而是解成 {"html":502},故这条分支是真实入口; // 若让它落进下面那条,会得到「乐收赢接口失败[null]: null」—— 既误导,又恰好丢掉了联调最需要的原文。 throw new LeshouyingApiException("PARSE_ERROR", "乐收赢返回报文缺少 return_code,非 v3 响应", json); } if (!SUCCESS.equals(returnCode)) { throw new LeshouyingApiException(returnCode, str(map.get("return_msg")), json); } return map; } /** * 解析 /api/* 报文。非 SUCCESS 一律抛异常(进件类没有独立的"业务失败"语义)。 * * 【本方法的边界:只认信封,不认字段】它验证的是「这是不是一个乐收赢的 /api/* 响应」 * (看 `result_code` 在不在、是不是 SUCCESS),**不校验也不列举任何业务字段**。 * 字段由调用方按需 `map.get(...)` 自取。 * * 【为什么这样定】进件是**子项目 B**(spec §13),本期 A 阶段只用文档给的测试商户 * `Y52211090004` 走通支付与收款生命周期,不去创建商户。进件的字段集在 B 才成立, * 而且**现在也定不下来**:spec §13 记着 B 有 7 条待确认项,其中 `reviewed_status` * 的值域在三处文档里互相冲突。所以这里**故意不写**字段白名单 —— * 写一份当下就自相矛盾的契约,比不写更糟:它会让后来的调用方以为漏掉的字段是 * 「上游没有」,而不是「我们没查」。 * * 这同时是**已知的范围限制**:本期 `parseApi` 的测试只覆盖信封行为 * (缺码 / 失败码 / 矛盾的 return_code),覆盖不到任何字段语义。不是遗漏。 */ public Map parseApi(String json) { Map map = toMap(json); String resultCode = str(map.get("result_code")); if (StrUtil.isBlank(resultCode)) { // 与 parseV3 同理:缺 result_code 说明不是乐收赢的进件响应,不是「上游回了 FAIL」。 throw new LeshouyingApiException("PARSE_ERROR", "乐收赢返回报文缺少 result_code,非 api 响应", json); } if (!SUCCESS.equals(resultCode)) { throw new LeshouyingApiException(resultCode, str(map.get("result_msg")), json); } return map; } public boolean isPaySuccess(Map resp) { return resp != null && PAY_SUCCESS.equals(str(resp.get("result_code"))); } public boolean isPayInProcess(Map resp) { return resp != null && PAY_IN_PROCESS.equals(str(resp.get("result_code"))); } /** * 只有**显式** PAY_FAIL 才算失败。 * * 【为什么需要它】调用方若写 `isPaySuccess(...) ? SUCCESS : FAIL`,那么上游一旦回了 * 未预期的 result_code(例如某端点回的是通用成功码 "SUCCESS" 而非 "PAY_SUCCESS"), * **一笔已经收到钱的支付会被落库为失败** —— 这正是本类存在的意义所要防的静默误判。 * 三个谓词全为 false 时,调用方必须走「未知码」分支:记日志 + 待人工核对,不得静默归类。 */ public boolean isPayFail(Map resp) { return resp != null && PAY_FAIL.equals(str(resp.get("result_code"))); } private Map toMap(String json) { if (StrUtil.isBlank(json)) { throw new LeshouyingApiException("EMPTY", "乐收赢返回空报文", json); } try { JSONObject obj = JSONUtil.parseObj(json); Map map = new LinkedHashMap<>(); for (String k : obj.keySet()) { Object v = obj.get(k); // Hutool 把 JSON 的 null 解成 JSONNull 单例,**不是** Java null(实测)。 // 不归一化的话 `resp.get(k) == null` 为 false,而 `String.valueOf(v)` 会得到 // **字符串** "null"(实测 len=4)—— 下游 Task 6 的 payPatch 拿它去 parseLong // 会抛 NumberFormatException: For input string: "null"。统一归一成 Java null。 // // 【边界:只归一层顶层,不递归】实测(NullProbe):嵌套对象原样透传为 JSONObject, // 其内部的 null 仍是 JSONNull,本循环够不着。当前消费方(payPatch、回调)只读顶层 // 字段,所以够用;**谁要开始读嵌套字段,先回来把这里改成递归**。 // 钉住这条边界的测试:parseV3_normalization_isTopLevelOnly_nestedJsonNullSurvives。 map.put(k, v instanceof JSONNull ? null : v); } return map; } catch (Exception e) { // 解析失败时把原文记进日志,是排查的唯一线索(十有八九是网关 HTML 或截断 JSON)。 // 必经 LeshouyingSensitive.mask —— 它是这条路径上**唯一**的脱敏控制。 // 【不要把 StrUtil.brief 的长度上限当兜底】brief 是「头 + 尾」缩略,不是前缀截断: // 位于上限之后、靠近末尾的内容照样进日志(实测 len=633、码在 offset 615 → 仍被记录)。 log.error("乐收赢报文解析失败, json={}", StrUtil.brief(LeshouyingSensitive.mask(json), 500), e); LeshouyingApiException ex = new LeshouyingApiException( "PARSE_ERROR", "乐收赢返回报文无法解析为 JSON", json); ex.initCause(e); // 否则 Hutool 的 JSONException 只活在上一行日志里,调用方程序化拿不到 throw ex; } } private String str(Object o) { return o == null ? null : String.valueOf(o); } } ``` - [ ] **Step 7: 运行测试确认全绿** ```bash cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest='LeshouyingResponseParserTest,LeshouyingApiExceptionTest,LeshouyingSensitiveTest' ``` Expected: - `LeshouyingResponseParserTest` → `Tests run: 17, Failures: 0, Errors: 0` - `LeshouyingApiExceptionTest` → `Tests run: 9, Failures: 0, Errors: 0` - `LeshouyingSensitiveTest` → `Tests run: 26, Failures: 0, Errors: 0` 模块级回归(Task 1 的 Signer 不得被碰坏):`-Dtest='Leshouying*Test'` → 17 + 9 + 26 + 11 = **63**。 (计数要盯着看,数字对不上说明有测试被漏写。上面的数字是**两轮修复之后的**: 首轮交付 13 / 7 / 23(合计 54);修复轮一加 7 条 —— parser +4(catch 可达性与变异可杀性、 两条 PARSE_ERROR 分支可分辨、EMPTY 分类)、exception +2(新压字符的成对断言、 二参构造器不得回归)、sensitive +1(base64 键名保留形态);修复轮二再加 sensitive +2 (驼峰 `AuthCode` / `authCode` 两类 —— 它们此前是「注释里声称被键层接住、实际没人守」, 与 base64 那条同一类问题,同一把尺子量到底)。 17 = 13 + 4;9 = 7 + 2;26 = 23 + 1 + 2。 这些测试都是**设计决策或安全控制的钉子**(顶层归一化的边界、阈值下界、不锚定前缀、 message 的两字段压平 / U+2028+U+0085 / `\t` 保留 / 512 封顶 / 原文保留 / catch 的 mask 不可删), 不是覆盖率凑数:删掉它们代码照样全绿,但那时「为什么这么做」就只剩注释里的一句话了。 **异常那 9 条尤其是 —— 删掉 `sanitize` 里任何一个 replace 都不会有任何东西变红。**) - [ ] **Step 8: 确认工作区状态(不提交)** ```bash git status --short ``` --- ## Task 3: 数据库迁移 + 4 个实体 + 4 个 Mapper **Files:** - Create: `java/src/main/resources/db/migration/V1.8__create_pay_lsy_tables.sql`(**不执行**) - Create: `java/src/main/java/com/payment/platform/module/payment/leshouying/enums/{LsyPayScene,LsyPayType,LsyOrderStatus,LsyRefundStatus,LsyNotifyStatus}.java` - Create: `java/src/main/java/com/payment/platform/module/payment/leshouying/entity/{LsyMerchant,LsyOrder,LsyRefund,LsyNotifyLog}Entity.java` - Create: `java/src/main/java/com/payment/platform/module/payment/leshouying/mapper/{LsyMerchant,LsyOrder,LsyRefund,LsyNotifyLog}Mapper.java` - Test: `java/src/test/java/com/payment/platform/module/payment/leshouying/entity/LsyEntityMappingTest.java` **Interfaces:** - Consumes: 无 - Produces: 4 个实体类 + 4 个 Mapper 接口。字段名见下方 SQL 与实体定义,**后续任务全部依赖这些 getter/setter 名**。 - [ ] **Step 1: 写迁移脚本** 创建 `V1.8__create_pay_lsy_tables.sql`: ```sql -- 乐收赢支付通道 —— 建表脚本 -- 【注意】本脚本未经执行,需用户放行后手动执行或由 Flyway 自动迁移。 -- 版本号说明:当前最新已应用为 V1.7;HAR 模块的 V1.8 在 stash 中未执行, -- 其恢复时需改名为 V1.9,否则会因 outOfOrder=false 报错。 -- 企业 ↔ 乐收赢商户映射 CREATE TABLE IF NOT EXISTS pay_lsy_merchant ( id BIGINT PRIMARY KEY, tenant_id BIGINT NOT NULL, enterprise_id VARCHAR(64) NOT NULL, mch_no VARCHAR(32) NOT NULL, shop_no VARCHAR(32), merchant_name VARCHAR(128), review_status VARCHAR(32) NOT NULL DEFAULT 'APPROVED', status VARCHAR(32) NOT NULL DEFAULT 'ENABLED', description VARCHAR(255), created_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), updated_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() ); CREATE UNIQUE INDEX IF NOT EXISTS uk_lsy_merchant_tenant_ent ON pay_lsy_merchant (tenant_id, enterprise_id); CREATE INDEX IF NOT EXISTS idx_lsy_merchant_mch_no ON pay_lsy_merchant (mch_no); COMMENT ON TABLE pay_lsy_merchant IS '乐收赢商户映射(A阶段手工维护,B阶段进件自动写入)'; -- 支付订单 CREATE TABLE IF NOT EXISTS pay_lsy_order ( id BIGINT PRIMARY KEY, tenant_id BIGINT NOT NULL, enterprise_id VARCHAR(64), mch_no VARCHAR(32) NOT NULL, shop_no VARCHAR(32), out_trade_no VARCHAR(64) NOT NULL, api_key_id BIGINT, notify_url VARCHAR(512), attach VARCHAR(128), order_body VARCHAR(128), pay_scene VARCHAR(16) NOT NULL, pay_type VARCHAR(8), total_amount BIGINT NOT NULL DEFAULT 0, receipt_amount BIGINT, discount_amount BIGINT, refunded_amount BIGINT NOT NULL DEFAULT 0, pay_trace_no VARCHAR(64) NOT NULL, pay_time VARCHAR(14), trade_no VARCHAR(64), td_trade_no VARCHAR(64), qd_trade_no VARCHAR(64), user_id VARCHAR(64), code_url TEXT, code_url_expire_time TIMESTAMP WITH TIME ZONE, status VARCHAR(32) NOT NULL, upstream_result_code VARCHAR(32), upstream_result_msg VARCHAR(255), query_count INT NOT NULL DEFAULT 0, next_query_time TIMESTAMP WITH TIME ZONE, last_query_time TIMESTAMP WITH TIME ZONE, notify_status VARCHAR(16) NOT NULL DEFAULT 'PENDING', notify_count INT NOT NULL DEFAULT 0, last_notify_time TIMESTAMP WITH TIME ZONE, finish_time TIMESTAMP WITH TIME ZONE, description VARCHAR(255), created_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), updated_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() ); CREATE UNIQUE INDEX IF NOT EXISTS uk_lsy_order_out_trade_no ON pay_lsy_order (out_trade_no); CREATE UNIQUE INDEX IF NOT EXISTS uk_lsy_order_pay_trace_no ON pay_lsy_order (pay_trace_no); CREATE INDEX IF NOT EXISTS idx_lsy_order_trade_no ON pay_lsy_order (trade_no); CREATE INDEX IF NOT EXISTS idx_lsy_order_poll ON pay_lsy_order (status, pay_scene, next_query_time); CREATE INDEX IF NOT EXISTS idx_lsy_order_tenant ON pay_lsy_order (tenant_id, created_time DESC); COMMENT ON TABLE pay_lsy_order IS '乐收赢支付订单'; -- 退款单 CREATE TABLE IF NOT EXISTS pay_lsy_refund ( id BIGINT PRIMARY KEY, tenant_id BIGINT NOT NULL, order_id BIGINT NOT NULL, out_trade_no VARCHAR(64) NOT NULL, out_refund_no VARCHAR(64) NOT NULL, refund_trace_no VARCHAR(64) NOT NULL, refund_no VARCHAR(64), qd_refund_no VARCHAR(64), refund_fee BIGINT NOT NULL DEFAULT 0, refund_settle BIGINT, status VARCHAR(32) NOT NULL, upstream_result_code VARCHAR(32), upstream_result_msg VARCHAR(255), -- 注意:上游退款接口里 pay_time 是"退款请求时间",原支付时间是 pay_trace_time refund_request_time VARCHAR(14), original_pay_time VARCHAR(14), notify_status VARCHAR(16) NOT NULL DEFAULT 'PENDING', notify_count INT NOT NULL DEFAULT 0, last_notify_time TIMESTAMP WITH TIME ZONE, finish_time TIMESTAMP WITH TIME ZONE, description VARCHAR(255), created_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), updated_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() ); CREATE UNIQUE INDEX IF NOT EXISTS uk_lsy_refund_out_refund_no ON pay_lsy_refund (out_refund_no); CREATE UNIQUE INDEX IF NOT EXISTS uk_lsy_refund_trace_no ON pay_lsy_refund (refund_trace_no); CREATE INDEX IF NOT EXISTS idx_lsy_refund_order ON pay_lsy_refund (order_id); -- ★ 部分唯一索引:同一订单最多只能有一笔"在途"退款(非终态)。 -- 这是防重复退款的**结构性**防线,不是尽力而为的应用层检查。 -- 为什么必须有:可退余额 remaining = total_amount - refunded_amount,而 refunded_amount -- 只在退款**成功后**才累加。所以在途期间 remaining 是虚高的 —— 一笔 100 元的单, -- 第一笔 100 元退款还在 REFUNDING 时,第二笔 100 元退款照样能通过应用层校验。 -- 上游真扣两次,我们这边记一次。 -- PostgreSQL 部分唯一索引让第二笔 insert 直接违反约束,把并发窗口一并封死: -- 应用层的"先查后插"是读-判-写,两个并发请求可以同时通过;索引不会。 -- 前提:pay_lsy_refund 是**状态机表**,没有逻辑删除列(本模块 DDL 全局无 deleted 列), -- 所以不存在"已软删的在途行永久占位"的问题。 CREATE UNIQUE INDEX IF NOT EXISTS uk_lsy_refund_one_pending ON pay_lsy_refund (order_id) WHERE status IN ('CREATED', 'REFUNDING'); COMMENT ON TABLE pay_lsy_refund IS '乐收赢退款单'; -- 回调审计(追加写,无唯一约束;幂等由状态机条件更新保证) CREATE TABLE IF NOT EXISTS pay_lsy_notify_log ( id BIGINT PRIMARY KEY, tenant_id BIGINT, trade_type VARCHAR(2), trade_no VARCHAR(64), pay_trace_no VARCHAR(64), raw_body TEXT, verify_status VARCHAR(16) NOT NULL, process_status VARCHAR(16), error_msg VARCHAR(512), -- ★ 本表**不用** status(审计行的状态由 verify_status + process_status 表达), -- 但这一列**必须存在**:LsyNotifyLogEntity 继承了 PaymentBaseEntity 的 status, -- 而 MyBatis-Plus 按**实体**生成列清单 —— 缺这一列会让本表**任何读操作**抛 -- PostgreSQL 42703 `column "status" does not exist`。 -- 写操作会「碰巧能过」(MP 默认 NOT_NULL 策略把 null 字段从 INSERT 列清单里剔掉), -- 于是缺陷潜伏到第一次读才爆 —— 而下面专为读建了 idx_lsy_notify_trade_no。 -- 兄弟审计表 pay_alipay_notify_log 有 status;迁移里建的、有基类实体的表,本表是唯一缺它的。 -- 【注意】必须在本脚本**首次执行前**补上:CREATE TABLE IF NOT EXISTS -- 会让执行之后对文件的任何修改静默失效(表已存在则整条语句跳过,不报错)。 status VARCHAR(32), description VARCHAR(255), created_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), updated_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_lsy_notify_trade_no ON pay_lsy_notify_log (trade_no, trade_type); COMMENT ON TABLE pay_lsy_notify_log IS '乐收赢回调原始报文审计(追加写)'; ``` - [ ] **Step 2: 写枚举** `LsyPayScene.java`: ```java package com.payment.platform.module.payment.leshouying.enums; /** 支付场景。决定下单走向、关单方式、是否同步轮询。 */ public enum LsyPayScene { /** 付款码支付(B扫C)。上游 v3/pay。会返回 PAY_IN_PROCESS,需同步轮询。关单用 cancelorder。 */ BARCODE, /** 聚合码支付(C扫B)。上游 v3/qrpay。不会返回 PAY_IN_PROCESS,不同步轮询。关单用 qrclose+code_url。 */ QRCODE, /** H5 支付。上游 v3/h5pay。会返回 PAY_IN_PROCESS,但用户被跳转,不同步轮询。无已知关单接口。 */ H5 } ``` `LsyPayType.java`: ```java package com.payment.platform.module.payment.leshouying.enums; import cn.hutool.core.util.StrUtil; /** 支付通道类型。 */ public enum LsyPayType { /** 自动识别。仅付款码支付支持。 */ AUTO("000"), ALIPAY("300"), WECHAT("400"); private final String code; LsyPayType(String code) { this.code = code; } public String getCode() { return code; } public static LsyPayType of(String code) { if (StrUtil.isBlank(code)) return AUTO; for (LsyPayType t : values()) { if (t.code.equals(code)) return t; } throw new IllegalArgumentException("不支持的支付类型: " + code); } /** H5 支付必须指定通道,不接受 AUTO */ public static LsyPayType ofRequired(String code) { LsyPayType t = of(code); if (t == AUTO) { throw new IllegalArgumentException("该场景必须指定支付类型(300 支付宝 / 400 微信)"); } return t; } } ``` `LsyOrderStatus.java`: ```java package com.payment.platform.module.payment.leshouying.enums; import java.util.Set; /** * 订单状态。 * * 条件更新的放行集合分成两组且互不重叠(spec §5.2): * - 支付回写:CREATED / PAYING → 支付结果 * - 退款回写:SUCCESS / CANCELED / CLOSED / PARTIAL_REFUNDED → 退款结果 * 共用一个集合会让「撤销后到达的退款回调」丢数据。 */ public enum LsyOrderStatus { CREATED, PAYING, SUCCESS, FAIL, CANCELED, CLOSED, EXPIRED, PARTIAL_REFUNDED, REFUNDED; /** 允许被支付结果回写的来源状态 */ public static final Set PAY_WRITE_FROM = Set.of("CREATED", "PAYING"); /** 允许被退款结果回写的来源状态 */ public static final Set REFUND_WRITE_FROM = Set.of("SUCCESS", "CANCELED", "CLOSED", "PARTIAL_REFUNDED"); /** * 非终态订单 = 可能还欠一个结果的订单。 * **使用者是 `LsyReconcileScheduler.expireStaleOrders` 的绝对过期兜底**(超出 absoluteExpireHours 仍未终态 → EXPIRED)。 * * ★ 注意它**不是** `reconcilePendingOrders` 的扫描集合 —— 那个查询过滤的是 `status = 'PAYING'` * (理由:只有已经送上游的单才谈得上「问上游要结果」)。 * 这个区别很要紧:写下单路径的未知码分支时,若以为「留在 CREATED 会被对账轮询」就错了 —— * 收尾它的是**回调**(PAY_WRITE_FROM 放行 CREATED)与**这张表的绝对过期**,不是对账轮询。 */ public static final Set PENDING = Set.of("CREATED", "PAYING"); /** * 「我们已经认过这笔成功收款」的状态。 * * 它与 {@link #PENDING} 合起来回答「回调的 0 行更新是什么意思」—— * 两者把全部状态**划分**成三块: * - ∈ {@code PENDING} → 这笔款还能被写成功(正常在途); * - ∈ {@code PAY_RECORDED} → 款已记过,重复的成功回调只是重推(真·幂等); * - 两者都不在 → **终结了但从未认过成功**:FAIL / EXPIRED / CANCELED / CLOSED * ⇒ 收到 PAY_SUCCESS 就是**账实不符**,必须人工核对。 * * ★ 为什么是「PENDING 的补集再去掉本集合」,而不是**列举**那四个异常态: * 新增一个终态而忘了归类时,补集写法会**多报一次告警**(响的), * 列举写法会在同样情形下**静默漏掉**(不响的)。 * 资金差异这件事上,多报一次可接受,漏一次不可接受 —— **故障方向选响的那一侧**。 * * ★ 它**不是** {@link #REFUND_WRITE_FROM} 的别名:那两个集合语义不同。 * `REFUND_WRITE_FROM` 回答「**能不能**收退款结果」(含 CANCELED / CLOSED —— 撤销与关单也要能退款), * 本集合回答「**款有没有记过**」。取值不同、将来也会各自增删,**不要合并**。 */ public static final Set PAY_RECORDED = Set.of("SUCCESS", "PARTIAL_REFUNDED", "REFUNDED"); } ``` `LsyRefundStatus.java`: ```java package com.payment.platform.module.payment.leshouying.enums; import java.util.Set; public enum LsyRefundStatus { CREATED, REFUNDING, SUCCESS, FAIL; /** 退款结果可回写的起始状态:本行只有从这里出发才能被写一次终态。 */ public static final Set PAY_WRITE_FROM = Set.of("CREATED", "REFUNDING"); /** * 在途退款(非终态)。 * * **使用者有两个**,改这个集合前先看这两处: * 1. `pay_lsy_refund` 上的部分唯一索引 `uk_lsy_refund_one_pending` * (`WHERE status IN ('CREATED','REFUNDING')`)—— SQL 里是**字面量**, * 改这里必须同步改 V1.8 迁移脚本的索引谓词,否则应用层与数据库的"在途"定义会分叉; * 2. `LsyRefundService.createRefund` 的在途前置校验(防重复退款)。 * * ★ 这两份副本(本常量 与 V1.8 索引谓词)由 * `LsyEntityMappingTest.refundPendingSetMatchesPartialUniqueIndexPredicate` 钉住, * 分叉即测试失败。这是真钱防线,不是文档约定。 * * ★ 它和 `PAY_WRITE_FROM` 目前**取值相同但语义不同**,不要因此合并成一个常量: * 前者问"这笔退款还在进行中吗",后者问"现在还能不能写终态"。 * 例如将来若允许从 FAIL 重试,PAY_WRITE_FROM 要加 FAIL 而 PENDING 不该加。 * * ★ 当前**没有**退款侧的兜底对账任务(LsyReconcileScheduler 只扫订单表), * 所以一笔回调丢失的退款会永久停在 REFUNDING。见待确认清单。 */ public static final Set PENDING = Set.of("CREATED", "REFUNDING"); } ``` `LsyNotifyStatus.java`: ```java package com.payment.platform.module.payment.leshouying.enums; /** 下游回调推送状态 */ public enum LsyNotifyStatus { PENDING, SUCCESS, FAIL } ``` - [ ] **Step 3: 写 4 个实体** `LsyMerchantEntity.java`: ```java package com.payment.platform.module.payment.leshouying.entity; import com.baomidou.mybatisplus.annotation.TableName; import com.payment.platform.common.base.PaymentTenantBaseEntity; import lombok.Data; import lombok.EqualsAndHashCode; /** 企业 ↔ 乐收赢商户映射。A 阶段管理端手工维护,B 阶段进件成功后自动写入。 */ @Data @EqualsAndHashCode(callSuper = true) @TableName("pay_lsy_merchant") public class LsyMerchantEntity extends PaymentTenantBaseEntity { private String enterpriseId; private String mchNo; private String shopNo; private String merchantName; private String reviewStatus; // id / tenantId / status / description / createdTime / updatedTime 由基类提供 } ``` `LsyOrderEntity.java`: ```java package com.payment.platform.module.payment.leshouying.entity; import com.baomidou.mybatisplus.annotation.TableName; import com.payment.platform.common.base.PaymentTenantBaseEntity; import lombok.Data; import lombok.EqualsAndHashCode; import java.time.OffsetDateTime; /** * 乐收赢支付订单。 * 付款码(auth_code)刻意不落库 —— 一次性支付凭证等同卡号,存了也不能复用,对账靠 trade_no/pay_trace_no。 */ @Data @EqualsAndHashCode(callSuper = true) @TableName("pay_lsy_order") public class LsyOrderEntity extends PaymentTenantBaseEntity { private String enterpriseId; private String mchNo; private String shopNo; private String outTradeNo; private Long apiKeyId; private String notifyUrl; private String attach; private String orderBody; private String payScene; private String payType; private Long totalAmount; private Long receiptAmount; private Long discountAmount; private Long refundedAmount; private String payTraceNo; private String payTime; private String tradeNo; private String tdTradeNo; private String qdTradeNo; private String userId; private String codeUrl; private OffsetDateTime codeUrlExpireTime; private String upstreamResultCode; private String upstreamResultMsg; private Integer queryCount; private OffsetDateTime nextQueryTime; private OffsetDateTime lastQueryTime; private String notifyStatus; private Integer notifyCount; private OffsetDateTime lastNotifyTime; private OffsetDateTime finishTime; } ``` `LsyRefundEntity.java`: ```java package com.payment.platform.module.payment.leshouying.entity; import com.baomidou.mybatisplus.annotation.TableName; import com.payment.platform.common.base.PaymentTenantBaseEntity; import lombok.Data; import lombok.EqualsAndHashCode; import java.time.OffsetDateTime; /** * 乐收赢退款单。 * 注意上游命名陷阱:退款接口的 pay_time 是"退款请求时间", * 原支付时间是 pay_trace_time。此处落库为 refundRequestTime / originalPayTime,避免搞反。 */ @Data @EqualsAndHashCode(callSuper = true) @TableName("pay_lsy_refund") public class LsyRefundEntity extends PaymentTenantBaseEntity { private Long orderId; private String outTradeNo; private String outRefundNo; private String refundTraceNo; private String refundNo; private String qdRefundNo; private Long refundFee; private Long refundSettle; private String upstreamResultCode; private String upstreamResultMsg; private String refundRequestTime; private String originalPayTime; private String notifyStatus; private Integer notifyCount; private OffsetDateTime lastNotifyTime; private OffsetDateTime finishTime; } ``` `LsyNotifyLogEntity.java`: ```java package com.payment.platform.module.payment.leshouying.entity; import com.baomidou.mybatisplus.annotation.TableName; import com.payment.platform.common.base.PaymentTenantBaseEntity; import lombok.Data; import lombok.EqualsAndHashCode; /** 乐收赢回调原始报文审计。追加写,无唯一约束。 */ @Data @EqualsAndHashCode(callSuper = true) @TableName("pay_lsy_notify_log") public class LsyNotifyLogEntity extends PaymentTenantBaseEntity { private String tradeType; private String tradeNo; private String payTraceNo; private String rawBody; private String verifyStatus; private String processStatus; private String errorMsg; } ``` - [ ] **Step 4: 写 4 个 Mapper** 四个文件形状相同,但**逐个写全**(不要写「同理创建」—— 四份实体类型名各自不同, 而实现者可能只读到自己那一段,让它去推断映射关系就是让它猜): **文件 1/4** `mapper/LsyOrderMapper.java`: ```java package com.payment.platform.module.payment.leshouying.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity; import org.apache.ibatis.annotations.Mapper; @Mapper public interface LsyOrderMapper extends BaseMapper { } ``` **文件 2/4** `mapper/LsyMerchantMapper.java`: ```java package com.payment.platform.module.payment.leshouying.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.payment.platform.module.payment.leshouying.entity.LsyMerchantEntity; import org.apache.ibatis.annotations.Mapper; @Mapper public interface LsyMerchantMapper extends BaseMapper { } ``` **文件 3/4** `mapper/LsyRefundMapper.java`: ```java package com.payment.platform.module.payment.leshouying.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.payment.platform.module.payment.leshouying.entity.LsyRefundEntity; import org.apache.ibatis.annotations.Mapper; @Mapper public interface LsyRefundMapper extends BaseMapper { } ``` **文件 4/4** `mapper/LsyNotifyLogMapper.java`: ```java package com.payment.platform.module.payment.leshouying.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.payment.platform.module.payment.leshouying.entity.LsyNotifyLogEntity; import org.apache.ibatis.annotations.Mapper; @Mapper public interface LsyNotifyLogMapper extends BaseMapper { } ``` > **关于「4 张表要不要注册进 `TenantInnerInterceptor.CONDITIONAL_TABLES`」——要,但不在本任务做。** > 它归 **Task 14 Step 5**(「三个共享文件必改点」之一),且带一条会红的钉子 > `LeshouyingWiringTest.conditionalTables_containAllLeshouyingTables`。 > **本任务只建表,不改任何既有文件。** > 原因:改动与它的钉子测试必须同处一个任务,否则「改了没测」或「测了没改」两头都看不出来; > 而且这个失效只在**运行时**显现,Task 14 早于任何一次真实调用。 > (评审若再提这一条,请看计划顶部预检表 `core/tenant/TenantInnerInterceptor.java:53-76` 一行 —— 已登记。) - [ ] **Step 5: 写实体映射测试(防止注解写错)** ```java package com.payment.platform.module.payment.leshouying.entity; import com.baomidou.mybatisplus.annotation.TableName; import com.payment.platform.module.payment.leshouying.enums.LsyRefundStatus; import org.junit.jupiter.api.Test; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.util.LinkedHashSet; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; import static org.junit.jupiter.api.Assertions.*; class LsyEntityMappingTest { private static final String MIGRATION = "db/migration/V1.8__create_pay_lsy_tables.sql"; /** * **故意**不被实体映射的列(`表名.列名`)。**目前为空,且希望一直为空。** * 留这个集合是为了让「不映射某一列」必须是一次**被写下来**的决定, * 而不是一个没人注意到的默认状态。 */ private static final Set UNMAPPED_COLUMNS = Set.of(); @Test void allEntitiesDeclareExpectedTableName() { assertEquals("pay_lsy_merchant", LsyMerchantEntity.class.getAnnotation(TableName.class).value()); assertEquals("pay_lsy_order", LsyOrderEntity.class.getAnnotation(TableName.class).value()); assertEquals("pay_lsy_refund", LsyRefundEntity.class.getAnnotation(TableName.class).value()); assertEquals("pay_lsy_notify_log", LsyNotifyLogEntity.class.getAnnotation(TableName.class).value()); } @Test void orderEntity_hasNoAuthCodeField() { // 安全约束:付款码不落库 for (var f : LsyOrderEntity.class.getDeclaredFields()) { assertNotEquals("authCode", f.getName(), "付款码不得落库"); } } @Test void entitiesInheritAuditFieldsFromBase() throws Exception { var base = Class.forName("com.payment.platform.common.base.PaymentTenantBaseEntity"); assertTrue(base.isAssignableFrom(LsyOrderEntity.class)); assertTrue(base.isAssignableFrom(LsyRefundEntity.class)); assertTrue(base.isAssignableFrom(LsyMerchantEntity.class)); assertTrue(base.isAssignableFrom(LsyNotifyLogEntity.class)); } /** * 实体字段 ⊆ 建表列 —— 这条是 C-1 那类缺陷的**机械**防线。 * * 为什么必须有:MyBatis-Plus 按**实体**生成 SQL 列清单,不是按表。所以 * 「实体上有的字段、表里没有这一列」既不会编译报错,也不会在**写**的时候报错 * (MP 默认 NOT_NULL 策略把 null 字段从 INSERT 列清单里剔掉), * 只会在**读**的时候抛 PostgreSQL 42703 —— 而读往往发生在很久以后。 * * `pay_lsy_notify_log` 就真的漏了基类带过来的 `status`,而 V1.8 偏偏给它建了 * idx_lsy_notify_trade_no,说明读是按设计要发生的。这条测试让这种分叉当场变红, * 而不是等上线后第一次查审计日志才炸。 * * 注意它与上面 `orderEntity_hasNoAuthCodeField` **互补、不可互替**: * 那条防的是「authCode 出现在实体里」,这条防的是「实体与 DDL 不同步」—— * 若有人把 authCode 同时加进实体和 DDL,那条会红、这条不会; * 若有人漏了某一列,这条会红、那条不会。 */ @Test void everyEntityFieldHasAColumnInTheMigration() throws Exception { String sql = readMigration(MIGRATION).replaceAll("(?m)--[^\\n]*", ""); for (var entity : new Class[]{LsyMerchantEntity.class, LsyOrderEntity.class, LsyRefundEntity.class, LsyNotifyLogEntity.class}) { String table = entity.getAnnotation(TableName.class).value(); Set columns = tableColumns(sql, table); Set mapped = new LinkedHashSet<>(); for (Class c = entity; c != null && c != Object.class; c = c.getSuperclass()) { for (var f : c.getDeclaredFields()) { if (f.isSynthetic() || java.lang.reflect.Modifier.isStatic(f.getModifiers())) { continue; } mapped.add(camelToSnake(f.getName())); } } // 方向一:实体字段 ⊆ 建表列 —— C-1 漏的正是这个方向(实体有 status、表里没有)。 for (String column : mapped) { assertTrue(columns.contains(column), table + " 缺列 " + column + "。MP 按实体生成列清单:" + "缺列会让**读**抛 42703,而写入因 NOT_NULL 策略跳过 null 字段而看似正常。"); } // 方向二:建表列 ⊆ 实体字段 —— **反方向**的分叉,典型样本是「表里多出一列 auth_code」。 // 这一半是必需的、不能被方向一替代:那种情况下实体侧完全干净( // orderEntity_hasNoAuthCodeField 绿),且 entity ⊆ DDL 照样成立(方向一也绿), // 只有这一半拦得住。 for (String column : columns) { assertTrue(mapped.contains(column) || UNMAPPED_COLUMNS.contains(table + "." + column), table + " 多出实体未映射的列 " + column + "。" + "若确实要故意不映射它,请加进 UNMAPPED_COLUMNS 并写明理由 —— " + "否则它可能就是绕过实体校验写进库的字段(auth_code 正是这个形态)。"); } } } /** 抽出一张表的建表语句(到第一个分号为止)。 */ private static String tableDdl(String sql, String table) { int at = sql.indexOf("CREATE TABLE IF NOT EXISTS " + table + " ("); assertTrue(at >= 0, "V1.8 里找不到建表语句: " + table); int end = sql.indexOf(';', at); assertTrue(end > at, table + " 的建表语句应以分号结束"); return sql.substring(at, end); } /** 建表语句里的列名集合(每行第一个标识符;跳过表级约束)。 */ private static Set tableColumns(String sql, String table) { String ddl = tableDdl(sql, table); int open = ddl.indexOf('('); int close = ddl.lastIndexOf(')'); assertTrue(open > 0 && close > open, "建表语句结构异常: " + table); Set cols = new LinkedHashSet<>(); for (String line : ddl.substring(open + 1, close).split(",")) { String t = line.trim(); if (t.isEmpty()) { continue; } String head = t.split("\\s+")[0]; if (head.equalsIgnoreCase("PRIMARY") || head.equalsIgnoreCase("CONSTRAINT") || head.equalsIgnoreCase("UNIQUE") || head.equalsIgnoreCase("FOREIGN") || head.equalsIgnoreCase("CHECK")) { continue; } cols.add(head); } return cols; } /** enterpriseId → enterprise_id */ private static String camelToSnake(String s) { StringBuilder b = new StringBuilder(); for (char ch : s.toCharArray()) { if (Character.isUpperCase(ch)) { b.append('_').append(Character.toLowerCase(ch)); } else { b.append(ch); } } return b.toString(); } /** * 钉住「在途退款」这个事实的两份副本: * Java 侧 {@link LsyRefundStatus#PENDING} 与 SQL 侧部分唯一索引 uk_lsy_refund_one_pending 的谓词。 * * 分叉的后果是真钱缺陷:应用层认「在途」而数据库不认(或反之), * 第二笔退款要么被应用层放过、要么绕过索引直奔上游扣款。 * 所以这里不写「文档约定」,直接让分叉变成红灯。 */ @Test void refundPendingSetMatchesPartialUniqueIndexPredicate() throws Exception { String indexName = "uk_lsy_refund_one_pending"; // 去掉 SQL 行注释,避免注释里出现的字面量污染下面的抽取 String sql = readMigration(MIGRATION).replaceAll("(?m)--[^\\n]*", ""); int at = sql.indexOf(indexName); assertTrue(at >= 0, "V1.8 必须存在部分唯一索引 " + indexName + " —— 它是防重复退款的结构性防线,不是可选项"); int end = sql.indexOf(';', at); assertTrue(end > at, indexName + " 的定义应以分号结束"); String ddl = sql.substring(at, end); assertTrue(ddl.contains("WHERE"), indexName + " 必须是**部分**唯一索引(带 WHERE 谓词);" + "退化成全表唯一索引会变成「一个订单只能有一笔退款」,把部分退款一起禁掉。实际定义:" + ddl); Set sqlLiterals = new LinkedHashSet<>(); Matcher m = Pattern.compile("'([^']*)'").matcher(ddl); while (m.find()) { sqlLiterals.add(m.group(1)); } assertEquals(LsyRefundStatus.PENDING, sqlLiterals, "V1.8 的 " + indexName + " 谓词与 LsyRefundStatus.PENDING 分叉了。" + "二者是同一个事实的两份副本,改任一处必须同步改另一处。"); } private static String readMigration(String path) throws Exception { try (InputStream in = LsyEntityMappingTest.class.getClassLoader().getResourceAsStream(path)) { assertNotNull(in, "找不到迁移脚本 " + path + "(应位于 src/main/resources 下)"); return new String(in.readAllBytes(), StandardCharsets.UTF_8); } } } ``` **上面第 4 个测试(`refundPendingSetMatchesPartialUniqueIndexPredicate`)是本计划早先漏写的, 补写进来是为了让「计划文本 = 交付物」保持逐字一致。** 它守的是一条**真钱**不变量: 「在途退款」这个事实在两个地方各有一份副本 —— Java 的 `LsyRefundStatus.PENDING` 和 V1.8 里部分唯一索引 `uk_lsy_refund_one_pending` 的 `WHERE` 谓词。 两份副本分叉时不会有任何编译错误,只会在生产里变成重复退款,所以让分叉直接红灯。 (它读 SQL 文本、抽索引定义里的字面量集合,与 `LsyRefundStatus.PENDING` 全等比对。) - [ ] **Step 6: 运行测试确认全绿** ```bash cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn test -Dtest=LsyEntityMappingTest ``` Expected: `Tests run: 5, Failures: 0, Errors: 0` > **这里故意没有 `-q`。** `-q` 会把 surefire 的 `Tests run:` 计数行一起吞掉(只剩 exit code 0), > 于是本步骤自己写的 `Expected:` **无法被观察** —— 一个要求你核对数字的步骤, > 却用了看不见数字的命令。凡本计划中写了 `Expected: Tests run: N` 的步骤,命令都不要加 `-q`。 **5 条的有效性已做变异验证**(实测,非推断): - 把 V1.8 里索引谓词的 `'REFUNDING'` 改成 `'REFUNDINGX'` 后重跑 → `Tests run: 5, Failures: 1`, **只有** `refundPendingSetMatchesPartialUniqueIndexPredicate` 变红,另外 4 条保持绿 (说明钉子是精确定位、不是碰巧全红)。变异后已还原,文件 sha256 与变异前逐位一致。 - 第 5 条 `everyEntityFieldHasAColumnInTheMigration` 是 **C-1 的机械防线**。它有**两个方向**, 两个方向**都要做变异验证** —— 否则等于有一条断言从来没被执行过(写了不等于守住了): - **方向一(实体字段 ⊆ 建表列)**:**在补上 V1.8 的 `status` 列之前它必红** —— `pay_lsy_notify_log` 实体侧 13 个字段、建表侧 12 列,缺的正是基类带来的 `status`; 补上之后四张表字段/列数是 **11/11、37/37、22/22、13/13** 全等。 - **方向二(建表列 ⊆ 实体字段)**:临时往任一张表加一列 `auth_code VARCHAR(64)` → 必红;删掉即恢复绿。 两个方向都**先红后绿、可复现**,所以这条不是「永远绿的断言」。 - 三条护栏的分工是**互补、不可互替**的(将来有人想删其中一条,请先回来看这段): - `orderEntity_hasNoAuthCodeField` —— authCode 出现在**实体**里(只看 entity 侧); - 方向一 —— 实体有、**表里缺**(C-1 的形态); - 方向二 —— **表里有、实体没有**(`auth_code` 溜进 DDL 的形态)—— ★ 这种形态下**前两条都是绿的**,只有方向二拦得住。 - [ ] **Step 7: 确认工作区状态(不提交)** ```bash git status --short ``` **本任务应当只新增文件,不修改任何既有文件。** 若 `git diff --cached` 非空,说明有人 `git add` 了 —— 立即回退。 **SQL 文件到此为止 —— 不要对任何数据库执行。** --- ## Task 4: LeshouyingProperties + LeshouyingClient **Files:** - Create: `java/src/main/java/com/payment/platform/module/payment/leshouying/config/LeshouyingProperties.java` - Create: `java/src/main/java/com/payment/platform/module/payment/leshouying/client/LeshouyingClient.java` - Modify: `java/src/main/resources/application.yml`(新增 `leshouying:` 段) - Test: `java/src/test/java/com/payment/platform/module/payment/leshouying/client/LeshouyingClientTest.java` **Interfaces:** - Consumes: - `LeshouyingSigner.sign(Map,String)`, `LeshouyingResponseParser.parseV3(String)` - **`LeshouyingSensitive.maskByKey(String)`(Task 2)** —— 出站 trace 日志脱敏。**用 `maskByKey` 不要用 `mask`**(见 Task 2 Produces 的判据)。脱敏逻辑已在 Task 2 单测覆盖,本任务**不要**为它再写日志捕获测试(脆弱且重复)。 - Produces: - `Map LeshouyingClient.post(String path, Map bizParams)` —— 自动补 `inst_no`、`sign`,POST JSON,返回解析后的 map(通信失败抛 `LeshouyingApiException`) - `LeshouyingProperties.getInstNo() / getKey() / getBaseUrl() / getSyncPollTimes() / getSyncPollIntervalMs() / getBarcodePollMaxTimes() / getH5ExpireMinutes() / getDefaultQrExpireMinutes()` - [ ] **Step 1: 写配置类** ```java package com.payment.platform.module.payment.leshouying.config; import cn.hutool.core.util.StrUtil; import jakarta.annotation.PostConstruct; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; import java.util.ArrayList; import java.util.List; /** * 乐收赢通道配置。 * inst_no 与机构密钥是平台级的(我们是一个机构,各企业在其下开商户), * 不入库,避免机构密钥明文落库。 */ @Data @Component @ConfigurationProperties(prefix = "leshouying") public class LeshouyingProperties { /** 接口域名,测试 https://test.gdyfsk.com/yfpay,正式 https://open.gdyfsk.com/yfpay */ private String baseUrl = "https://test.gdyfsk.com/yfpay"; /** 机构号 */ private String instNo; /** 机构密钥 */ private String key; /** 后端回调地址(我们暴露给乐收赢的入口) */ private String notifyUrl; /** 被扫同步轮询次数(用户定:3 次) */ private int syncPollTimes = 3; /** 被扫同步轮询间隔毫秒(用户定:2 秒) */ private long syncPollIntervalMs = 2000L; /** 被扫异步轮询最大次数(上游文档:15 次 × 5 秒 ≈ 80 秒) */ private int barcodePollMaxTimes = 15; /** 被扫异步轮询间隔毫秒 */ private long barcodePollIntervalMs = 5000L; /** 主扫聚合码默认有效期(分钟)。上游默认 3 天,我们收短以便本地状态收敛 */ private int defaultQrExpireMinutes = 15; /** H5 订单未支付多久后本地置 EXPIRED(上游无已知关单接口) */ private int h5ExpireMinutes = 30; /** 兜底对账:非终态订单创建多久后开始兜底轮询(秒) */ private int reconcileDelaySeconds = 60; /** 任意非终态订单超过多久统一置 EXPIRED(小时) */ private int absoluteExpireHours = 24; /** 下游回调最大重试次数 */ private int downstreamNotifyMaxRetries = 8; /** * 启动即校验,缺配置就大声失败。 * * 【为什么必须在这里炸,而不是等第一笔真实支付】签名器 {@code LeshouyingSigner} 是纯函数, * 对 {@code key == null} 它**不报错**,而是照常拼出 {@code ...&key=null} 再取 MD5 —— * 也就是签出一个「确定可预测」的值(谁都知道 key 字面量是 null),而失败会推迟到上游回 * 「签名错误」那一刻。那时候人已经在排查一笔真实支付,而不是在看启动日志。 * 校验放这里而不是放进签名器:签名器必须保持无状态纯函数(两条路径都走它,见其类注释)。 * * 【notifyUrl 为什么也在必填里】它为空时 {@code post()} 会把这个 null 值**剔掉不上送**, * 上游于是用它的默认回调地址 —— 我们的回调**永远不会到达**,而且**没有任何报错**: * 订单停在非终态、只能靠 Task 16 的兜底轮询把状态捞回来。这是典型的静默失败, * 必须在启动时挡住。三个旋钮(轮询次数/间隔/过期时间)不校验:它们有合理默认值, * 键名打错也只是退回默认值,不会造成状态不收敛。 * * 一次性列出**所有**缺失项:逐个抛会让人改一个重启一次,进件当天每轮重启都是分钟级。 */ @PostConstruct void validate() { List missing = new ArrayList<>(); if (StrUtil.isBlank(instNo)) { missing.add("leshouying.inst-no(环境变量 LESHOUYING_INST_NO)"); } if (StrUtil.isBlank(key)) { missing.add("leshouying.key(环境变量 LESHOUYING_KEY)"); } if (StrUtil.isBlank(baseUrl)) { missing.add("leshouying.base-url"); } if (StrUtil.isBlank(notifyUrl)) { missing.add("leshouying.notify-url(环境变量 LESHOUYING_NOTIFY_URL)"); } if (!missing.isEmpty()) { throw new IllegalStateException( "乐收赢通道配置缺失,启动中止: " + String.join("; ", missing)); } } } ``` - [ ] **Step 2: 在 application.yml 追加配置** 在 `java/src/main/resources/application.yml` 末尾追加。 **机构号与机构密钥都用「环境变量占位 + 测试值兜底」的写法**(形如 `变量名:默认值`),理由可当场核实: - 这个文件**被 git 跟踪**(`git ls-files` 查得到),`key` 后面直接跟明文就等于**把机构密钥提交进仓库**。 同一文件里 `alipay-public-key` / `app-id` / `app-private-key` 都留着空值 `""`, 可见本仓库的既有意图正是不把通道密钥写进这个文件。 - 这种写法两边都不牺牲:开发期不设任何环境变量也**开箱即用**(跑的就是文档给的测试机构), 进件后注入环境变量即可,**不需要有人改这个文件、也就不需要提交正式密钥**。 - 同一个配置块里的 `notify-url` 已经是这个写法 —— 密钥留明文会让这个块自相矛盾。 ```yaml # 乐收赢支付通道 leshouying: base-url: https://test.gdyfsk.com/yfpay # 默认值是**文档给的测试机构**,开发期开箱即用;正式机构号进件后由环境变量注入 inst-no: ${LESHOUYING_INST_NO:900112} # 机构密钥同样用环境变量占位:本文件被 git 跟踪,写死正式密钥 = 把机构密钥提交进仓库 key: ${LESHOUYING_KEY:1c4d83f0c3c24a7d9fcaa1fed2682076} notify-url: ${LESHOUYING_NOTIFY_URL:https://qcsj88888.com/api/v1/payment/leshouying/notify/pay} sync-poll-times: 3 sync-poll-interval-ms: 2000 barcode-poll-max-times: 15 barcode-poll-interval-ms: 5000 default-qr-expire-minutes: 15 h5-expire-minutes: 30 reconcile-delay-seconds: 60 absolute-expire-hours: 24 downstream-notify-max-retries: 8 ``` - [ ] **Step 3: 写失败的测试** ```java package com.payment.platform.module.payment.leshouying.client; import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.client.HttpServerErrorException; import org.springframework.web.client.ResourceAccessException; import org.springframework.web.client.RestTemplate; import java.nio.charset.StandardCharsets; import java.util.Map; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class LeshouyingClientTest { @Mock private RestTemplate restTemplate; private LeshouyingProperties props; private LeshouyingClient client; @BeforeEach void setUp() { props = new LeshouyingProperties(); props.setBaseUrl("https://test.gdyfsk.com/yfpay"); props.setInstNo("900112"); props.setKey("1c4d83f0c3c24a7d9fcaa1fed2682076"); client = new LeshouyingClient(restTemplate, new LeshouyingSigner(), new LeshouyingResponseParser(), props); } @Test void post_injectsInstNoAndSign_automatically() { String body = "{\"return_code\":\"SUCCESS\",\"result_code\":\"PAY_SUCCESS\"}"; when(restTemplate.postForEntity(eq("https://test.gdyfsk.com/yfpay/v3/pay"), any(HttpEntity.class), eq(String.class))) .thenReturn(new ResponseEntity<>(body, HttpStatus.OK)); Map resp = client.post("/v3/pay", Map.of("mch_no", "Y52211090004")); assertEquals("PAY_SUCCESS", resp.get("result_code")); // 验证请求体里确实补了 inst_no 与 sign,且 sign 可被同一密钥验回 var captor = org.mockito.ArgumentCaptor.forClass(HttpEntity.class); org.mockito.Mockito.verify(restTemplate).postForEntity( eq("https://test.gdyfsk.com/yfpay/v3/pay"), captor.capture(), eq(String.class)); @SuppressWarnings("unchecked") Map sent = (Map) captor.getValue().getBody(); assertEquals("900112", sent.get("inst_no")); assertNotNull(sent.get("sign")); assertTrue(new LeshouyingSigner().verify(sent, props.getKey())); } @Test void post_communicationFailure_throws() { when(restTemplate.postForEntity(any(String.class), any(HttpEntity.class), eq(String.class))) .thenReturn(new ResponseEntity<>( "{\"return_code\":\"FAIL\",\"return_msg\":\"参数不合法\"}", HttpStatus.OK)); LeshouyingApiException ex = assertThrows(LeshouyingApiException.class, () -> client.post("/v3/pay", Map.of("mch_no", "X"))); assertEquals("参数不合法", ex.getRawReturnMsg()); } @Test void post_networkException_wrappedAsApiException() { when(restTemplate.postForEntity(any(String.class), any(HttpEntity.class), eq(String.class))) .thenThrow(new ResourceAccessException("connect timed out")); assertThrows(LeshouyingApiException.class, () -> client.post("/v3/pay", Map.of("mch_no", "X"))); } @Test void post_nullValueParams_areStrippedBeforeSending() { when(restTemplate.postForEntity(any(String.class), any(HttpEntity.class), eq(String.class))) .thenReturn(new ResponseEntity<>( "{\"return_code\":\"SUCCESS\",\"result_code\":\"PAY_SUCCESS\"}", HttpStatus.OK)); Map p = new java.util.LinkedHashMap<>(); p.put("mch_no", "Y52211090004"); p.put("shop_no", null); client.post("/v3/pay", p); var captor = org.mockito.ArgumentCaptor.forClass(HttpEntity.class); org.mockito.Mockito.verify(restTemplate).postForEntity( any(String.class), captor.capture(), eq(String.class)); @SuppressWarnings("unchecked") Map sent = (Map) captor.getValue().getBody(); assertFalse(sent.containsKey("shop_no")); } /** * ★ 安全回归:上游 4xx/5xx 的**响应体不得**随异常对象外流。 * * 为什么必须有:RestTemplate 默认的 DefaultResponseErrorHandler 对 4xx/5xx 是**抛**异常 * (`HttpServerErrorException` / `HttpClientErrorException`,都是 `RestClientResponseException` 的子类), * 而不是返回 ResponseEntity;而这些异常的 `getMessage()` **内嵌完整响应体**。 * 上游或 CDN 在错误页里回显我们的请求是常见行为 —— 而我们的请求含 `auth_code`, * 它是能**直接发起支付**的凭据。一旦它进了 message 或 cause,就会经 * GlobalExceptionHandler 的 `log.error("系统异常 [{}]", uri, e)` 落进日志文件。 * * 本用例断言**三处**都不含付款码:`rawReturnMsg`、`getMessage()`、`cause`。 * 第三处是关键:只把 message 脱敏、却仍然 `initCause(e)`,body 会从 "Caused by:" 那行原样漏出去。 * (`cause` 为 null 时 `String.valueOf` 得 "null",同样不含码 —— 两种情况都算过。) */ @Test void post_upstreamErrorBody_neverRidesAlongInException() { String code = "134573607383541004"; String echoBody = "{\"error\":\"bad gateway\",\"request\":{\"auth_code\":\"" + code + "\",\"mch_no\":\"Y52211090004\"}}"; when(restTemplate.postForEntity(any(String.class), any(HttpEntity.class), eq(String.class))) .thenThrow(HttpServerErrorException.create(HttpStatus.BAD_GATEWAY, "Bad Gateway", HttpHeaders.EMPTY, echoBody.getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8)); LeshouyingApiException ex = assertThrows(LeshouyingApiException.class, () -> client.post("/v3/pay", Map.of("mch_no", "Y52211090004", "auth_code", code))); // 4xx/5xx 必须归到 HTTP_xxx,不得退化成 NETWORK_ERROR(否则接住它的那个分支等于不存在) assertEquals("HTTP_502", ex.getRawReturnCode()); assertFalse(String.valueOf(ex.getRawReturnMsg()).contains(code), "rawReturnMsg 泄漏付款码"); assertFalse(String.valueOf(ex.getMessage()).contains(code), "getMessage 泄漏付款码"); assertFalse(String.valueOf(ex.getCause()).contains(code), "cause 链泄漏付款码"); } } ``` - [ ] **Step 4: 运行测试确认失败** ```bash cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LeshouyingClientTest ``` Expected: 编译失败 —— `LeshouyingClient` 不存在。 - [ ] **Step 5: 实现 LeshouyingClient** ```java package com.payment.platform.module.payment.leshouying.client; import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClientResponseException; import org.springframework.web.client.RestTemplate; import java.util.LinkedHashMap; import java.util.Map; /** * 乐收赢上游 HTTP 客户端。 * 职责:补 inst_no → 签名 → POST JSON → 解析两套信封 → 异常包装。 * 业务层失败(PAY_FAIL / PAY_IN_PROCESS)不抛异常,由调用方分支处理 —— 但**必须用 * `isPaySuccess` / `isPayInProcess` / `isPayFail` 这三个谓词**,不得裸比较 `result_code` 字符串。 * * ★ **禁止写 `!isPaySuccess(resp)`。** 上游某端点在 `/v3/pay` 回的是通用成功码 `SUCCESS` * 而非 `PAY_SUCCESS`(属未预期码)时,三个谓词**全为 false** —— 此时 `!isPaySuccess` * 会把一笔**已经收到钱**的支付判成失败落库,后续对账缺口或按失败单重收。 * 未预期码必须走独立的未知码分支:**记日志 + 留在非终态 + 待人工核对**, * 既不写 SUCCESS 也不写 FAIL。 * (三个谓词本身已被 LeshouyingResponseParserTest 钉住;这里钉的是**调用方的用法**。) */ @Slf4j @Component @RequiredArgsConstructor public class LeshouyingClient { private final RestTemplate restTemplate; private final LeshouyingSigner signer; private final LeshouyingResponseParser parser; private final LeshouyingProperties props; /** * 调用 /v3/* 接口。 * @param path 以 / 开头的路径,如 "/v3/pay" * @param bizParams 业务参数(不含 inst_no / sign,本方法自动补) */ public Map post(String path, Map bizParams) { Map payload = new LinkedHashMap<>(); if (bizParams != null) { // null 值不上送(与签名规则一致,避免产生歧义) bizParams.forEach((k, v) -> { if (v != null) { payload.put(k, v); } }); } payload.put("inst_no", props.getInstNo()); payload.put("sign", signer.sign(payload, props.getKey())); // ★ 全模块唯一一处记录「签名原文」的地方,必须留在出站侧。 // 入站路径(LeshouyingSigner.verify → sign)的参数由**攻击者控制**, // 在那里打日志会同时带来两个问题:参数值里塞 CRLF 可伪造日志行(CWE-117), // 以及 auth_code 被原样落盘。所以 LeshouyingSigner 里一个日志都没有。 // 这里虽然参数是我们自己构造的,auth_code 仍要打码 —— // 它是能直接发起支付的凭据,与计划「auth_code 刻意不落库」的决策保持一致。 // // ★ 用 maskByKey 而不是 mask:**判据是内容由谁构造**。本方法的报文由本模块拼, // 键名确定已知,键层足够;值层会连 out_trade_no / pay_trace_no 这些 // **我们自己的对账号**一起遮掉 —— 那等于把这条 trace 日志的唯一用途 // (回答「这次请求到底发了什么参数」)抹掉。 // 也**不要**退回手写 `containsKey("auth_code")`:那是又一次「按键名匹配」, // 而且只在键名恰好拼作 auth_code 时有效,将来出现 authCode / AUTH_CODE 就静默失效。 // 渲染成字符串再过一遍,是为了出站与入站共用**同一份**正则实现,而不是各写一份。 if (log.isTraceEnabled()) { log.trace("乐收赢请求 {} 出站参数(已脱敏): {}", path, LeshouyingSensitive.maskByKey(payload.toString())); } String url = props.getBaseUrl() + path; String json; try { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); HttpEntity> entity = new HttpEntity<>(payload, headers); long start = System.currentTimeMillis(); ResponseEntity resp = restTemplate.postForEntity(url, entity, String.class); long cost = System.currentTimeMillis() - start; log.info("乐收赢请求完成: path={}, status={}, cost={}ms", path, resp.getStatusCode(), cost); if (!resp.getStatusCode().is2xxSuccessful() || resp.getBody() == null) { throw new LeshouyingApiException("HTTP_" + resp.getStatusCode().value(), "乐收赢返回非 2xx 或空响应", null); } json = resp.getBody(); } catch (LeshouyingApiException e) { throw e; } catch (RestClientResponseException e) { // ★ 4xx/5xx **走这里**,不走上面那个 is2xxSuccessful() 分支 —— // RestTemplate 默认的 DefaultResponseErrorHandler 对 400/403/429/502/503 一律 hasError=true, // 于是**抛异常**而不是返回 ResponseEntity(Task 4 实现时实测:400/403/429/502/503 全抛,200/302 不抛)。 // 所以上面那行 "HTTP_" + resp.getStatusCode() 对 4xx/5xx 是**死代码**, // 真正兜住它们的是这里。若不显式接住,真实的 502/429/403 会全部退化成 NETWORK_ERROR。 // // ★★ 响应体可能**回显我们的请求**(上游或 CDN 在错误页里带上原文),而我们的请求含 auth_code —— // 它是能**直接发起支付**的凭据。所以这个异常对象**一个字都不许带着响应体往外走**: // - 不把 body 拼进 message(否则经 GlobalExceptionHandler 的 // `log.error("系统异常 [{}]", uri, e)` 落进栈日志) // - **不 initCause(e)**(否则 body 从 "Caused by:" 那行原样漏出去, // 前面把 message 脱敏就白做了) // 要留证据就只留**已脱敏**的 body,且**只留在这一行日志里**: // 本模块自带判据是「内容由我们构造 → maskByKey;内容来自外部 → mask」, // 这里的内容来自上游 ⇒ 用 mask(值层兜底,能挡住 auth_code 的键名变体)。 // (Task 4 实现时实测:上游 502 回显请求体时,未脱敏的 message 里 // 18 位付款码原样可见 `>>> message 含 18 位付款码: true`。) log.error("乐收赢请求失败: path={}, status={}, body(已脱敏)={}", path, e.getStatusCode().value(), LeshouyingSensitive.mask(e.getResponseBodyAsString())); throw new LeshouyingApiException("HTTP_" + e.getStatusCode().value(), "乐收赢返回 HTTP " + e.getStatusCode().value(), null); } catch (Exception e) { // 走到这里的**没有响应体**(连接超时 / SSL 握手 / DNS 解析失败等), // message 与栈都可以原样留 —— 它们只含本机信息,不含上游回显。 log.error("乐收赢请求异常: path={}, error={}", path, e.getMessage(), e); // 【不要用 (String, Throwable) 构造器 —— 它被 LeshouyingApiException 刻意取消了】 // 该构造器会绕过 sanitize(那层防线只长在 3 参路径上),把调用方消息塞进 rawReturnMsg, // 破坏「三个 Raw 字段 = 逐字上游原文」的契约,并重开 CWE-117 的口子。 // 需要带 cause 时用 3 参构造器 + initCause(与 LeshouyingResponseParser 的 catch 分支一致)。 // initCause 不可省:否则连接超时/SSL 握手的原始异常只活在上一行日志里,调用方程序化拿不到。 LeshouyingApiException ex = new LeshouyingApiException("NETWORK_ERROR", "乐收赢接口调用失败: " + e.getMessage(), null); ex.initCause(e); throw ex; } return parser.parseV3(json); } /** 机构号,供调用方拼装查询流水号等场景使用 */ public String instNo() { return props.getInstNo(); } } ``` - [ ] **Step 6: 运行测试确认全绿** ```bash cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn test -Dtest=LeshouyingClientTest ``` Expected: `Tests run: 5, Failures: 0, Errors: 0` > **命令里没有 `-q`**:`-q` 会把下面这行 `Tests run:` 计数一起吞掉,于是这个 Expected 无法被观察。 > (Step 4 的 `-q` 保留 —— 它的 Expected 是"编译失败",没有计数行要核对。) - [ ] **Step 7: 确认工作区状态(不提交)** ```bash git status --short ``` --- ## Task 5: 订单状态机核心 —— 条件更新 幂等的实现基础。**受影响行数 = 1 才触发下游回调**(spec §5.2)。 **Files:** - Create: `java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyOrderService.java`(本任务只写状态流转部分) - Create: `java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyQueryService.java`(**仅骨架**:持有 `LeshouyingClient` 一个字段 + 构造器,本任务不加任何方法。Task 6 Step 4 再追加 `queryOrder`) - Test: `java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyOrderServiceStateTest.java` **Interfaces:** - Consumes: `LsyOrderEntity`, `LsyOrderMapper`, `LsyOrderStatus.PAY_WRITE_FROM` / `REFUND_WRITE_FROM`, `LeshouyingClient`, `LeshouyingResponseParser`, `LeshouyingProperties`, `LsyQueryService` - Produces: - **构造器(后续所有任务统一按此 5 参签名构造,不要再改)**: `LsyOrderService(LsyOrderMapper orderMapper, LeshouyingClient client, LeshouyingResponseParser parser, LeshouyingProperties props, LsyQueryService queryService)` - `boolean applyPayResult(LsyOrderEntity order, String newStatus, LsyOrderEntity patch)`   patch 由调用方填好业务字段(trade_no / receipt_amount / td_trade_no …);   本方法补 `status` / `finishTime` / `updatedTime` 后做条件更新。   **全模块唯一的支付状态写入口** —— 下单、回调、调度都走它,避免三份副本各自漂移。 - `boolean applyRefundResult(LsyOrderEntity order, long refundAmount, String newStatus)` - `boolean isTerminal(String status)` > 本任务一次性把 5 个 `final` 字段和构造器定下来(虽然本任务只用到 `orderMapper`), > 这样 Task 6-8 往里加下单方法时**不需要回头改 Task 5 的测试**。 - [ ] **Step 1: 写失败的测试** ```java package com.payment.platform.module.payment.leshouying.service; import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; 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.entity.LsyOrderEntity; import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper; 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 java.util.Set; import java.util.stream.Collectors; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class LsyOrderServiceStateTest { @Mock private LsyOrderMapper orderMapper; @Mock private LeshouyingClient client; @Mock private LsyQueryService queryService; private LsyOrderService service; private LeshouyingProperties props; @BeforeEach void setUp() { props = new LeshouyingProperties(); // 构造器签名固定为 5 参 —— 后续任务新增下单方法时不要改动它 service = new LsyOrderService(orderMapper, client, new LeshouyingResponseParser(), props, queryService); } private LsyOrderEntity order(String status) { LsyOrderEntity o = new LsyOrderEntity(); o.setId(1L); o.setStatus(status); o.setPayScene("BARCODE"); o.setTotalAmount(100L); o.setRefundedAmount(0L); return o; } private LsyOrderEntity patch(String tradeNo) { LsyOrderEntity p = new LsyOrderEntity(); p.setTradeNo(tradeNo); p.setUpstreamResultCode("PAY_SUCCESS"); return p; } @Test void applyPayResult_affectedRowIsOne_returnsTrue_andBackfillsOrderInMemory() { when(orderMapper.update(any(), any(UpdateWrapper.class))).thenReturn(1); LsyOrderEntity o = order("PAYING"); assertTrue(service.applyPayResult(o, "SUCCESS", patch("T1"))); // 内存里的对象也要被更新,否则调用方紧接着回调用到的是旧状态 assertEquals("SUCCESS", o.getStatus()); assertEquals("T1", o.getTradeNo()); assertNotNull(o.getFinishTime()); } @Test void applyPayResult_affectedRowIsZero_returnsFalse_duplicateNotification() { when(orderMapper.update(any(), any(UpdateWrapper.class))).thenReturn(0); assertFalse(service.applyPayResult(order("SUCCESS"), "SUCCESS", patch("T1")), "重复通知必须返回 false,调用方据此不重复回调下游"); } @Test void applyPayResult_onlyAllowsCreatedAndPayingAsSource() { when(orderMapper.update(any(), any(UpdateWrapper.class))).thenReturn(1); service.applyPayResult(order("CREATED"), "SUCCESS", patch("T1")); ArgumentCaptor captor = ArgumentCaptor.forClass(UpdateWrapper.class); verify(orderMapper).update(any(), captor.capture()); UpdateWrapper w = captor.getValue(); // ★ 断言必须分两半 —— MyBatis-Plus 的 `.in()` **不把值写进 SQL 文本**, // 而是塞进 paramNameValuePairs,SQL 里只留 `#{ew.paramNameValuePairs.MPGENVALn}` 占位符。 // Task 5 开工前实测(真探针跑在 MP 3.5.5 上,非推断): // getSqlSegment() = (id = #{ew.paramNameValuePairs.MPGENVAL1} AND status IN (#{...VAL2},#{...VAL3})) // paramNameValuePairs = {MPGENVAL3=CREATED, MPGENVAL2=PAYING, MPGENVAL1=1} // seg.contains("CREATED") = **false** // 所以 `sql.contains("CREATED")` 恒 false:两条 assertTrue 必失败, // 而 assertFalse 会**空洞通过**(放行集合是什么都过)。 // 那种测试比没有更糟 —— 它给出"已覆盖"的假象。 String sql = w.getSqlSegment(); assertTrue(sql.contains("status IN"), "放行条件必须是 status 的 IN 集合,实测: " + sql); // 取值看 paramNameValuePairs。只取 String 值(Long 那个是 id)。 // 用**恰好相等**而非 contains:contains 挡不住"多放行了一个状态"。 Set allowFrom = w.getParamNameValuePairs().values().stream() .filter(String.class::isInstance).map(String.class::cast) .collect(Collectors.toSet()); assertEquals(Set.of("CREATED", "PAYING"), allowFrom, "支付回写的放行集合必须恰好是 CREATED/PAYING"); assertFalse(allowFrom.contains("SUCCESS"), "支付回写不得以 SUCCESS 为来源,否则会覆盖退款状态"); } @Test void applyPayResult_stampsStatusAndFinishTimeOntoPatch() { when(orderMapper.update(any(), any(UpdateWrapper.class))).thenReturn(1); LsyOrderEntity p = patch("T1"); service.applyPayResult(order("PAYING"), "FAIL", p); ArgumentCaptor captor = ArgumentCaptor.forClass(LsyOrderEntity.class); verify(orderMapper).update(captor.capture(), any(UpdateWrapper.class)); assertEquals("FAIL", captor.getValue().getStatus()); assertNotNull(captor.getValue().getFinishTime()); assertNotNull(captor.getValue().getUpdatedTime()); } @Test void applyRefundResult_allowsSuccessCanceledClosedPartial() { when(orderMapper.update(any(), any(UpdateWrapper.class))).thenReturn(1); service.applyRefundResult(order("CANCELED"), 100L, "REFUNDED"); ArgumentCaptor captor = ArgumentCaptor.forClass(UpdateWrapper.class); verify(orderMapper).update(any(), captor.capture()); UpdateWrapper w = captor.getValue(); // 同 applyPayResult 那条:值在 paramNameValuePairs 里,不在 SQL 文本里(实测见上) assertTrue(w.getSqlSegment().contains("status IN")); Set allowFrom = w.getParamNameValuePairs().values().stream() .filter(String.class::isInstance).map(String.class::cast) .collect(Collectors.toSet()); assertEquals(Set.of("SUCCESS", "CANCELED", "CLOSED", "PARTIAL_REFUNDED"), allowFrom, "撤销后到达的退款回调必须能回写,否则丢数据"); assertFalse(allowFrom.contains("PAYING"), "退款不得把支付中的单改成退款态"); } @Test void applyRefundResult_accumulatesRefundedAmount() { when(orderMapper.update(any(), any(UpdateWrapper.class))).thenReturn(1); LsyOrderEntity o = order("SUCCESS"); o.setRefundedAmount(30L); // 已退 30 service.applyRefundResult(o, 20L, "PARTIAL_REFUNDED"); ArgumentCaptor captor = ArgumentCaptor.forClass(LsyOrderEntity.class); verify(orderMapper).update(captor.capture(), any(UpdateWrapper.class)); assertEquals(50L, captor.getValue().getRefundedAmount()); assertEquals(50L, o.getRefundedAmount(), "内存对象同步累加"); } @Test void isTerminal_recognizesAllTerminalStates() { assertTrue(service.isTerminal("SUCCESS")); assertTrue(service.isTerminal("FAIL")); assertTrue(service.isTerminal("CANCELED")); assertTrue(service.isTerminal("CLOSED")); assertTrue(service.isTerminal("EXPIRED")); assertTrue(service.isTerminal("REFUNDED")); assertFalse(service.isTerminal("CREATED")); assertFalse(service.isTerminal("PAYING")); assertFalse(service.isTerminal("PARTIAL_REFUNDED")); } } ``` - [ ] **Step 2: 运行测试确认失败** ```bash cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LsyOrderServiceStateTest ``` Expected: 编译失败。 - [ ] **Step 3: 实现状态流转** 创建 `LsyOrderService.java`,本任务只写以下方法(下单方法在 Task 6-8 追加): ```java package com.payment.platform.module.payment.leshouying.service; import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; 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.entity.LsyOrderEntity; import com.payment.platform.module.payment.leshouying.enums.LsyOrderStatus; import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.time.OffsetDateTime; import java.util.Set; /** * 乐收赢订单服务 —— 状态机核心 + 三种下单入口(下单方法在 Task 6-8 追加)。 * * 【幂等设计】所有状态流转走条件更新,用受影响行数判断是否真的流转: * 行数 = 1 → 真流转 → 调用方据此触发下游回调 * 行数 = 0 → 重复通知 / 竞态 → 静默忽略,不重复回调 * 不用唯一约束做幂等 —— 那会误伤「撤销后到达的退款回调」这类更正型通知。 * * 【单一写入口】applyPayResult 是全模块唯一改支付状态的路径。 * 下单、回调、两个调度器都调它,避免条件更新逻辑出现多份副本各自漂移。 */ @Slf4j @Service @RequiredArgsConstructor public class LsyOrderService { private final LsyOrderMapper orderMapper; private final LeshouyingClient client; private final LeshouyingResponseParser parser; private final LeshouyingProperties props; private final LsyQueryService queryService; private static final Set TERMINAL = Set.of( "SUCCESS", "FAIL", "CANCELED", "CLOSED", "EXPIRED", "REFUNDED"); /** * 支付结果回写。返回 true 表示真的发生了流转(调用方应触发下游回调)。 * * @param patch 调用方填好的业务字段(trade_no / receipt_amount / td_trade_no / user_id …)。 * 本方法会覆盖其中的 status / finishTime / updatedTime。 */ public boolean applyPayResult(LsyOrderEntity order, String newStatus, LsyOrderEntity patch) { if (patch == null) { patch = new LsyOrderEntity(); } patch.setStatus(newStatus); patch.setFinishTime(OffsetDateTime.now()); patch.setUpdatedTime(OffsetDateTime.now()); UpdateWrapper w = new UpdateWrapper<>(); w.eq("id", order.getId()) .in("status", LsyOrderStatus.PAY_WRITE_FROM); int rows = orderMapper.update(patch, w); if (rows == 1) { // 同步内存对象:调用方紧接着要拿新状态去回调下游 order.setStatus(newStatus); order.setFinishTime(patch.getFinishTime()); if (patch.getTradeNo() != null) { order.setTradeNo(patch.getTradeNo()); } if (patch.getTdTradeNo() != null) { order.setTdTradeNo(patch.getTdTradeNo()); } if (patch.getQdTradeNo() != null) { order.setQdTradeNo(patch.getQdTradeNo()); } if (patch.getUserId() != null) { order.setUserId(patch.getUserId()); } if (patch.getReceiptAmount() != null) { order.setReceiptAmount(patch.getReceiptAmount()); } if (patch.getUpstreamResultCode() != null) { order.setUpstreamResultCode(patch.getUpstreamResultCode()); } return true; } log.info("支付回写未命中(重复或状态不符): orderId={}, currentStatus={}, target={}", order.getId(), order.getStatus(), newStatus); return false; } /** 退款结果回写:累加 refundedAmount,更新状态。返回 true 表示真的流转。 */ public boolean applyRefundResult(LsyOrderEntity order, long refundAmount, String newStatus) { long current = order.getRefundedAmount() == null ? 0L : order.getRefundedAmount(); long accumulated = current + refundAmount; LsyOrderEntity patch = new LsyOrderEntity(); patch.setStatus(newStatus); patch.setRefundedAmount(accumulated); patch.setUpdatedTime(OffsetDateTime.now()); UpdateWrapper w = new UpdateWrapper<>(); w.eq("id", order.getId()) .in("status", LsyOrderStatus.REFUND_WRITE_FROM); int rows = orderMapper.update(patch, w); if (rows == 1) { order.setStatus(newStatus); order.setRefundedAmount(accumulated); return true; } log.info("退款回写未命中: orderId={}, currentStatus={}", order.getId(), order.getStatus()); return false; } public boolean isTerminal(String status) { return status != null && TERMINAL.contains(status); } } ``` > **本任务只实现状态流转**,`client` / `parser` / `props` / `queryService` 四个字段暂时只声明不使用 —— > 这是刻意的:一次性锁定构造器签名,Task 6-8 加下单方法时就不必回头改本任务的测试。 > 编译不会因未使用字段报警(`private final` 由 Lombok 生成构造器,必然被"使用")。 - [ ] **Step 4: 运行测试确认全绿** ```bash cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LsyOrderServiceStateTest ``` Expected: `Tests run: 7, Failures: 0, Errors: 0` - [ ] **Step 5: 确认工作区状态(不提交)** ```bash git status --short ``` --- ## Task 6: 被扫下单(含同步轮询) **唯一需要同步轮询的场景。** 用户就站在收银台前,要有即时反馈。 **Files:** - Modify: `java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyOrderService.java:1`(追加 `createBarcodeOrder`) - Modify: `java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyQueryService.java`(本任务只写 `queryOrder`) - Create: `java/src/main/java/com/payment/platform/module/payment/leshouying/dto/LsyPayResultDTO.java` - Test: `java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyBarcodePayTest.java` **Interfaces:** - Consumes: `LeshouyingClient.post(String,Map)`, `LeshouyingResponseParser.isPaySuccess/isPayInProcess/isPayFail`, `LsyOrderService.applyPayResult(...)` - **三个谓词都要用**:`isPaySuccess` → SUCCESS,`isPayFail` → FAIL,**两者皆 false → 什么都不写**。 Task 7/8 下单路径同理。`!isPayInProcess` 当失败用是错的,见 Task 2 Produces 的说明。 - Produces: - `LsyPayResultDTO LsyOrderService.createBarcodeOrder(LsyOrderEntity order, String authCode, String payType)` - `LsyPayResultDTO` 字段:`String outTradeNo`、`String status`、`String tradeNo`、`Long totalAmount`、`Long receiptAmount`、`String codeUrl`、`String message` - `Map LsyQueryService.queryOrder(LsyOrderEntity order)` - **`LsyOrderEntity LsyOrderService.payPatch(Map resp, Long receiptFallback)`**   **包内可见**(非 private):Task 12 的回调报文与查询报文同形,直接复用它,不要自己再写一份映射。 - **`String LsyOrderService.settleFromQuery(LsyOrderEntity order, Map resp)`**   `"SUCCESS"` / `"FAIL"` / `null`(支付中**或**条件更新未命中)。   全模块唯一解释「上游支付报文」的地方;Task 16 两个调度器、Task 7/8 都调它。   **调用方见 null 时不得据此撤销订单** —— 它无法区分「仍支付中」和「已被回调抢先」,   要撤销必须另查一次交易(见 Task 16 Step 3 的复查逻辑)。 - [ ] **Step 1: 写失败的测试** ```java package com.payment.platform.module.payment.leshouying.service; 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.LsyPayResultDTO; import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity; import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import java.util.LinkedHashMap; import java.util.Map; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class LsyBarcodePayTest { @Mock private LeshouyingClient client; @Mock private LsyOrderMapper orderMapper; @Mock private LsyQueryService queryService; private LsyOrderService orderService; private LeshouyingProperties props; @BeforeEach void setUp() { props = new LeshouyingProperties(); props.setSyncPollTimes(3); props.setSyncPollIntervalMs(0); // 测试里不真的睡 // 构造器签名与 Task 5 一致(5 参),不要改 orderService = new LsyOrderService(orderMapper, client, new LeshouyingResponseParser(), props, queryService); } private LsyOrderEntity order() { LsyOrderEntity o = new LsyOrderEntity(); o.setId(1L); o.setOutTradeNo("OUT001"); o.setMchNo("Y52211090004"); o.setPayScene("BARCODE"); o.setPayTraceNo("PT001"); o.setPayTime("20260921120000"); o.setTotalAmount(100L); o.setStatus("CREATED"); return o; } private Map resp(String resultCode) { Map m = new LinkedHashMap<>(); m.put("return_code", "SUCCESS"); m.put("result_code", resultCode); m.put("trade_no", "T001"); m.put("total_amount", "100"); return m; } @Test void barcode_paySuccess_returnsImmediately_withoutPolling() { when(client.post(eq("/v3/pay"), anyMap())).thenReturn(resp("PAY_SUCCESS")); when(orderMapper.update(any(), any())).thenReturn(1); LsyPayResultDTO r = orderService.createBarcodeOrder(order(), "134567890123456789", "000"); assertEquals("SUCCESS", r.getStatus()); assertEquals("T001", r.getTradeNo()); verify(client, times(1)).post(eq("/v3/pay"), anyMap()); // ★ 必须打在 queryService 上:实现从不以 /v3/queryorder 路径调 client, // 写 client 的 never() 恒真 —— 那是空洞断言,给的是「已覆盖」的假象。 verify(queryService, never()).queryOrder(any()); } @Test void barcode_payFail_returnsFail() { when(client.post(eq("/v3/pay"), anyMap())).thenReturn(resp("PAY_FAIL")); when(orderMapper.update(any(), any())).thenReturn(1); LsyPayResultDTO r = orderService.createBarcodeOrder(order(), "134567890123456789", "000"); assertEquals("FAIL", r.getStatus()); } @Test void barcode_payInProcess_thenSuccessOnSecondSyncPoll() { when(client.post(eq("/v3/pay"), anyMap())).thenReturn(resp("PAY_IN_PROCESS")); when(orderMapper.update(any(), any())).thenReturn(1); // 第 1 次查询仍支付中,第 2 次成功 // ★ 桩必须打在 queryService 上,不是 client 上。 // 同步轮询的实现(settleAfterPay)走的是 queryService.queryOrder(order), // 它内部才去调 client.post("/v3/queryorder")。client 是 @Mock ⇒ 那条边永不执行。 // Task 6 实现时实测:打在 client 上的桩永不兑现,Mockito 直接报 // 「Wanted but not invoked … However, there was exactly 1 interaction with this mock」, // 而那 1 次是 /v3/pay。计划里 Task 9 / Task 16 的测试本来就全桩在 queryService 上, // 此处原为孤例。 when(queryService.queryOrder(any())) .thenReturn(resp("PAY_IN_PROCESS")) .thenReturn(resp("PAY_SUCCESS")); LsyPayResultDTO r = orderService.createBarcodeOrder(order(), "134567890123456789", "000"); assertEquals("SUCCESS", r.getStatus()); verify(queryService, times(2)).queryOrder(any()); } @Test void barcode_payInProcess_exhaustsSyncPolls_returnsPayingAndSetsNextQuery() { when(client.post(eq("/v3/pay"), anyMap())).thenReturn(resp("PAY_IN_PROCESS")); when(queryService.queryOrder(any())).thenReturn(resp("PAY_IN_PROCESS")); when(orderMapper.update(any(), any())).thenReturn(1); LsyPayResultDTO r = orderService.createBarcodeOrder(order(), "134567890123456789", "000"); assertEquals("PAYING", r.getStatus()); verify(queryService, times(3)).queryOrder(any()); // syncPollTimes = 3 var captor = org.mockito.ArgumentCaptor.forClass(LsyOrderEntity.class); verify(orderMapper, atLeastOnce()).update(captor.capture(), any()); LsyOrderEntity lastPatch = captor.getAllValues().get(captor.getAllValues().size() - 1); assertNotNull(lastPatch.getNextQueryTime(), "必须落 next_query_time 交给异步调度器"); } @Test void barcode_sendsAuthCodeAndPayType_upstream() { when(client.post(eq("/v3/pay"), anyMap())).thenReturn(resp("PAY_SUCCESS")); when(orderMapper.update(any(), any())).thenReturn(1); orderService.createBarcodeOrder(order(), "134567890123456789", "400"); var captor = org.mockito.ArgumentCaptor.forClass(Map.class); verify(client).post(eq("/v3/pay"), captor.capture()); @SuppressWarnings("unchecked") Map sent = captor.getValue(); assertEquals("134567890123456789", sent.get("auth_code")); assertEquals("400", sent.get("pay_type")); assertEquals("Y52211090004", sent.get("mch_no")); assertEquals("PT001", sent.get("pay_trace_no")); } // ---- settleFromQuery 的契约(Task 16 两个调度器都依赖它) ---- @Test void settleFromQuery_success_writesAndReturnsSuccess() { when(orderMapper.update(any(), any())).thenReturn(1); assertEquals("SUCCESS", orderService.settleFromQuery(order(), resp("PAY_SUCCESS"))); } @Test void settleFromQuery_conditionUpdateMissed_returnsNull() { // 回调抢先写了终态 → WHERE status IN ('CREATED','PAYING') 命中 0 行 when(orderMapper.update(any(), any())).thenReturn(0); assertNull(orderService.settleFromQuery(order(), resp("PAY_SUCCESS")), "未命中必须返回 null,否则调度器会去撤销一张已付款的单"); } @Test void settleFromQuery_inProcess_returnsNull_andDoesNotTouchDb() { assertNull(orderService.settleFromQuery(order(), resp("PAY_IN_PROCESS"))); verify(orderMapper, never()).update(any(), any()); } @Test void settleFromQuery_payFail_writesFail() { // 显式 PAY_FAIL 是**唯一**允许落 FAIL 的输入 when(orderMapper.update(any(), any())).thenReturn(1); assertEquals("FAIL", orderService.settleFromQuery(order(), resp("PAY_FAIL"))); } @Test void settleFromQuery_unknownResultCode_doesNotWriteAnyState() { // ★ 本用例守的是「钱已收、单记失败」这个最坏方向。 // `resp("SUCCESS")` 造出的正是那个输入:return_code=SUCCESS 而 result_code 是通用成功码。 // 原写法 `if (!isPayInProcess(resp)) → applyPayResult(FAIL)` 会把这样一笔 // **已经收到钱的支付**写成终态 FAIL —— 终态不会被回调纠正,也退出了对账集合。 // 正确行为:三个谓词都不认 → 什么都不写,订单留在 CREATED/PAYING(= PENDING), // 下一轮 LsyReconcileScheduler 会拿同一个问题再问上游一次。 assertNull(orderService.settleFromQuery(order(), resp("SUCCESS")), "未知码不得落终态,必须返回 null 让订单留在 PENDING 被对账捞起"); verify(orderMapper, never()).update(any(), any()); } @Test void payPatch_mapsAllUpstreamFields() { // 「上游报文 → 我们字段」的唯一映射点在这里。 // 回调用的是同一个方法(包内可见),所以回调字段也由本用例覆盖。 Map r = resp("PAY_SUCCESS"); r.put("trade_no", "T001"); r.put("td_trade_no", "TD001"); r.put("qd_trade_no", "QD001"); r.put("user_id", "2088xxxx"); r.put("receipt_amount", "100"); r.put("return_msg", "成功"); LsyOrderEntity patch = orderService.payPatch(r, null); assertEquals("PAY_SUCCESS", patch.getUpstreamResultCode()); assertEquals("成功", patch.getUpstreamResultMsg()); assertEquals("T001", patch.getTradeNo()); assertEquals("TD001", patch.getTdTradeNo()); assertEquals("QD001", patch.getQdTradeNo()); assertEquals("2088xxxx", patch.getUserId()); assertEquals(100L, patch.getReceiptAmount()); } @Test void payPatch_receiptAmountAbsent_fallsBackToOrderTotal() { // 同步轮询拿到 PAY_SUCCESS 时上游可能不带 receipt_amount,用订单金额兜底 LsyOrderEntity patch = orderService.payPatch(resp("PAY_SUCCESS"), 100L); assertEquals(100L, patch.getReceiptAmount()); } } ``` - [ ] **Step 2: 运行测试确认失败** ```bash cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LsyBarcodePayTest ``` Expected: 编译失败。 - [ ] **Step 3: 写 LsyPayResultDTO** ```java package com.payment.platform.module.payment.leshouying.dto; import lombok.Data; /** 下单结果(三种场景共用),返回给下游 OpenAPI 层 */ @Data public class LsyPayResultDTO { private String outTradeNo; /** CREATED / PAYING / SUCCESS / FAIL */ private String status; private String tradeNo; private Long totalAmount; private Long receiptAmount; /** 主扫/H5 场景的跳转或二维码链接 */ private String codeUrl; private String message; } ``` - [ ] **Step 4: 写 LsyQueryService.queryOrder** ```java package com.payment.platform.module.payment.leshouying.service; import cn.hutool.core.util.IdUtil; import com.payment.platform.module.payment.leshouying.client.LeshouyingClient; import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.util.LinkedHashMap; import java.util.Map; /** 交易查询。上游要求每次查询带唯一的 query_trace_no。 */ @Slf4j @Service @RequiredArgsConstructor public class LsyQueryService { private final LeshouyingClient client; /** * 查交易。优先用 trade_no,没有则用 pay_trace_no + pay_time 组合。 * 注意:判断交易结果以 result_code 为准,trade_state 仅供参考(上游文档明确说明)。 */ public Map queryOrder(LsyOrderEntity order) { Map p = new LinkedHashMap<>(); p.put("mch_no", order.getMchNo()); if (order.getShopNo() != null) { p.put("shop_no", order.getShopNo()); } p.put("query_trace_no", IdUtil.fastSimpleUUID()); if (order.getTradeNo() != null) { p.put("trade_no", order.getTradeNo()); } else { p.put("pay_trace_no", order.getPayTraceNo()); p.put("pay_time", order.getPayTime()); } return client.post("/v3/queryorder", p); } } ``` - [ ] **Step 5: 把被扫下单方法追加到 LsyOrderService** **不要改动 Task 5 定下的 5 个字段和构造器**,只在类中追加常量和下列方法: ```java private static final String TIME_FMT = "yyyyMMddHHmmss"; /** * 被扫下单(B扫C)。 * 流程:调 v3/pay → PAY_SUCCESS/PAY_FAIL 直接返回 → PAY_IN_PROCESS 则同步轮询 syncPollTimes 次。 * 轮询耗尽仍支付中 → 落 next_query_time,交给 LsyOrderPollScheduler。 */ public LsyPayResultDTO createBarcodeOrder(LsyOrderEntity order, String authCode, String payType) { Map p = new LinkedHashMap<>(); p.put("mch_no", order.getMchNo()); if (order.getShopNo() != null) { p.put("shop_no", order.getShopNo()); } p.put("pay_trace_no", order.getPayTraceNo()); p.put("pay_time", order.getPayTime()); p.put("total_amount", String.valueOf(order.getTotalAmount())); p.put("auth_code", authCode); p.put("pay_type", StrUtil.blankToDefault(payType, LsyPayType.AUTO.getCode())); if (order.getOrderBody() != null) { p.put("order_body", order.getOrderBody()); } if (order.getAttach() != null) { p.put("attach", order.getAttach()); } if (order.getNotifyUrl() != null) { p.put("notify_url", order.getNotifyUrl()); } Map resp = client.post("/v3/pay", p); return settleAfterPay(order, resp, true); } /** * 把上游下单/查询响应落到订单上,必要时同步轮询。 * * 【只给被扫用】全模块唯一调用点是 {@code createBarcodeOrder}(传 allowSyncPoll=true)。 * 主扫/H5 **不得**走这里 —— 下单这一刻拿不到支付结果(主扫的码还没被扫、H5 是用户跳到上游页面自行付款), * 同步轮询只会白占请求线程。 * * ★ 原注释「Task 7/8 复用(allowSyncPoll=false)」**不实**:Task 7 与 Task 8 都没复用它, * allowSyncPoll=false 那条分支实际是死代码。签名保留不动(不得删参数、不得删该分支)。 */ private LsyPayResultDTO settleAfterPay(LsyOrderEntity order, Map resp, boolean allowSyncPoll) { String settled = settleFromQuery(order, resp); if (settled != null) { return toResult(order, "SUCCESS".equals(settled) ? "支付成功" : "支付失败"); } // 支付中 applyPayResult(order, "PAYING", payPatch(resp, null)); if (!allowSyncPoll) { return toResult(order, "支付中"); } for (int i = 0; i < props.getSyncPollTimes(); i++) { sleepQuietly(props.getSyncPollIntervalMs()); Map q = queryService.queryOrder(order); String s = settleFromQuery(order, q); if (s != null) { return toResult(order, "SUCCESS".equals(s) ? "支付成功" : "支付失败"); } } markNextQuery(order); return toResult(order, "支付中"); } /** * 把一次上游查询/下单响应落库 —— 全模块唯一解释「上游支付报文」的地方。 * * 被扫同步轮询、被扫密集轮询(LsyOrderPollScheduler)、主扫/H5 兜底对账 * (LsyReconcileScheduler)三条路径全部调用本方法,不要再各自写一份分支。 * * @return 流转后的终态 "SUCCESS"/"FAIL";仍是支付中、或条件更新未命中 * (说明已被回调先行写终态 = 重复)返回 null */ public String settleFromQuery(LsyOrderEntity order, Map resp) { if (parser.isPaySuccess(resp)) { return applyPayResult(order, "SUCCESS", payPatch(resp, order.getTotalAmount())) ? "SUCCESS" : null; } if (parser.isPayFail(resp)) { return applyPayResult(order, "FAIL", payPatch(resp, null)) ? "FAIL" : null; } if (!parser.isPayInProcess(resp)) { // ★ 三个谓词全 false = 上游回了个我们没见过的 result_code。 // 这里**绝不能**落 FAIL。原写法 `if (!isPayInProcess) → FAIL` 就是这个陷阱: // 上游若回了通用成功码(如 "SUCCESS" 而非 "PAY_SUCCESS"),它会把 // **一笔已经收到钱的支付写成终态 FAIL** —— 用户付了款、我们的单是失败, // 而且终态不再进对账集合,回调若也回同一个码也纠正不了。这是最坏的失败方向。 // // 正确做法是**什么都不写**:订单留在 CREATED/PAYING(= LsyOrderStatus.PENDING), // 于是调用方原有的重试机制会拿同一个问题再问上游一次 —— // 走对账路径的(PAYING 的 QRCODE/H5)由 LsyReconcileScheduler.reconcilePendingOrders 再查, // 走被扫轮询的由 LsyOrderPollScheduler 按 queryCount 再查(耗尽后自动撤销)。 // 两条路都有界,不会无限重试。落 FAIL 反而把这两条路全切断。 // 把原始码打出来 —— 未知码第一次出现时,这行日志是唯一的追溯线索。 log.error("乐收赢返回未知 result_code,不落状态、留在 PENDING 待对账: outTradeNo={}, " + "return_code={}, result_code={}, return_msg={}", order.getOutTradeNo(), resp.get("return_code"), resp.get("result_code"), resp.get("return_msg")); } return null; } /** * 把上游报文摊平成 patch 对象,交给 applyPayResult 统一落库。 * 这是「上游字段 → 我们字段」的**唯一映射点**,别再在别处重复写一遍。 * * 包内可见(非 private):LsyNotifyService 在同一包下,回调报文与查询报文 * 字段名完全一致,直接复用它,不要自己再写一份。 */ LsyOrderEntity payPatch(Map resp, Long receiptFallback) { LsyOrderEntity patch = new LsyOrderEntity(); patch.setUpstreamResultCode(str(resp.get("result_code"))); patch.setUpstreamResultMsg(str(resp.get("return_msg"))); patch.setTradeNo(str(resp.get("trade_no"))); patch.setTdTradeNo(str(resp.get("td_trade_no"))); patch.setQdTradeNo(str(resp.get("qd_trade_no"))); patch.setUserId(str(resp.get("user_id"))); patch.setReceiptAmount(parseAmount(resp.get("receipt_amount"), receiptFallback)); // ★ fallback 必须传 null(不是 receiptFallback)—— 上游没返回 discount_amount 就是**未知**, // 不是「没有优惠」。写 0 等于替上游编一个它没说的结论;写订单总额更是无中生有。 patch.setDiscountAmount(parseAmount(resp.get("discount_amount"), null)); return patch; } /** 落 next_query_time,交给异步调度器接手 */ void markNextQuery(LsyOrderEntity order) { LsyOrderEntity patch = new LsyOrderEntity(); patch.setNextQueryTime(OffsetDateTime.now().plusSeconds( props.getBarcodePollIntervalMs() / 1000)); patch.setUpdatedTime(OffsetDateTime.now()); orderMapper.update(patch, new UpdateWrapper() .eq("id", order.getId()).in("status", LsyOrderStatus.PAY_WRITE_FROM)); order.setNextQueryTime(patch.getNextQueryTime()); } private void sleepQuietly(long ms) { if (ms <= 0) { return; } try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } private LsyPayResultDTO toResult(LsyOrderEntity order, String msg) { LsyPayResultDTO dto = new LsyPayResultDTO(); dto.setOutTradeNo(order.getOutTradeNo()); dto.setStatus(order.getStatus()); dto.setTradeNo(order.getTradeNo()); dto.setTotalAmount(order.getTotalAmount()); dto.setReceiptAmount(order.getReceiptAmount()); dto.setCodeUrl(order.getCodeUrl()); dto.setMessage(msg); return dto; } private String str(Object o) { return o == null ? null : String.valueOf(o); } private Long parseAmount(Object v, Long fallback) { if (v == null) { return fallback; } try { return Long.parseLong(String.valueOf(v)); } catch (NumberFormatException e) { return fallback; } } ``` `LsyQueryService` 已在 Task 5 注入完毕(**无循环依赖:`LsyQueryService` 只依赖 `LeshouyingClient`,不依赖 `LsyOrderService`**)。 类头部需要补 import: ```java import cn.hutool.core.util.StrUtil; import com.payment.platform.module.payment.leshouying.dto.LsyPayResultDTO; import com.payment.platform.module.payment.leshouying.enums.LsyPayType; import java.util.LinkedHashMap; import java.util.Map; ``` - [ ] **Step 6: 运行测试确认全绿** ```bash cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LsyBarcodePayTest ``` Expected: `Tests run: 12, Failures: 0, Errors: 0` (5 个下单用例 + 5 个 `settleFromQuery` 契约用例 + 2 个 `payPatch` 映射用例) - [ ] **Step 7: 回归 Task 5 的状态机测试** ```bash cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest='Lsy*Test' ``` Expected: 全绿。构造器签名在 Task 5 就已锁定为 5 参,本任务不新增依赖,**Task 5 的测试无需改动**。 - [ ] **Step 8: 确认工作区状态(不提交)** ```bash git status --short ``` --- ## Task 7: 主扫下单(聚合码 qrpay) **关键约束:`qrpay` 不返回 `PAY_IN_PROCESS`**(spec §2.1),因此**不做同步轮询**。 **Files:** - Modify: `java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyOrderService.java`(追加 `createQrOrder`) - Test: `java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyQrPayTest.java` **Interfaces:** - Consumes: `LeshouyingClient.post`, `LsyOrderService.settleAfterPay`(本任务复用但 `allowSyncPoll=false`) - Produces: - `LsyPayResultDTO LsyOrderService.createQrOrder(LsyOrderEntity order, Integer timeExpireMinutes)` - [ ] **Step 1: 写失败的测试** ```java package com.payment.platform.module.payment.leshouying.service; 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.LsyPayResultDTO; import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity; import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import java.util.LinkedHashMap; import java.util.Map; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class LsyQrPayTest { @Mock private LeshouyingClient client; @Mock private LsyOrderMapper orderMapper; @Mock private LsyQueryService queryService; private LsyOrderService orderService; private LeshouyingProperties props; @BeforeEach void setUp() { props = new LeshouyingProperties(); props.setDefaultQrExpireMinutes(15); // 构造器签名与 Task 5 一致(5 参) orderService = new LsyOrderService(orderMapper, client, new LeshouyingResponseParser(), props, queryService); } private LsyOrderEntity order() { LsyOrderEntity o = new LsyOrderEntity(); o.setId(2L); o.setOutTradeNo("OUT002"); o.setMchNo("Y52211090004"); o.setPayScene("QRCODE"); o.setPayTraceNo("PT002"); o.setPayTime("20260921120000"); o.setTotalAmount(500L); o.setStatus("CREATED"); return o; } @Test void qr_success_returnsCodeUrl() { Map resp = new LinkedHashMap<>(); resp.put("return_code", "SUCCESS"); resp.put("result_code", "PAY_SUCCESS"); resp.put("code_url", "https://open.gdyfsk.com/yfpay/s/abc123"); when(client.post(eq("/v3/qrpay"), anyMap())).thenReturn(resp); when(orderMapper.update(any(), any())).thenReturn(1); LsyPayResultDTO r = orderService.createQrOrder(order(), null); // ★ 落 PAYING 不是 SUCCESS —— 下单成功 ≠ 收款成功(判据见 createQrOrder 的裁定注释)。 assertEquals("PAYING", r.getStatus()); assertEquals("https://open.gdyfsk.com/yfpay/s/abc123", r.getCodeUrl()); } @Test void qr_neverSendsPayTypeParam() { Map resp = new LinkedHashMap<>(); resp.put("return_code", "SUCCESS"); resp.put("result_code", "PAY_SUCCESS"); resp.put("code_url", "https://x"); when(client.post(eq("/v3/qrpay"), anyMap())).thenReturn(resp); when(orderMapper.update(any(), any())).thenReturn(1); orderService.createQrOrder(order(), null); var captor = org.mockito.ArgumentCaptor.forClass(Map.class); verify(client).post(eq("/v3/qrpay"), captor.capture()); @SuppressWarnings("unchecked") Map sent = captor.getValue(); assertFalse(sent.containsKey("pay_type"), "聚合码自动识别通道,传 pay_type 会被上游拒绝"); } @Test void qr_defaultExpireApplied_whenNotSpecified() { Map resp = new LinkedHashMap<>(); resp.put("return_code", "SUCCESS"); resp.put("result_code", "PAY_SUCCESS"); resp.put("code_url", "https://x"); when(client.post(eq("/v3/qrpay"), anyMap())).thenReturn(resp); when(orderMapper.update(any(), any())).thenReturn(1); orderService.createQrOrder(order(), null); var captor = org.mockito.ArgumentCaptor.forClass(Map.class); verify(client).post(eq("/v3/qrpay"), captor.capture()); // 不加 String.valueOf —— 那会把类型信息抹掉,实现若塞 Integer 15 也照样绿。 assertEquals("15", captor.getValue().get("time_expire")); } @Test void qr_customExpire_isClampedToThreeDays() { Map resp = new LinkedHashMap<>(); resp.put("return_code", "SUCCESS"); resp.put("result_code", "PAY_SUCCESS"); resp.put("code_url", "https://x"); when(client.post(eq("/v3/qrpay"), anyMap())).thenReturn(resp); when(orderMapper.update(any(), any())).thenReturn(1); orderService.createQrOrder(order(), 99999); // 超过 4320 分钟上限 var captor = org.mockito.ArgumentCaptor.forClass(Map.class); verify(client).post(eq("/v3/qrpay"), captor.capture()); assertEquals("4320", captor.getValue().get("time_expire")); } @Test void qr_neverSyncPolls() { Map resp = new LinkedHashMap<>(); resp.put("return_code", "SUCCESS"); // ★ 必须用**非终态**码。用 PAY_SUCCESS 时 settleFromQuery 立刻返回 SUCCESS、settleAfterPay 提前 return, // 轮询循环根本不可达 —— 下面的 verify(never()) 就成了恒真断言,去掉实现的任何分支都不会红。 resp.put("result_code", "PAY_IN_PROCESS"); resp.put("code_url", "https://x"); when(client.post(eq("/v3/qrpay"), anyMap())).thenReturn(resp); // 不 stub orderMapper.update:STRICT_STUBS 下未被调用的 stub 会报 UnnecessaryStubbingException, // 而「一次都没被调用」正是本用例要断言的事实 —— 补一个桩反而会让用例红。 orderService.createQrOrder(order(), null); verify(queryService, never()).queryOrder(any()); } @Test void qr_payFail_returnsFail() { Map resp = new LinkedHashMap<>(); resp.put("return_code", "SUCCESS"); resp.put("result_code", "PAY_FAIL"); resp.put("return_msg", "商户未开通"); when(client.post(eq("/v3/qrpay"), anyMap())).thenReturn(resp); when(orderMapper.update(any(), any())).thenReturn(1); LsyPayResultDTO r = orderService.createQrOrder(order(), null); assertEquals("FAIL", r.getStatus()); } @Test void qr_unknownResultCode_doesNotWriteTerminalFail() { // 未知码 ≠ 失败。落 FAIL 会让订单退出 PENDING、不再被对账捞起, // 而这时我们并不知道上游到底建没建出这笔单。 Map resp = new LinkedHashMap<>(); resp.put("return_code", "SUCCESS"); resp.put("result_code", "SUCCESS"); // 通用成功码,三个谓词都不认 when(client.post(eq("/v3/qrpay"), anyMap())).thenReturn(resp); // 注意:**不要**在这里 stub orderMapper.update —— MockitoExtension 默认 STRICT_STUBS, // 未被调用的 stub 会直接让用例报 UnnecessaryStubbingException。 // 而「一次都没被调用」正是本用例要断言的事实。 LsyPayResultDTO r = orderService.createQrOrder(order(), null); // 对调用方仍回「下单失败」—— 确实没拿到可用的 code_url,不能骗调用方 assertEquals("下单失败", r.getMessage()); // 但库里**不能**落终态 verify(orderMapper, never()).update(any(), any()); } } ``` - [ ] **Step 2: 运行测试确认失败** ```bash cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LsyQrPayTest ``` Expected: 编译失败 —— `createQrOrder` 不存在。 - [ ] **Step 3: 实现 createQrOrder** 追加到 `LsyOrderService`: ```java /** 上游硬限制:聚合码有效期最大 3 天 */ private static final int QR_MAX_EXPIRE_MINUTES = 4320; /** * 主扫下单(聚合码,C扫B)。 * 不做同步轮询 —— qrpay 只返回 PAY_SUCCESS/PAY_FAIL,没有 PAY_IN_PROCESS。 * 支付结果靠回调 + LsyReconcileScheduler 兜底。 */ public LsyPayResultDTO createQrOrder(LsyOrderEntity order, Integer timeExpireMinutes) { int expire = timeExpireMinutes == null ? props.getDefaultQrExpireMinutes() : timeExpireMinutes; if (expire <= 0) { expire = props.getDefaultQrExpireMinutes(); } expire = Math.min(expire, QR_MAX_EXPIRE_MINUTES); Map p = new LinkedHashMap<>(); p.put("mch_no", order.getMchNo()); if (order.getShopNo() != null) { p.put("shop_no", order.getShopNo()); } p.put("pay_trace_no", order.getPayTraceNo()); p.put("pay_time", order.getPayTime()); p.put("total_amount", String.valueOf(order.getTotalAmount())); p.put("time_expire", String.valueOf(expire)); if (order.getOrderBody() != null) { p.put("order_body", order.getOrderBody()); } if (order.getAttach() != null) { p.put("attach", order.getAttach()); } if (order.getNotifyUrl() != null) { p.put("notify_url", order.getNotifyUrl()); } // 注意:聚合码不传 pay_type,上游自动识别支付宝/微信 Map resp = client.post("/v3/qrpay", p); if (parser.isPaySuccess(resp)) { String codeUrl = str(resp.get("code_url")); OffsetDateTime expireAt = OffsetDateTime.now().plusMinutes(expire); // code_url 不走 applyPayResult(那不是支付结果字段),先单独落库 LsyOrderEntity urlPatch = new LsyOrderEntity(); urlPatch.setCodeUrl(codeUrl); urlPatch.setCodeUrlExpireTime(expireAt); urlPatch.setUpdatedTime(OffsetDateTime.now()); orderMapper.update(urlPatch, new UpdateWrapper() .eq("id", order.getId()).in("status", LsyOrderStatus.PAY_WRITE_FROM)); order.setCodeUrl(codeUrl); order.setCodeUrlExpireTime(expireAt); // ★ receiptFallback 传 null(**下单路径**):上游没说的字段就是**未知**,不许替它编一个。 // 传 order.getTotalAmount() 会在下游没回 receipt_amount 时把实收写成全额, // 而这一单此刻是 PAYING、用户一分钱都还没付 —— order/query 透给租户的就是自相矛盾的信息。 // (对比 settleFromQuery / createBarcodeOrder:那是**支付结果路径**,PAY_SUCCESS 就是收款成功, // 兜底成订单总额才对。判据只有一条:这条路径发起时,用户有没有可能已经付完款?) LsyOrderEntity payPatch = payPatch(resp, null); payPatch.setCodeUrl(codeUrl); // ★ 落 PAYING,**不是** SUCCESS。 // 下单成功 ≠ 收款成功:/v3/qrpay 回的 PAY_SUCCESS 描述的是「下单请求被受理」, // 此刻码还没交到用户手上,不可能有任何支付发生。 // 而 SUCCESS 是首个终态、PAY_WRITE_FROM = {CREATED, PAYING} 不含它 —— 写进去就再改不了: // 用户真付款后的回调走同一个 applyPayResult → 0 行 → changed==false // → 不触发 callbackService.notifyOrder → 下游一条通知都收不到 // (retryFailed 只重试 notify_status='FAIL',本单该列恒为 NULL,永不被拾起), // 且 reconcilePendingOrders / expireStaleOrders 都过滤非终态,两条对账路也捞不到。 // PAYING 非终态,四处既有机制就地对上:① 在 PAY_WRITE_FROM 内 → 回调可写; // ② PAYING+QRCODE 命中 reconcilePendingOrders 的两个过滤条件;③ PAYING ∈ PENDING → 绝对过期兜底; // ④ closeExpiredQrOrders 首次真正生效(qrclose 关单)。 // 【与 createBarcodeOrder 的区别】被扫写 SUCCESS 是对的:被扫是用户出示码、我们当场提交, // 上游同步返回的就是真实付款结果 → 可写终态。 applyPayResult(order, "PAYING", payPatch); return toResult(order, "下单成功"); } if (parser.isPayFail(resp)) { applyPayResult(order, "FAIL", payPatch(resp, null)); return toResult(order, "下单失败"); } // ★ 未知 result_code:**不落终态 FAIL**。 // 我们并不确定上游到底有没有建出这笔单,而 FAIL 是终态、不可逆。 // // 留在 CREATED 靠什么收尾(说准确,别想当然): // - **主力是回调**:订单状态写入的条件更新放行 CREATED/PAYING(LsyOrderStatus.PAY_WRITE_FROM), // 所以一条迟到的支付回调仍然能把这一单推进到 SUCCESS。落 FAIL 就没这条路了。 // - **兜底是绝对过期**:LsyReconcileScheduler.expireStaleOrders 会把超过 // absoluteExpireHours 仍未终态的 CREATED/PAYING 强制收敛为 EXPIRED。 // - **注意:reconcilePendingOrders 捞不到它** —— 那个查询过滤 `status = 'PAYING'`, // 而这里留下的状态是 CREATED。所以这一单不靠「下一轮对账主动问上游」, // 而是靠回调 + 绝对过期两条路收尾。不要把它写成会被对账轮询。 // // 对调用方仍回「下单失败」—— 我们确实没能给出可用的 code_url,不能骗调用方说成功了。 // 局部的措辞不一致(调用方看到失败、库里还没终态)是有意为之:**外部看到的确定性 // 优先于内部记账的整洁**,而内部那笔账由回调/绝对过期收尾。 log.error("乐收赢聚合码下单返回未知 result_code,不落终态待对账: outTradeNo={}, " + "return_code={}, result_code={}, return_msg={}", order.getOutTradeNo(), resp.get("return_code"), resp.get("result_code"), resp.get("return_msg")); return toResult(order, "下单失败"); } ``` > **语义说明**:主扫(聚合码)下单成功后订单状态置 **`PAYING`(非终态)**,**不是 `SUCCESS`**。 > 判据只有一条:**这条路径发起时,用户有没有可能已经把款付完?** > 主扫是用户拿码去扫 —— 下单这一刻码还没交到用户手上,付款不可能发生,所以只能落非终态。 > 写 `SUCCESS` 会让真实支付回调被条件更新吞掉、下游永远收不到通知 > (完整推演见上面 `createQrOrder` 成功分支的裁定注释)。 > > `toResult` 返回的 `message` 仍用"下单成功"(`status` 取自订单,自然是 `PAYING`); > 下游 `order/query` 以库中状态为准,用户付款前 `receiptAmount` 为 **null**。 > > **这条语义必须对下游可见 —— 落点是 Task 15 的 `LsyPayResultVO` javadoc(已写明 > 「主扫与 H5 的**下单**成功落 `PAYING`(不是 SUCCESS)—— 下单成功 ≠ 收款成功」), > 以及 Task 15 各端点方法的 javadoc。** > 本计划**不产出**独立的「下游接入文档」文件(18 个任务的 Files 段里没有任何 `.md` 交付物), > 所以不要把这句话读成一个待建文档 —— 语义靠代码内的 javadoc 承载, > 对接方要的说明由交付时另行提供。 - [ ] **Step 4: 运行测试确认全绿** ```bash cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LsyQrPayTest ``` Expected: `Tests run: 7, Failures: 0, Errors: 0` (含 1 条 `qr_unknownResultCode_doesNotWriteTerminalFail` —— 它守的是「未知码不得落终态」) - [ ] **Step 5: 确认工作区状态(不提交)** ```bash git status --short ``` --- ## Task 8: H5 下单(h5pay) `pay_type` **必填**(300/400),与聚合码相反;会返回 `PAY_IN_PROCESS`,但用户被跳转到支付 App,**不做同步轮询**。 **Files:** - Modify: `java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyOrderService.java`(追加 `createH5Order`) - Test: `java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyH5PayTest.java` **Interfaces:** - Consumes: `LeshouyingClient.post`, `LsyOrderService.settleAfterPay` - Produces: `LsyPayResultDTO LsyOrderService.createH5Order(LsyOrderEntity order, String payType)` - [ ] **Step 1: 写失败的测试** ```java package com.payment.platform.module.payment.leshouying.service; 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.LsyPayResultDTO; import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity; import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import java.util.LinkedHashMap; import java.util.Map; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class LsyH5PayTest { @Mock private LeshouyingClient client; @Mock private LsyOrderMapper orderMapper; @Mock private LsyQueryService queryService; private LsyOrderService orderService; @BeforeEach void setUp() { LeshouyingProperties props = new LeshouyingProperties(); props.setSyncPollTimes(3); props.setSyncPollIntervalMs(0); // 构造器签名与 Task 5 一致(5 参) orderService = new LsyOrderService(orderMapper, client, new LeshouyingResponseParser(), props, queryService); } private LsyOrderEntity order() { LsyOrderEntity o = new LsyOrderEntity(); o.setId(3L); o.setOutTradeNo("OUT003"); o.setMchNo("Y52211090004"); o.setPayScene("H5"); o.setPayTraceNo("PT003"); o.setPayTime("20260921120000"); o.setTotalAmount(200L); o.setStatus("CREATED"); return o; } private Map resp(String rc) { Map m = new LinkedHashMap<>(); m.put("return_code", "SUCCESS"); m.put("result_code", rc); m.put("code_url", "https://pay.congmingpay.com/pay/scene/h5tominiapp_new.html?order_id=x"); return m; } @Test void h5_payTypeIsRequired() { assertThrows(IllegalArgumentException.class, () -> orderService.createH5Order(order(), null)); assertThrows(IllegalArgumentException.class, () -> orderService.createH5Order(order(), "000"), "H5 不接受自动识别,必须明确 300/400"); } @Test void h5_success_returnsCodeUrl_immediately() { when(client.post(eq("/v3/h5pay"), anyMap())).thenReturn(resp("PAY_SUCCESS")); when(orderMapper.update(any(), any())).thenReturn(1); LsyPayResultDTO r = orderService.createH5Order(order(), "400"); // ★ 同主扫:H5 下单成功落 PAYING(用户还没被跳转,付款还没发生)。 assertEquals("PAYING", r.getStatus()); assertNotNull(r.getCodeUrl()); } @Test void h5_payInProcess_doesNotSyncPoll() { when(client.post(eq("/v3/h5pay"), anyMap())).thenReturn(resp("PAY_IN_PROCESS")); when(orderMapper.update(any(), any())).thenReturn(1); LsyPayResultDTO r = orderService.createH5Order(order(), "400"); assertEquals("PAYING", r.getStatus()); assertNotNull(r.getCodeUrl(), "支付中也必须把 code_url 给出去,否则用户无法跳转"); verify(queryService, never()).queryOrder(any()); } @Test void h5_sendsRequiredPayType() { when(client.post(eq("/v3/h5pay"), anyMap())).thenReturn(resp("PAY_SUCCESS")); when(orderMapper.update(any(), any())).thenReturn(1); orderService.createH5Order(order(), "300"); var captor = org.mockito.ArgumentCaptor.forClass(Map.class); verify(client).post(eq("/v3/h5pay"), captor.capture()); assertEquals("300", captor.getValue().get("pay_type")); // 不加 String.valueOf:那会抹掉类型,塞 Integer 也照样绿 @SuppressWarnings("unchecked") Map sent = captor.getValue(); assertEquals("200", sent.get("total_amount"), "金额必须是「分」的字符串,不是 Long"); assertEquals("20260921120000", sent.get("pay_time"), "必须是 14 位 yyyyMMddHHmmss 串,不是时间对象"); } @Test void h5_unknownResultCode_doesNotWriteTerminalFail() { // H5 的未知码比聚合码更危险:用户可能**已经**被跳到支付页并完成了付款, // 落终态 FAIL 会让这笔真实支付无处落地,且退出对账集合、不再被纠正。 when(client.post(eq("/v3/h5pay"), anyMap())).thenReturn(resp("SUCCESS")); // 不 stub orderMapper.update:STRICT_STUBS 下未调用的 stub 会报 UnnecessaryStubbingException, // 而「一次都没被调用」正是本用例要断言的事实。 LsyPayResultDTO r = orderService.createH5Order(order(), "300"); assertEquals("下单失败", r.getMessage()); // 对调用方不撒谎 verify(orderMapper, never()).update(any(), any()); // 但库里不得落终态 } @Test void h5_payFail_writesFail() { // 与上一条恰好相反:上游**明确**说这笔单没建成(PAY_FAIL),此时落终态是安全的 —— // 没有可用的 code_url 交给用户,用户不可能已经付款,不存在「真实支付无处落地」的风险。 // 同门 `barcode_payFail_returnsFail` / `qr_payFail_returnsFail` 都已有这条断言, // H5 是三种下单场景里**唯一漏掉 PAY_FAIL 终态断言**的 //(H5 的 `applyPayResult(order, "FAIL", ...)` 在改动前全仓库零覆盖)。 when(client.post(eq("/v3/h5pay"), anyMap())).thenReturn(resp("PAY_FAIL")); when(orderMapper.update(any(), any())).thenReturn(1); LsyPayResultDTO r = orderService.createH5Order(order(), "400"); assertEquals("FAIL", r.getStatus()); } } ``` - [ ] **Step 2: 运行测试确认失败** ```bash cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LsyH5PayTest ``` Expected: 编译失败。 - [ ] **Step 3: 实现 createH5Order** 追加到 `LsyOrderService`: ```java /** * H5 下单(移动端浏览器唤起微信/支付宝)。 * * 与聚合码的关键差异: * 1. pay_type 必填(300 支付宝 / 400 微信),不支持自动识别(与聚合码相反) * —— 上游 H5 还接受 600 云闪付 / 900 龙支付,但本模块**按 spec §2 不接入** * (spec「不做」表:龙支付 / 云闪付「无场景」),故 `ofRequired` 会拒绝这两个码。 * 这是**故意的**,不是漏配:见到 `不支持的支付类型: 600` 不要当成 bug 去补枚举。 * 2. 会返回 PAY_IN_PROCESS * 3. 但用户会被跳转到支付 App,停留时长不可控 → 不做同步轮询 * * 上游「H5支付」目录下无独立关单接口,本类不为 H5 提供关单路径(见 spec §2.3)。 */ public LsyPayResultDTO createH5Order(LsyOrderEntity order, String payType) { LsyPayType type = LsyPayType.ofRequired(payType); // AUTO 会被拒绝 Map p = new LinkedHashMap<>(); p.put("mch_no", order.getMchNo()); if (order.getShopNo() != null) { p.put("shop_no", order.getShopNo()); } p.put("pay_type", type.getCode()); p.put("pay_trace_no", order.getPayTraceNo()); p.put("pay_time", order.getPayTime()); p.put("total_amount", String.valueOf(order.getTotalAmount())); if (order.getOrderBody() != null) { p.put("order_body", order.getOrderBody()); } if (order.getAttach() != null) { p.put("attach", order.getAttach()); } if (order.getNotifyUrl() != null) { p.put("notify_url", order.getNotifyUrl()); } Map resp = client.post("/v3/h5pay", p); if (parser.isPaySuccess(resp)) { stageCodeUrl(order, resp, type.getCode()); // ★ 同主扫:**下单路径**一律传 null。H5 这一单此刻用户连支付页都还没跳过去, // 上游没回 receipt_amount 时兜底成订单总额,就是把「一分钱没付」写成「实收全额」。 LsyOrderEntity patch = payPatch(resp, null); patch.setCodeUrl(order.getCodeUrl()); // ★ 同主扫:落 PAYING。H5 的下单成功只表示「上游受理了这次下单请求」, // 用户此刻还没被跳转到支付页 —— 与 isPayInProcess 分支落同一种状态才自洽。 applyPayResult(order, "PAYING", patch); return toResult(order, "下单成功"); } if (parser.isPayInProcess(resp)) { // 支付中也必须回传 code_url —— 用户还没跳转,不给链接就等于下单失败 stageCodeUrl(order, resp, type.getCode()); LsyOrderEntity patch = payPatch(resp, null); patch.setCodeUrl(order.getCodeUrl()); applyPayResult(order, "PAYING", patch); return toResult(order, "支付中"); } if (!parser.isPayFail(resp)) { // ★ 未知 result_code —— 同 createQrOrder:不落终态 FAIL,靠**回调**或 // **absoluteExpireHours 绝对过期**收尾(不是靠对账轮询,那个查询只捞 PAYING)。 // H5 尤其危险:用户可能已经被跳到支付页了,落 FAIL 会让一笔真实支付无处落地。 log.error("乐收赢 H5 下单返回未知 result_code,不落终态待对账: outTradeNo={}, " + "return_code={}, result_code={}, return_msg={}", order.getOutTradeNo(), resp.get("return_code"), resp.get("result_code"), resp.get("return_msg")); return toResult(order, "下单失败"); } applyPayResult(order, "FAIL", payPatch(resp, null)); return toResult(order, "下单失败"); } /** 把 code_url / pay_type 落库并同步内存对象。H5 两个分支共用。 */ private void stageCodeUrl(LsyOrderEntity order, Map resp, String payType) { String codeUrl = str(resp.get("code_url")); LsyOrderEntity patch = new LsyOrderEntity(); patch.setCodeUrl(codeUrl); patch.setPayType(payType); patch.setUpdatedTime(OffsetDateTime.now()); orderMapper.update(patch, new UpdateWrapper() .eq("id", order.getId())); order.setCodeUrl(codeUrl); order.setPayType(payType); } ``` - [ ] **Step 4: 运行测试确认全绿** ```bash cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LsyH5PayTest ``` Expected: `Tests run: 6, Failures: 0, Errors: 0` (含 `h5_unknownResultCode_doesNotWriteTerminalFail` 与 `h5_payFail_writesFail` —— 这两条**必须成对**: 前者证明未知码**不**写终态,后者证明明确失败**要**写终态。只留任一条,另一条都挡不住 「两个分支写反了」这种改动。) - [ ] **Step 5: 跑一遍本模块全部测试** ```bash cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest='Lsy*Test' ``` - [ ] **Step 6: 确认工作区状态(不提交)** ```bash git status --short ``` --- ## Task 9: 撤销 / 关单(先查后撤) **本任务的核心是规避 spec §2.4 的陷阱**:`cancelorder` 对已支付的单会退款给用户,但响应无法区分"关单"与"退款"。 **Files:** - Create: `java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyCancelService.java` - Test: `java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyCancelServiceTest.java` **Interfaces:** - Consumes: `LsyQueryService.queryOrder`, `LeshouyingClient.post`, `LeshouyingResponseParser.isPaySuccess/isPayFail/isPayInProcess` - 前置查询的作用是**判断能不能安全撤销**,所以三个谓词都要用: 已支付 → 转退款;支付中 → **拒绝撤销**;未知码 → **拒绝撤销**;只有显式 `PAY_FAIL` 才继续走撤销。 撤销是不可逆资金动作,失败方向朝「不执行」倾斜。 - Produces: - `String LsyCancelService.closeOrder(LsyOrderEntity order)` —— 返回目标状态字符串(`CANCELED` / `CLOSED` / `EXPIRED` / `SUCCESS`) - `Long LsyCancelService.cancelTraceNo()` / 无 - [ ] **Step 1: 写失败的测试** ```java package com.payment.platform.module.payment.leshouying.service; 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.entity.LsyOrderEntity; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import java.util.LinkedHashMap; import java.util.Map; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class LsyCancelServiceTest { @Mock private LeshouyingClient client; @Mock private LsyQueryService queryService; private LsyCancelService service; @BeforeEach void setUp() { service = new LsyCancelService(client, queryService, new LeshouyingResponseParser()); } private LsyOrderEntity barcode() { LsyOrderEntity o = new LsyOrderEntity(); o.setId(1L); o.setMchNo("Y52211090004"); o.setPayScene("BARCODE"); o.setPayTraceNo("PT001"); o.setPayTime("20260921120000"); o.setStatus("PAYING"); return o; } private LsyOrderEntity qrcode() { LsyOrderEntity o = new LsyOrderEntity(); o.setId(2L); o.setMchNo("Y52211090004"); o.setPayScene("QRCODE"); o.setCodeUrl("https://open.gdyfsk.com/yfpay/s/abc"); o.setStatus("PAYING"); return o; } private Map resp(String resultCode) { Map m = new LinkedHashMap<>(); m.put("return_code", "SUCCESS"); m.put("result_code", resultCode); return m; } @Test void barcode_alreadyPaid_skipsCancelOrder_returnsSuccess() { // 查到已支付 → 不调 cancelorder,交由退款流程处理 when(queryService.queryOrder(any())).thenReturn(resp("PAY_SUCCESS")); String target = service.closeOrder(barcode()); assertEquals("SUCCESS", target, "已支付的单不能记成关单,必须留给退款流程"); verify(client, never()).post(eq("/v3/cancelorder"), anyMap()); } @Test void barcode_notPaid_callsCancelOrder_returnsCanceled() { // ★ 查询桩必须是 `PAY_FAIL`,**不能是** `PAY_IN_PROCESS`。 // 本用例的名字(notPaid)说明它想表达的是「查到没付款 → 走撤销」, // 而「没付款」在 `cancelBarcodeOrder` 里的判据是 `isPayFail` 为真 —— 只有显式 PAY_FAIL。 // `PAY_IN_PROCESS` 走的是上面那条「支付中 → 拒绝撤销」分支,压根到不了 cancelorder。 // (历史:本用例的桩是 `PAY_IN_PROCESS`,那是「不是已支付就直接撤」那版写法的遗留; // 代码改成三谓词分派之后没同步,于是它与 `barcode_payInProcess_refusesToCancel` // 成了**同样输入、相反断言**的一对 —— 必有一条红。) when(queryService.queryOrder(any())).thenReturn(resp("PAY_FAIL")); when(client.post(eq("/v3/cancelorder"), anyMap())).thenReturn(resp("PAY_SUCCESS")); String target = service.closeOrder(barcode()); assertEquals("CANCELED", target); } @Test void barcode_cancelFails_returnsNull_meaningDoNotChangeStatus() { // ★ 同上:桩必须是 `PAY_FAIL`,否则走不到 cancelorder。 // 桩成 `PAY_IN_PROCESS` 时 `assertNull` 会**因为错误的原因通过** —— 断言绿了, // 但 `when(client.post(...))` 一次都没被调用,strict stubs 下这一整个用例报 // `UnnecessaryStubbingException`;即便关掉 strictness,它也变成一条什么都没验的测试。 when(queryService.queryOrder(any())).thenReturn(resp("PAY_FAIL")); when(client.post(eq("/v3/cancelorder"), anyMap())).thenReturn(resp("PAY_FAIL")); String target = service.closeOrder(barcode()); assertNull(target, "撤销失败时不应改状态,留给下次重试"); } @Test void barcode_queryFails_doesNotCallCancel() { when(queryService.queryOrder(any())) .thenThrow(new RuntimeException("网络超时")); assertThrows(RuntimeException.class, () -> service.closeOrder(barcode())); verify(client, never()).post(eq("/v3/cancelorder"), anyMap()); } @Test void barcode_payInProcess_refusesToCancel() { // 用户可能正在输密码。撤销是**不可逆**的资金动作, // 「这次没撤成让运维重试」远轻于「撤掉一笔正在扣款的单」。 when(queryService.queryOrder(any())).thenReturn(resp("PAY_IN_PROCESS")); assertNull(service.closeOrder(barcode()), "支付中不得撤销"); verify(client, never()).post(eq("/v3/cancelorder"), anyMap()); } @Test void barcode_unknownResultCode_refusesToCancel() { // ★ 原写法是「不是已支付 → 直接撤」,未知码会一路走到撤销。 // 而我们恰恰**不知道这笔付没付** —— 猜错的方向是不可逆的,所以要朝不执行的方向失败。 when(queryService.queryOrder(any())).thenReturn(resp("SUCCESS")); assertNull(service.closeOrder(barcode()), "未知码下不知付没付,拒绝撤销"); verify(client, never()).post(eq("/v3/cancelorder"), anyMap()); } @Test void barcode_docSpellingPaySuceess_isNotAcceptedAsSuccess() { // ★ 这条钉的是一个**决定**,不是一段逻辑。 // 上游文档里 `/v3/cancelorder` 与 `/v3/closeorder` 的**正文**把成功码写成 `PAY_SUCEESS` // (少一个 S),而同一份文档在 `/v3/trade/*` 的示例里用小写 `success`、 // 在别处用 `PAY_SUCCESS` —— 文档**没有**解决这个拼写冲突,我们无法从文档判定 // `PAY_SUCEESS` 是笔误还是真实返回值。 // // 决定:**不猜**,不把它加进 parser 的词表。它按未知码处理 ⇒ 撤销未确认 ⇒ 不改状态。 // 为什么这样选:两种误判的代价不对称 —— 猜它是真实值,会让一个可疑拼写进入正常路径 // (若它其实是笔误,我们就永远看不到「真的没撤成」);猜它是笔误,代价只是撤销多走人工核对, // 而**钱没动**。撤销/关单是**不可逆**动作,不可逆那一侧要更保守。 // // 代价明说:若上游真回这个拼写,每笔撤销都会被我们记成「没撤成」。所以 // `cancelBarcodeOrder` / `closeQrOrder` 的失败日志里**带上了 result_code** —— // 联调第一笔撤销就能一眼看出是不是这个拼写。那之后再改,是**有意**地改。 when(queryService.queryOrder(any())).thenReturn(resp("PAY_FAIL")); when(client.post(eq("/v3/cancelorder"), anyMap())).thenReturn(resp("PAY_SUCEESS")); assertNull(service.closeOrder(barcode()), "PAY_SUCEESS 是未确认的文档拼写,不得当作撤销成功"); } @Test void qrcode_usesQrcloseWithCodeUrl_notTradeNo() { when(client.post(eq("/v3/qrclose"), anyMap())).thenReturn(resp("PAY_SUCCESS")); String target = service.closeOrder(qrcode()); assertEquals("CLOSED", target); var captor = org.mockito.ArgumentCaptor.forClass(Map.class); verify(client).post(eq("/v3/qrclose"), captor.capture()); @SuppressWarnings("unchecked") Map sent = captor.getValue(); assertEquals("https://open.gdyfsk.com/yfpay/s/abc", sent.get("code_url")); assertFalse(sent.containsKey("trade_no"), "qrclose 入参是 code_url,不是 trade_no"); verify(client, never()).post(eq("/v3/closeorder"), anyMap()); } @Test void qrcode_neverQueriesFirst_noAmbiguityInQrclose() { when(client.post(eq("/v3/qrclose"), anyMap())).thenReturn(resp("PAY_SUCCESS")); service.closeOrder(qrcode()); verify(queryService, never()).queryOrder(any()); } @Test void h5_hasNoCloseInterface_returnsExpiredLocally() { LsyOrderEntity o = new LsyOrderEntity(); o.setId(3L); o.setPayScene("H5"); o.setStatus("PAYING"); String target = service.closeOrder(o); assertEquals("EXPIRED", target); verify(client, never()).post(anyString(), anyMap()); verify(queryService, never()).queryOrder(any()); } @Test void qrcode_missingCodeUrl_returnsExpiredWithoutCallingUpstream() { LsyOrderEntity o = qrcode(); o.setCodeUrl(null); assertEquals("EXPIRED", service.closeOrder(o)); verify(client, never()).post(eq("/v3/qrclose"), anyMap()); } } ``` - [ ] **Step 2: 运行测试确认失败** ```bash cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LsyCancelServiceTest ``` Expected: 编译失败。 - [ ] **Step 3: 实现 LsyCancelService** ```java package com.payment.platform.module.payment.leshouying.service; import cn.hutool.core.util.IdUtil; 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.entity.LsyOrderEntity; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.LinkedHashMap; import java.util.Map; /** * 撤销 / 关单。 * * 【核心设计:先查后撤】上游 cancelorder 对已支付的单会把资金退还给用户, * 但返回码只有 PAY_SUCCESS/PAY_FAIL,**无法区分"关单"还是"退款"**。 * 直接调会导致本地状态记错,因此: * 查到已支付 → 不调 cancelorder,返回 SUCCESS,交由退款流程处理 * 查到未支付 → 调 cancelorder 关单 → CANCELED * * 竞态兜底:若查询时还在支付中、调撤销的瞬间用户支付成功,上游会退款并发退款回调, * 届时由回调把状态从 CANCELED 更正为 REFUNDED。 */ @Slf4j @Service @RequiredArgsConstructor public class LsyCancelService { private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); private final LeshouyingClient client; private final LsyQueryService queryService; private final LeshouyingResponseParser parser; /** * 关闭订单。 * @return 目标状态;返回 null 表示"本次不改状态"(上游拒绝,留给下次重试) */ public String closeOrder(LsyOrderEntity order) { String scene = order.getPayScene(); if ("H5".equals(scene)) { // 上游 H5 目录下无独立关单接口,closeorder 正文也未提 H5。 // v1 只做本地收敛,不动上游(见 spec §2.3 / §14 待确认项)。 log.info("H5 订单无已知关单接口,本地收敛为 EXPIRED: outTradeNo={}", order.getOutTradeNo()); return "EXPIRED"; } if ("QRCODE".equals(scene)) { return closeQrOrder(order); } return cancelBarcodeOrder(order); } /** 聚合码关单:入参是 code_url,不是 trade_no */ private String closeQrOrder(LsyOrderEntity order) { if (order.getCodeUrl() == null || order.getCodeUrl().isBlank()) { log.warn("聚合码订单缺少 code_url,无法关单,本地收敛: outTradeNo={}", order.getOutTradeNo()); return "EXPIRED"; } Map p = new LinkedHashMap<>(); p.put("mch_no", order.getMchNo()); if (order.getShopNo() != null) { p.put("shop_no", order.getShopNo()); } p.put("code_url", order.getCodeUrl()); Map resp = client.post("/v3/qrclose", p); if (parser.isPaySuccess(resp)) { return "CLOSED"; } // ★ 必须把 result_code 一起打出来,不能只打 return_msg。 // 上游文档里 `/v3/qrclose`(以及 closeorder / cancelorder)**正文把成功写成 `PAY_SUCEESS`** // —— 少一个 S。若那不是笔误而是真实返回值,`isPaySuccess` 对它为 false, // 于是关单**实际上成功了**,我们却在这里记一条「失败」, // 而 `return_msg` 大概率是「成功」二字 —— 运维看到「关单失败: msg=成功」将无从判断。 // 带上 result_code,这一条日志自己就回答了「是不是那个拼写」。 // ★ 不要为了这个去给 parser 加 `PAY_SUCEESS`:我们无法判定它是笔误还是真实值, // 而两种误判不对称 —— 猜错成真实值会让一个可疑拼写进入正常路径; // 猜成笔误只是让关单多走人工核对。**留到联调第一笔关单看日志定案。** log.warn("聚合码关单失败: outTradeNo={}, result_code={}, return_msg={}", order.getOutTradeNo(), resp.get("result_code"), resp.get("return_msg")); return null; } /** 被扫撤销:先查后撤 */ private String cancelBarcodeOrder(LsyOrderEntity order) { Map q = queryService.queryOrder(order); if (parser.isPaySuccess(q)) { // 已支付 → 撤销会变成退款,语义完全不同,不在本方法处理 log.info("撤销前查询发现已支付,转退款流程: outTradeNo={}", order.getOutTradeNo()); return "SUCCESS"; } // ★ 撤销是**资金动作**,所以这里的失败方向要选对: // 原写法是「不是已支付 → 直接撤」,于是 `PAY_IN_PROCESS`(用户正在输密码)和 // 未知 result_code(我们根本不知道付没付)都会一路走到撤销。 // 撤掉一笔已付款的单,比「这次没撤成、让运维重试一次」严重得多 —— // 所以这两种情况都**拒绝撤销**,朝不执行的方向失败。 // 查不到/撤销被拒都是**可重试**的;撤销成功是**不可逆**的。不可逆的那一侧要更保守。 if (parser.isPayInProcess(q)) { log.warn("撤销前查询显示支付中,暂不撤销(可能正在扣款): outTradeNo={}", order.getOutTradeNo()); return null; } if (!parser.isPayFail(q)) { log.error("撤销前查询返回未知 result_code,不知付没付,拒绝撤销待人工核对: outTradeNo={}, " + "return_code={}, result_code={}", order.getOutTradeNo(), q.get("return_code"), q.get("result_code")); return null; } Map p = new LinkedHashMap<>(); p.put("mch_no", order.getMchNo()); p.put("cancel_trace_no", IdUtil.fastSimpleUUID()); p.put("cancel_time", LocalDateTime.now().format(FMT)); if (order.getTradeNo() != null) { p.put("trade_no", order.getTradeNo()); } else { p.put("pay_trace_no", order.getPayTraceNo()); p.put("pay_time", order.getPayTime()); } Map resp = client.post("/v3/cancelorder", p); if (parser.isPaySuccess(resp)) { return "CANCELED"; } // ★ 同 closeQrOrder:`/v3/cancelorder` 的文档正文把成功写成 `PAY_SUCEESS`(少一个 S)。 // 不带 result_code 的话,「撤销实际成功、我们记成失败」将表现为 // 「撤销失败: outTradeNo=..., msg=成功」—— 一句自相矛盾、无法定位的日志。 log.warn("撤销失败: outTradeNo={}, result_code={}, return_msg={}", order.getOutTradeNo(), resp.get("result_code"), resp.get("return_msg")); return null; } } ``` - [ ] **Step 4: 运行测试确认全绿** ```bash cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LsyCancelServiceTest ``` Expected: `Tests run: 11, Failures: 0, Errors: 0` (含 `barcode_payInProcess_refusesToCancel`、`barcode_unknownResultCode_refusesToCancel`、 `barcode_docSpellingPaySuceess_isNotAcceptedAsSuccess` 三条「拒绝撤销」用例 —— 它们守的是**不可逆**动作的失败方向:撤销/关单一旦执行就无法回退, 所以在「不知道能不能撤」时必须朝**不执行**的方向失败。 ★ `barcode_cancelFails_returnsNull_meaningDoNotChangeStatus` 与 `barcode_notPaid_callsCancelOrder_returnsCanceled` 的查询桩必须都是 `resp("PAY_FAIL")`: 只有显式 `PAY_FAIL` 才代表「查到没付款」。这两个桩原先都是 `PAY_IN_PROCESS`, 与 `barcode_payInProcess_refusesToCancel` 构成**同样输入、相反断言**的一对,必有一条红。) - [ ] **Step 5: 确认工作区状态(不提交)** ```bash git status --short ``` --- ## Task 10: 退款与退款查询 **字段语义陷阱**:上游退款接口的 `pay_time` 是**退款请求时间**,原支付时间是 `pay_trace_time`。 > ### ★ 本任务的真正难点不是字段映射,是**防重复退款** > > 可退余额的唯一定义是 `remaining = total_amount - refunded_amount`,而 `refunded_amount` > **只在退款成功后才累加**。这中间有一个必然存在的窗口,窗口里的 `remaining` 是虚高的。 > 一笔 100 元的单,第一笔 100 元退款还在途时,第二笔 100 元退款从余额校验看**完全合法**。 > 上游会真扣两次钱,我们这边只记一笔。这不是"极端情况",是默认路径上的洞。 > > 三道锁,缺一不可,改任何一处前先读全三条: > > | # | 锁 | 挡的是 | > |---|---|---| > | 1 | `uk_lsy_refund_one_pending` 部分唯一索引(Task 3 DDL) | **并发**双发:两请求同时通过应用层检查时,第二条 insert 直接违反约束 | > | 2 | `createRefund` 的在途前置校验(读 `LsyRefundStatus.PENDING`) | **串行**双发:上一笔还在 REFUNDING 时再来一笔,给出人话错误 | > | 3 | 同步成功立即收敛订单侧(`settleOrderAfterRefund`) | **事后**双发:不收敛则 `refunded_amount` 永远不涨,锁 1/2 也拦不住下一轮 | > > 锁 1 与锁 2 都依赖"在途"的定义一致,而索引里的状态集是 **SQL 字面量**、 > 应用侧读的是 `LsyRefundStatus.PENDING` —— 改一处必须改两处。 > 锁 3 为什么必须存在,见 `createRefund` 里那段 ★ 注释,一句话: > 同步成功的退款行直接是终态,回调到达时会被当成重复回调丢弃, > 于是订单侧的累加**永远不会发生**。 > > 已知残留(不修,记录在案):应用层的读-判-写不是原子的,锁 2 单独用挡不住并发; > 兜底靠锁 1。若将来去掉锁 1,锁 2 必须改成真正的行锁或 `SELECT ... FOR UPDATE`。 **Files:** - Create: `java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyRefundService.java` - Test: `java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyRefundServiceTest.java` **Interfaces:** - Consumes: `LeshouyingClient.post`, `LeshouyingResponseParser`, `LsyRefundMapper`(含 `selectList` / `insert` / `update`), `LsyRefundStatus.PENDING`, `LsyOrderService.applyRefundResult` - Produces: - `LsyRefundEntity LsyRefundService.createRefund(LsyOrderEntity order, String outRefundNo, long refundFee)` —— **同步成功时会连同订单侧一起收敛**,调用方不要再自己累加 `refundedAmount` - `Map LsyRefundService.queryRefund(LsyRefundEntity refund)` - **`boolean LsyRefundService.settleOrderAfterRefund(LsyOrderEntity order, long refundAmount)`** —— 退款成功后订单侧的**唯一**收敛入口(累加 + 推导 REFUNDED/PARTIAL_REFUNDED)。Task 12 也调它, 不要在 `handleRefundNotify` 里另算一份状态。 - **`boolean LsyRefundService.applyNotifyResult(LsyRefundEntity refund, String resultCode, String resultMsg, String newStatus)`** —— 退款行的**唯一状态写入口**,条件更新受影响行数 = 1 才返回 `true`。这是 Task 12 的幂等闸门, 没有它重复回调会把 `refunded_amount` 重复累加。 - **`LsyRefundEntity LsyRefundService.requireByOutRefundNo(Long tenantId, String outRefundNo)`** —— **Task 15 Step 5 才追加**(不在本任务写)。下游 OpenAPI 专用,首参必须带 `tenantId`。 - 构造器(4 参):`LsyRefundService(LeshouyingClient, LeshouyingResponseParser, LsyRefundMapper, LsyOrderService)` - [ ] **Step 1: 写失败的测试** ```java package com.payment.platform.module.payment.leshouying.service; 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.entity.LsyOrderEntity; import com.payment.platform.module.payment.leshouying.entity.LsyRefundEntity; import com.payment.platform.module.payment.leshouying.mapper.LsyRefundMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class LsyRefundServiceTest { @Mock private LeshouyingClient client; @Mock private LsyRefundMapper refundMapper; @Mock private LsyOrderService orderService; private LsyRefundService service; @BeforeEach void setUp() { service = new LsyRefundService(client, new LeshouyingResponseParser(), refundMapper, orderService); } private LsyOrderEntity order() { LsyOrderEntity o = new LsyOrderEntity(); o.setId(1L); o.setOutTradeNo("OUT001"); o.setMchNo("Y52211090004"); o.setPayTraceNo("PT001"); o.setPayTime("20260921120000"); o.setTradeNo("T001"); o.setTotalAmount(100L); o.setRefundedAmount(0L); o.setStatus("SUCCESS"); return o; } private Map resp(String rc) { Map m = new LinkedHashMap<>(); m.put("return_code", "SUCCESS"); m.put("result_code", rc); m.put("refund_no", "R001"); m.put("refund_settle", "100"); return m; } @Test void refund_rejectsAmountExceedingRemaining() { LsyOrderEntity o = order(); o.setRefundedAmount(80L); // 剩余 20 assertThrows(IllegalArgumentException.class, () -> service.createRefund(o, "RF001", 30L)); verify(client, never()).post(anyString(), anyMap()); } @Test void refund_rejectsNonPositiveAmount() { assertThrows(IllegalArgumentException.class, () -> service.createRefund(order(), "RF001", 0L)); } @Test void refund_rejectsOrderNotInSuccessState() { LsyOrderEntity o = order(); o.setStatus("PAYING"); assertThrows(IllegalStateException.class, () -> service.createRefund(o, "RF001", 10L)); } @Test void refund_mapsTimeFieldsCorrectly_notConfused() { when(client.post(eq("/v3/refundorder"), anyMap())).thenReturn(resp("PAY_SUCCESS")); when(refundMapper.insert(any())).thenReturn(1); when(refundMapper.update(any(), any())).thenReturn(1); when(orderService.applyRefundResult(any(), anyLong(), any())).thenReturn(true); service.createRefund(order(), "RF001", 100L); var captor = org.mockito.ArgumentCaptor.forClass(Map.class); verify(client).post(eq("/v3/refundorder"), captor.capture()); @SuppressWarnings("unchecked") Map sent = captor.getValue(); assertEquals("100", sent.get("refund_fee")); // 上游 pay_time = 退款请求时间(本次),pay_trace_time = 原支付时间 assertNotNull(sent.get("pay_time")); assertEquals("20260921120000", sent.get("pay_trace_time"), "pay_trace_time 必须传原支付时间,传反会导致上游找不到原单"); } @Test void refund_success_statusIsSuccess() { when(client.post(eq("/v3/refundorder"), anyMap())).thenReturn(resp("PAY_SUCCESS")); when(refundMapper.insert(any())).thenReturn(1); when(refundMapper.update(any(), any())).thenReturn(1); when(orderService.applyRefundResult(any(), anyLong(), any())).thenReturn(true); LsyRefundEntity r = service.createRefund(order(), "RF001", 100L); assertEquals("SUCCESS", r.getStatus()); assertEquals("R001", r.getRefundNo()); } @Test void refund_insertsAsRefundingFirst_neverDirectlyTerminal() { // 必须先进 REFUNDING 再收敛:insert 成 SUCCESS 会让本行落在 // uk_lsy_refund_one_pending 的谓词之外,并发第二笔退款撞不上约束。 // 这个断言就是在钉住"互斥窗口必须真的存在"。 when(client.post(eq("/v3/refundorder"), anyMap())).thenReturn(resp("PAY_SUCCESS")); when(refundMapper.insert(any())).thenReturn(1); when(refundMapper.update(any(), any())).thenReturn(1); when(orderService.applyRefundResult(any(), anyLong(), any())).thenReturn(true); service.createRefund(order(), "RF001", 100L); var captor = org.mockito.ArgumentCaptor.forClass(LsyRefundEntity.class); verify(refundMapper).insert(captor.capture()); assertEquals("REFUNDING", captor.getValue().getStatus(), "插入时必须是非终态,否则部分唯一索引护不住并发窗口"); } @Test void refund_syncSuccess_settlesOrderSideImmediately() { // ★ 这条是防"钱退了两遍"的核心回归。 // 同步成功若不在本地就累加 refunded_amount,回调到达时本行已是 SUCCESS、 // 不在 PAY_WRITE_FROM 里 → applyNotifyResult 命中 0 行 → handleRefundNotify // 判为重复回调 → refunded_amount 永不累加 → remaining 永远虚高 → 可以再退一次。 when(client.post(eq("/v3/refundorder"), anyMap())).thenReturn(resp("PAY_SUCCESS")); when(refundMapper.insert(any())).thenReturn(1); when(refundMapper.update(any(), any())).thenReturn(1); when(orderService.applyRefundResult(any(), anyLong(), any())).thenReturn(true); service.createRefund(order(), "RF001", 100L); verify(orderService).applyRefundResult(any(), eq(100L), eq("REFUNDED")); } @Test void refund_rejectsWhenAnotherRefundIsPending() { // 在途退款挡住新退款:refunded_amount 只在退款成功后才累加, // 所以在途期间 remaining 是虚高的,金额校验挡不住重复退款。 LsyRefundEntity inFlight = new LsyRefundEntity(); inFlight.setId(9L); inFlight.setStatus("REFUNDING"); when(refundMapper.selectList(any())).thenReturn(List.of(inFlight)); IllegalStateException ex = assertThrows(IllegalStateException.class, () -> service.createRefund(order(), "RF002", 100L)); assertTrue(ex.getMessage().contains("正在处理中")); verify(client, never()).post(anyString(), anyMap()); } @Test void refund_pendingGuard_passesWhenPreviousRefundIsTerminal() { // 对照组:上一笔已终态(SUCCESS/FAIL)不得挡路,否则退款只能退一次。 LsyRefundEntity settled = new LsyRefundEntity(); settled.setId(9L); settled.setStatus("SUCCESS"); when(refundMapper.selectList(any())).thenReturn(List.of(settled)); when(client.post(eq("/v3/refundorder"), anyMap())).thenReturn(resp("PAY_IN_PROCESS")); when(refundMapper.insert(any())).thenReturn(1); LsyRefundEntity r = service.createRefund(order(), "RF002", 30L); assertEquals("REFUNDING", r.getStatus()); } @Test void refund_payInProcess_statusIsRefunding() { when(client.post(eq("/v3/refundorder"), anyMap())).thenReturn(resp("PAY_IN_PROCESS")); when(refundMapper.insert(any())).thenReturn(1); LsyRefundEntity r = service.createRefund(order(), "RF001", 100L); assertEquals("REFUNDING", r.getStatus()); } @Test void refund_persistsEntityWithOutRefundNo() { when(client.post(eq("/v3/refundorder"), anyMap())).thenReturn(resp("PAY_SUCCESS")); when(refundMapper.insert(any())).thenReturn(1); when(refundMapper.update(any(), any())).thenReturn(1); when(orderService.applyRefundResult(any(), anyLong(), any())).thenReturn(true); service.createRefund(order(), "RF001", 50L); var captor = org.mockito.ArgumentCaptor.forClass(LsyRefundEntity.class); verify(refundMapper).insert(captor.capture()); LsyRefundEntity saved = captor.getValue(); assertEquals("RF001", saved.getOutRefundNo()); assertEquals("OUT001", saved.getOutTradeNo()); assertEquals(50L, saved.getRefundFee()); assertNotNull(saved.getRefundTraceNo()); } // ---- applyNotifyResult:退款行的幂等闸门(Task 12 依赖) ---- @Test void applyNotifyResult_transitionApplied_returnsTrue() { LsyRefundEntity refund = refund("REFUNDING"); when(refundMapper.update(any(), any())).thenReturn(1); assertTrue(service.applyNotifyResult(refund, "TRADE_REFUND", "成功", "SUCCESS")); assertEquals("SUCCESS", refund.getStatus()); } @Test void applyNotifyResult_duplicateCallback_returnsFalse() { // 同一笔退款的回调重复到达 → status 已不在 {CREATED, REFUNDING} → 命中 0 行 LsyRefundEntity refund = refund("SUCCESS"); when(refundMapper.update(any(), any())).thenReturn(0); assertFalse(service.applyNotifyResult(refund, "TRADE_REFUND", "成功", "SUCCESS"), "重复回调必须返回 false,否则订单侧会重复累加 refunded_amount"); } private LsyRefundEntity refund(String status) { LsyRefundEntity r = new LsyRefundEntity(); r.setId(9L); r.setOutRefundNo("RF001"); r.setRefundFee(30L); r.setStatus(status); return r; } } ``` - [ ] **Step 2: 运行测试确认失败** ```bash cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LsyRefundServiceTest ``` Expected: 编译失败。 - [ ] **Step 3: 实现 LsyRefundService** ```java package com.payment.platform.module.payment.leshouying.service; import cn.hutool.core.util.IdUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; 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.entity.LsyOrderEntity; import com.payment.platform.module.payment.leshouying.entity.LsyRefundEntity; import com.payment.platform.module.payment.leshouying.enums.LsyRefundStatus; import com.payment.platform.module.payment.leshouying.mapper.LsyRefundMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; /** * 退款与退款查询。 * * 【字段陷阱】上游退款接口的 pay_time 是"退款请求时间", * 原支付时间是 pay_trace_time。传反会导致上游找不到原单。 * 本类内部统一用 refundRequestTime / originalPayTime 命名,绝不混用。 * * 【为什么本类**不加** `@Transactional`】别"顺手补上",那会让情况更糟: * `createRefund` 中间有一次不可撤销的上游调用(`/v3/refundorder`,钱真的出去了), * 而本地写库是它的**后置记账**。若包在事务里,之后任何一步失败回滚, * 结果就是「上游钱已退、本地无记录」—— 记录消失比记录不准危险得多。 * 所以本地写入只做两件事:条件更新保证幂等,失败就 log.error 让人看见。 */ @Slf4j @Service @RequiredArgsConstructor public class LsyRefundService { private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); private static final Set REFUNDABLE = Set.of("SUCCESS", "PARTIAL_REFUNDED"); private final LeshouyingClient client; private final LeshouyingResponseParser parser; private final LsyRefundMapper refundMapper; /** * 只为退款成功后的订单侧收敛({@link #settleOrderAfterRefund})而依赖。 * 无循环依赖:LsyOrderService 不认识 LsyRefundService。 */ private final LsyOrderService orderService; /** * 申请退款。支持部分退款。 * @throws IllegalArgumentException 金额非法 * @throws IllegalStateException 订单状态不允许退款 */ public LsyRefundEntity createRefund(LsyOrderEntity order, String outRefundNo, long refundFee) { if (refundFee <= 0) { throw new IllegalArgumentException("退款金额必须大于 0"); } if (!REFUNDABLE.contains(order.getStatus())) { throw new IllegalStateException("订单当前状态不可退款: " + order.getStatus()); } // ★ 在途退款前置校验。为什么必须有: // remaining 来自 refunded_amount,而 refunded_amount 只在退款**成功后**才累加。 // 在途期间它还是旧值 → remaining 虚高 → 一笔在途 100 元的单还能再退 100 元。 // 下面的金额校验挡不住这一点,因为从它的输入看余额确实够。 // 用 selectList + LIMIT 1 而不是 selectCount:后者返 Long,Mockito 默认 null, // 测试里 unboxing 直接 NPE;selectList 默认空 List,没有这个坑。 List pending = refundMapper.selectList(new LambdaQueryWrapper() .eq(LsyRefundEntity::getOrderId, order.getId()) .in(LsyRefundEntity::getStatus, LsyRefundStatus.PENDING) .last("LIMIT 1")); if (!pending.isEmpty()) { throw new IllegalStateException( "该订单有退款正在处理中,请等结果返回后再发起: orderId=" + order.getId()); } long refunded = order.getRefundedAmount() == null ? 0L : order.getRefundedAmount(); long remaining = order.getTotalAmount() - refunded; if (refundFee > remaining) { throw new IllegalArgumentException( "退款金额超过可退余额: 申请 " + refundFee + ", 可退 " + remaining); } String refundTraceNo = IdUtil.fastSimpleUUID(); String requestTime = LocalDateTime.now().format(FMT); Map p = new LinkedHashMap<>(); p.put("mch_no", order.getMchNo()); if (order.getShopNo() != null) { p.put("shop_no", order.getShopNo()); } if (order.getTradeNo() != null) { p.put("trade_no", order.getTradeNo()); } else { p.put("pay_trace_no", order.getPayTraceNo()); } p.put("refund_trace_no", refundTraceNo); p.put("refund_fee", String.valueOf(refundFee)); // 注意:此处的 pay_time 是退款请求时间;原支付时间走 pay_trace_time p.put("pay_time", requestTime); p.put("pay_trace_time", order.getPayTime()); Map resp = client.post("/v3/refundorder", p); LsyRefundEntity entity = new LsyRefundEntity(); entity.setTenantId(order.getTenantId()); entity.setOrderId(order.getId()); entity.setOutTradeNo(order.getOutTradeNo()); entity.setOutRefundNo(outRefundNo); entity.setRefundTraceNo(refundTraceNo); entity.setRefundFee(refundFee); entity.setRefundRequestTime(requestTime); entity.setOriginalPayTime(order.getPayTime()); entity.setRefundNo(str(resp.get("refund_no"))); entity.setQdRefundNo(str(resp.get("qd_refund_no"))); entity.setRefundSettle(parseLong(resp.get("refund_settle"))); entity.setUpstreamResultCode(str(resp.get("result_code"))); entity.setUpstreamResultMsg(str(resp.get("return_msg"))); entity.setNotifyStatus("PENDING"); entity.setNotifyCount(0); // 这里刻意保留二元三元式,与 Task 6/7/8 的处理不同 —— 理由是失败方向不同: // 未知 result_code 落到 REFUNDING,而 REFUNDING 属于 LsyRefundStatus.PENDING // (非终态),所以我们既没有对下游宣称「退款成功」,也没有宣称「退款失败」, // 只是把它留在待收敛状态 + upstreamResultCode 存了原始码可追溯。 // 没有「钱已退、单记失败」这种最坏方向,因此不需要像支付侧那样拦住未知码。 // (已知的次生问题:若这是个永远不出现的未知码,退款会停在 REFUNDING。 // 这属于「需要人工/对账收敛」,比误报失败轻,已记入待确认清单。) // // ★ 一律先落非终态,同步成功也不能直接 insert 成 SUCCESS。两个理由: // 1. uk_lsy_refund_one_pending 的互斥窗口只覆盖 {CREATED, REFUNDING}。 // 直接写 SUCCESS 的行不在索引谓词里,并发第二笔退款插进来不会撞约束。 // 2. 退款行的状态只有一个写入口(applyNotifyResult),同步路径也不该例外。 entity.setStatus("REFUNDING"); refundMapper.insert(entity); // ★ 同步成功必须在这里就把订单侧收敛完,不能等回调: // 回调到达时本行已是 SUCCESS,不在 PAY_WRITE_FROM 里 → applyNotifyResult 命中 0 行 // → handleRefundNotify 判为重复回调直接返回 → refunded_amount 永不累加。 // 后果不是"少记一笔",而是 remaining 永远虚高:同一张单可以被反复退款。 // 所以同步路径自己收敛,走的是与异步回调完全相同的那个方法。 if (parser.isPaySuccess(resp)) { // 行刚插进去、状态必然是 REFUNDING,所以这里返回 false 只可能是本地写库失败。 // 那会让本行卡在 REFUNDING 并**永久挡住这张单的后续退款**(在途校验 + 部分唯一索引都认它), // 而它不会有退款对账来捞 —— 必须让人看见,不能静默。 if (!applyNotifyResult(entity, str(resp.get("result_code")), str(resp.get("return_msg")), "SUCCESS")) { log.error("退款同步成功但状态回写失败, 本行将卡在 REFUNDING 且再无兜底, 需人工处理: " + "outRefundNo={}, refundTraceNo={}", outRefundNo, refundTraceNo); } if (!settleOrderAfterRefund(order, refundFee)) { // 不抛异常:上游的钱确实已经退了,抛出去等于对调用方谎报失败。 // 只记账 —— 订单侧没动,需要人工/对账收敛。 log.error("退款同步成功但订单侧收敛未命中(状态不符或并发), 需人工核对: " + "orderId={}, orderStatus={}, outRefundNo={}, refundFee={}", order.getId(), order.getStatus(), outRefundNo, refundFee); } } return entity; } /** * 退款成功后的订单侧收敛 —— 累加 refundedAmount 并推导订单新状态。 * * **同步成功(本类 createRefund)与异步回调(LsyNotifyService.handleRefundNotify) * 都必须走这里。** 两处各写一份 `(refunded + fee) >= total ? REFUNDED : PARTIAL_REFUNDED` * 是注定要漂移的:这个式子是"退满了没有"的唯一定义,只该有一个副本。 * * @return true = 本次真的流转了;false = 订单状态不允许回写(并发或上游先动了) */ public boolean settleOrderAfterRefund(LsyOrderEntity order, long refundAmount) { long current = order.getRefundedAmount() == null ? 0L : order.getRefundedAmount(); String newStatus = (current + refundAmount) >= order.getTotalAmount() ? "REFUNDED" : "PARTIAL_REFUNDED"; return orderService.applyRefundResult(order, refundAmount, newStatus); } /** 退款查询。同上:pay_time 是退款请求时间。 */ public Map queryRefund(LsyRefundEntity refund) { Map p = new LinkedHashMap<>(); p.put("query_trace_no", IdUtil.fastSimpleUUID()); if (refund.getRefundNo() != null) { p.put("refund_no", refund.getRefundNo()); } else { p.put("refund_trace_no", refund.getRefundTraceNo()); p.put("refund_time", refund.getRefundRequestTime()); } return client.post("/v3/queryrefund", p); } /** * 退款结果回写 —— **退款行的唯一状态写入口**,条件更新,受影响行数 = 1 才算真的流转。 * * ★ 为什么幂等闸门必须落在退款行而不是订单行: * 订单侧的放行集合是 {SUCCESS, CANCELED, CLOSED, PARTIAL_REFUNDED}, * 而 PARTIAL_REFUNDED **必须**保持可写(否则第二笔部分退款就落不了库)。 * 于是同一笔退款的回调重复到达时,订单侧条件更新照样命中 → * refunded_amount 被**重复累加**,几轮之后一张 100 元的单会被算成"退满"。 * 把闸门放在退款行上:CREATE/REFUNDING → SUCCESS/FAIL 只可能发生一次, * 重复回调命中 0 行,调用方据此跳过订单侧累加。 * * @return true = 本次真的流转了;false = 重复回调或状态不符 */ public boolean applyNotifyResult(LsyRefundEntity refund, String resultCode, String resultMsg, String newStatus) { LsyRefundEntity patch = new LsyRefundEntity(); patch.setStatus(newStatus); patch.setUpstreamResultCode(resultCode); patch.setUpstreamResultMsg(resultMsg); patch.setFinishTime(OffsetDateTime.now()); patch.setUpdatedTime(OffsetDateTime.now()); UpdateWrapper w = new UpdateWrapper<>(); w.eq("id", refund.getId()).in("status", LsyRefundStatus.PAY_WRITE_FROM); if (refundMapper.update(patch, w) != 1) { log.info("退款回写未命中(重复回调): outRefundNo={}, currentStatus={}", refund.getOutRefundNo(), refund.getStatus()); return false; } refund.setStatus(newStatus); refund.setUpstreamResultCode(resultCode); refund.setUpstreamResultMsg(resultMsg); return true; } private String str(Object o) { return o == null ? null : String.valueOf(o); } private Long parseLong(Object o) { if (o == null) { return null; } try { return Long.parseLong(String.valueOf(o)); } catch (NumberFormatException e) { return null; } } } ``` 类头部需要补的 import: ```java import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; import com.payment.platform.module.payment.leshouying.enums.LsyRefundStatus; import java.time.OffsetDateTime; ``` - [ ] **Step 4: 运行测试确认全绿** ```bash cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LsyRefundServiceTest ``` Expected: `Tests run: 13, Failures: 0, Errors: 0` (7 个原有用例 + `applyNotifyResult` 的「流转成功返回 true」与「重复回调返回 false」两个 + 本轮新增 4 个:`refund_insertsAsRefundingFirst_neverDirectlyTerminal`、 `refund_syncSuccess_settlesOrderSideImmediately`、`refund_rejectsWhenAnotherRefundIsPending`、 `refund_pendingGuard_passesWhenPreviousRefundIsTerminal`。 它们是**防重复退款**的回归,不能因为"难写"省掉 —— 见本节顶部的 ★ 说明) - [ ] **Step 5: 确认工作区状态(不提交)** ```bash git status --short ``` --- ## Task 11: 调用方 —— 上游商户号解析 三种下单都需要把企业映射成 `mch_no`。本任务很小但为 Task 12 铺路,**单独成一个测试周期**。 **Files:** - Create: `java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyMerchantService.java` - Test: `java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyMerchantServiceTest.java` **Interfaces:** - Consumes: `LsyMerchantMapper` - Produces: - `LsyMerchantEntity LsyMerchantService.requireByEnterprise(Long tenantId, String enterpriseId)` - `LsyMerchantEntity LsyMerchantService.requireByMchNo(String mchNo)` - `PageResult LsyMerchantService.page(int pageNo, int pageSize, String keyword)` - `LsyMerchantEntity LsyMerchantService.save(LsyMerchantEntity entity)` - [ ] **Step 1: 写失败的测试** ```java package com.payment.platform.module.payment.leshouying.service; import com.payment.platform.common.exception.BusinessException; import com.payment.platform.module.payment.leshouying.entity.LsyMerchantEntity; import com.payment.platform.module.payment.leshouying.mapper.LsyMerchantMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class LsyMerchantServiceTest { @Mock private LsyMerchantMapper merchantMapper; private LsyMerchantService service; @BeforeEach void setUp() { service = new LsyMerchantService(merchantMapper); } @Test void requireByEnterprise_found_returnsEntity() { LsyMerchantEntity m = new LsyMerchantEntity(); m.setMchNo("Y52211090004"); when(merchantMapper.selectOne(any())).thenReturn(m); assertEquals("Y52211090004", service.requireByEnterprise(1L, "ENT1").getMchNo()); } @Test void requireByEnterprise_missing_throws404() { when(merchantMapper.selectOne(any())).thenReturn(null); BusinessException ex = assertThrows(BusinessException.class, () -> service.requireByEnterprise(1L, "ENT1")); assertEquals(404, ex.getCode()); } @Test void requireByEnterprise_blankEnterpriseId_throws() { assertThrows(BusinessException.class, () -> service.requireByEnterprise(1L, " ")); } @Test void requireByMchNo_missing_throws404() { when(merchantMapper.selectOne(any())).thenReturn(null); assertThrows(BusinessException.class, () -> service.requireByMchNo("NOT_EXIST")); } } ``` - [ ] **Step 2: 运行测试确认失败** ```bash cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LsyMerchantServiceTest ``` Expected: 编译失败。 - [ ] **Step 3: 实现 LsyMerchantService** ```java package com.payment.platform.module.payment.leshouying.service; import cn.hutool.core.util.StrUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.payment.platform.common.exception.BusinessException; import com.payment.platform.common.response.PageResult; import com.payment.platform.module.payment.leshouying.entity.LsyMerchantEntity; import com.payment.platform.module.payment.leshouying.mapper.LsyMerchantMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.stream.Collectors; /** * 企业 ↔ 乐收赢商户映射服务。 * A 阶段由管理端手工维护(测试商户 Y52211090004); * B 阶段(进件)成功后自动写入,reviewStatus 接乐收赢审核通知。 */ @Slf4j @Service @RequiredArgsConstructor public class LsyMerchantService { private final LsyMerchantMapper merchantMapper; public LsyMerchantEntity requireByEnterprise(Long tenantId, String enterpriseId) { if (StrUtil.isBlank(enterpriseId)) { throw new BusinessException(400, "enterprise_id 不能为空"); } LsyMerchantEntity m = merchantMapper.selectOne(new LambdaQueryWrapper() .eq(LsyMerchantEntity::getTenantId, tenantId) .eq(LsyMerchantEntity::getEnterpriseId, enterpriseId) .last("LIMIT 1")); if (m == null) { throw new BusinessException(404, "该企业未配置乐收赢商户号,请先在「乐收赢商户映射」中配置: " + enterpriseId); } return m; } public LsyMerchantEntity requireByMchNo(String mchNo) { if (StrUtil.isBlank(mchNo)) { throw new BusinessException(400, "mch_no 不能为空"); } LsyMerchantEntity m = merchantMapper.selectOne(new LambdaQueryWrapper() .eq(LsyMerchantEntity::getMchNo, mchNo) .last("LIMIT 1")); if (m == null) { throw new BusinessException(404, "乐收赢商户号不存在: " + mchNo); } return m; } public PageResult page(int pageNo, int pageSize, String keyword) { Page page = new Page<>(pageNo, pageSize); LambdaQueryWrapper w = new LambdaQueryWrapper<>(); if (StrUtil.isNotBlank(keyword)) { w.and(q -> q.like(LsyMerchantEntity::getMchNo, keyword) .or().like(LsyMerchantEntity::getMerchantName, keyword) .or().like(LsyMerchantEntity::getEnterpriseId, keyword)); } w.orderByDesc(LsyMerchantEntity::getCreatedTime); Page r = merchantMapper.selectPage(page, w); return PageResult.of(pageNo, pageSize, r.getTotal(), r.getRecords()); } @Transactional public LsyMerchantEntity save(LsyMerchantEntity entity) { if (StrUtil.isBlank(entity.getMchNo())) { throw new BusinessException(400, "乐收赢商户号不能为空"); } if (StrUtil.isBlank(entity.getEnterpriseId())) { throw new BusinessException(400, "企业 ID 不能为空"); } if (StrUtil.isBlank(entity.getReviewStatus())) { entity.setReviewStatus("APPROVED"); // A 阶段手工维护,默认视为已审核 } if (StrUtil.isBlank(entity.getStatus())) { entity.setStatus("ENABLED"); } if (entity.getId() == null) { merchantMapper.insert(entity); } else { merchantMapper.updateById(entity); } return merchantMapper.selectById(entity.getId()); } @Transactional public void delete(Long id) { if (merchantMapper.selectById(id) == null) { throw new BusinessException(404, "商户映射不存在"); } merchantMapper.deleteById(id); } } ``` - [ ] **Step 4: 运行测试确认全绿** ```bash cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LsyMerchantServiceTest ``` Expected: `Tests run: 4, Failures: 0, Errors: 0` - [ ] **Step 5: 确认工作区状态(不提交)** ```bash git status --short ``` --- ## Task 12: 回调接收 —— 验签 + 幂等 + 状态回写 **本任务是安全边界。入口免登录,MD5 验签是唯一身份证明。** **Files:** - Create: `java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyNotifyService.java` - Create: `java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingNotifyController.java` - Test: `java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyNotifyServiceTest.java` **Interfaces:** - Consumes: - `LeshouyingSigner.verify(params, key)` - **`LeshouyingSensitive.mask(String)`(Task 2)** —— 落 `raw_body` 与 `log.error` 出参报文时都要过它。 **用 `mask` 不要用 `maskByKey`**(与 Task 4 出站日志的选择相反,判据见 Task 2 Produces): 回调报文来自外部,键名的写法我们控制不了,只按键名匹配实测漏 9 种形态; `mask` 多遮的那点噪音(14 位 `pay_time` 之类)在这里无所谓,漏掉付款码代价大得多。 **不要在本类里另写一份正则。** - `LsyOrderService.payPatch(Map,Long)`(Task 6 的**包内可见**方法) - `LsyOrderService.applyPayResult(order, newStatus, patch)` - `LsyRefundService.applyNotifyResult(refund, resultCode, resultMsg, newStatus)`(Task 10) - `LsyRefundService.settleOrderAfterRefund(order, refundAmount)`(Task 10) —— 退款成功后订单侧的收敛**只走这个**。不要在 `handleRefundNotify` 里自己算 `(refunded + fee) >= total ? REFUNDED : PARTIAL_REFUNDED`:同步成功那条路径 (`LsyRefundService.createRefund`)要得出同一个答案,两份副本必然漂移。 - `LsyNotifyLogMapper` / `LsyOrderMapper` / `LsyRefundMapper` / `LsyCallbackService` - Produces: - **构造器(8 参)**:`LsyNotifyService(LeshouyingSigner, LeshouyingProperties, LsyOrderMapper, LsyRefundMapper, LsyNotifyLogMapper, LsyCallbackService, LsyOrderService, LsyRefundService)` - `String LsyNotifyService.handlePayNotify(Map params, String rawBody)` —— 返回应答 JSON 字符串 - `String LsyNotifyService.handleRefundNotify(Map params, String rawBody)` > **本类不做字段映射**:支付回调直接复用 `LsyOrderService.payPatch`(同包,无需 public)。 > 两份 `payPatch` 会各自漂移,字段映射只能有一处。 > 报文怎么解释、状态怎么写,全部下沉到 `LsyOrderService` / `LsyRefundService`; > 本类只负责:验签 → 找单 → 委派 → 触发下游回调 → 记审计。 > ### ★ raw_body 必须脱敏(本任务的安全要点,别漏) > > 本计划从 Task 3 起就守着一条纪律:**付款码 `auth_code` 是能直接发起支付的一次性凭据,等同卡号,不落任何持久层**。 > 这条纪律在结构化存储上是成立的 —— `auth_code` 不出现在任何实体字段里,也不出现在 `V1.8` 建表脚本里。 > **但 `pay_lsy_notify_log.raw_body TEXT` 是唯一一条例外通道**:它把整包回调整文原样落库, > 而回调整文里有什么字段,不由我们决定。审计表的存在恰恰让它成了一个**无人在场的入口** —— > 没有实体字段、没有 DDL 约束、没有校验,谁都不会在 review 时想起它。 > 日志也是持久层,所以本任务里 `raw_body`、`log.error` 两处都要过 `LeshouyingSensitive.mask`。 > > 三条硬约束: > 1. **只脱敏字符串,绝不改 `params` map。** `signer.verify(params, key)` 吃的是原文; > 动了 map 就是改坏验签 —— 而验签是绝不能跳过的。 > 2. **先 mask 再 `StrUtil.brief`,顺序不能反。** `brief` 是「头 + 尾」缩略而非前缀截断, > 一个 18 位付款码跨在截断点上会被切成两段 6 位,值层要求连续 12 位、两段都接不住 —— 等于原样泄漏。 > 3. **不要删 `raw_body` 这一列。** 审计价值是真的(报文结构、`return_code`、`trade_no` 都在), > 被遮的只有长数字。因噎废食地删列是过度反应。 > > 对应测试:`logNotify_masksAuthCodeInRawBody_butKeepsDiagnosticFields`,**已完整写在 Step 1 里**。 > > ⚠️ 这里原先只放了一段带省略号(`// ……按正常路径让 handler 走到 logNotify 并落库`)的草稿, > 并且写着「Step 1 里必须有」—— 但 Step 1 里**从来没有**这条测试:草稿不可编译,谁也没把它搬进去。 > 结果是一条承重的安全约束(付款码不得进审计表)只剩下一段注释,没有任何执行点。 > 现已补为可运行的真实测试,并顺带修掉了草稿里的一个错:草稿用 > `verify(notifyLogMapper).insert(...)`,而走完全程的回调会写**两行**审计(RECEIVED + APPLIED), > `times(1)` 的默认语义会因「调用次数过多」直接红 —— 真实版用 `atLeastOnce()` + `getAllValues()` > 断言「每一行」都干净。 > > **教训(记在本模块):注释里写「必须有」而正文里没有,等于没有;** > 计划自身的这类「草稿 + 省略号」是最容易被当成已完成的东西。 - [ ] **Step 1: 写失败的测试** ```java package com.payment.platform.module.payment.leshouying.service; import com.payment.platform.module.payment.leshouying.client.LeshouyingSigner; import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties; import com.payment.platform.module.payment.leshouying.entity.LsyNotifyLogEntity; import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity; import com.payment.platform.module.payment.leshouying.mapper.LsyNotifyLogMapper; import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper; import com.payment.platform.module.payment.leshouying.mapper.LsyRefundMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import java.util.LinkedHashMap; import java.util.Map; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class LsyNotifyServiceTest { private static final String KEY = "1c4d83f0c3c24a7d9fcaa1fed2682076"; @Mock private LsyOrderMapper orderMapper; @Mock private LsyRefundMapper refundMapper; @Mock private LsyNotifyLogMapper notifyLogMapper; @Mock private LsyOrderService orderService; @Mock private LsyRefundService refundService; private LeshouyingSigner signer; private LsyNotifyService service; private LsyCallbackService callbackService; @BeforeEach void setUp() { signer = new LeshouyingSigner(); LeshouyingProperties props = new LeshouyingProperties(); props.setKey(KEY); callbackService = mock(LsyCallbackService.class); service = new LsyNotifyService(signer, props, orderMapper, refundMapper, notifyLogMapper, callbackService, orderService, refundService); } private Map signed(Map body) { Map m = new LinkedHashMap<>(body); m.put("sign", signer.sign(m, KEY)); return m; } private Map payBody(String resultCode) { Map m = new LinkedHashMap<>(); m.put("return_code", "SUCCESS"); m.put("result_code", resultCode); m.put("pay_trace_no", "PT001"); m.put("trade_no", "T001"); m.put("trade_type", "1"); m.put("total_amount", "100"); m.put("mch_no", "Y52211090004"); return m; } private LsyOrderEntity order(String status) { LsyOrderEntity o = new LsyOrderEntity(); o.setId(1L); o.setTenantId(1L); o.setOutTradeNo("OUT001"); o.setPayTraceNo("PT001"); o.setPayScene("BARCODE"); o.setTotalAmount(100L); o.setRefundedAmount(0L); o.setStatus(status); return o; } @Test void badSignature_isRejected_businessStateUntouched() { Map body = payBody("PAY_SUCCESS"); body.put("sign", "0000000000000000000000000000dead"); // 伪造签名 String resp = service.handlePayNotify(body, "{}"); assertTrue(resp.contains("FAIL"), "验签失败必须返回非 SUCCESS"); verify(orderService, never()).applyPayResult(any(), any(), any()); verify(callbackService, never()).notifyOrder(any()); // 必须留审计记录 var captor = org.mockito.ArgumentCaptor.forClass(LsyNotifyLogEntity.class); verify(notifyLogMapper).insert(captor.capture()); assertEquals("FAIL", captor.getValue().getVerifyStatus()); } @Test void missingSignature_isRejected() { Map body = payBody("PAY_SUCCESS"); // 无 sign String resp = service.handlePayNotify(body, "{}"); assertTrue(resp.contains("FAIL")); verify(orderService, never()).applyPayResult(any(), any(), any()); } @Test void validSignature_paySuccess_updatesOrderAndNotifiesDownstream() { when(orderMapper.selectOne(any())).thenReturn(order("PAYING")); when(orderService.applyPayResult(any(), any(), any())).thenReturn(true); String resp = service.handlePayNotify(signed(payBody("PAY_SUCCESS")), "{}"); assertTrue(resp.contains("SUCCESS")); verify(orderService).applyPayResult(any(), eq("SUCCESS"), any()); verify(callbackService).notifyOrder(any()); } @Test void duplicateNotification_doesNotNotifyDownstreamTwice() { when(orderMapper.selectOne(any())).thenReturn(order("SUCCESS")); when(orderService.applyPayResult(any(), any(), any())).thenReturn(false); // 未命中 = 重复 String resp = service.handlePayNotify(signed(payBody("PAY_SUCCESS")), "{}"); assertTrue(resp.contains("SUCCESS"), "重复通知仍要应答成功,否则上游会一直重推"); verify(callbackService, never()).notifyOrder(any()); } @Test void unknownOrder_stillAcksSuccess_toAvoidInfiniteRetry() { when(orderMapper.selectOne(any())).thenReturn(null); String resp = service.handlePayNotify(signed(payBody("PAY_SUCCESS")), "{}"); assertTrue(resp.contains("SUCCESS"), "查不到订单也要应答成功并告警,不能让上游无限重推"); verify(callbackService, never()).notifyOrder(any()); } @Test void payFail_updatesOrderToFail() { when(orderMapper.selectOne(any())).thenReturn(order("PAYING")); when(orderService.applyPayResult(any(), any(), any())).thenReturn(true); service.handlePayNotify(signed(payBody("PAY_FAIL")), "{}"); verify(orderService).applyPayResult(any(), eq("FAIL"), any()); } @Test void payNotify_unknownResultCode_doesNotWriteTerminalStatus() { // ★ 未知码既不是成功也不是失败。落 FAIL 会让订单退出 PENDING, // 而 LsyReconcileScheduler.reconcilePendingOrders 只扫 status = 'PAYING', // 这笔已收到钱的支付就再也不会被对账捞起 —— 缺口固化。 // 用 "SUCCESS"(通用成功码)作未知码:它是真实可能出现的值(上游某端点即回此值), // 且三个业务谓词对它全 false。 when(orderMapper.selectOne(any())).thenReturn(order("PAYING")); service.handlePayNotify(signed(payBody("SUCCESS")), "{}"); // 不写任何终态、不通知下游;但仍应答上游 SUCCESS(免无限重推) verify(orderService, never()).applyPayResult(any(), any(), any()); verify(callbackService, never()).notifyOrder(any()); } @Test void duplicateRefundCallback_doesNotDoubleCountRefundedAmount() { // 幂等闸门在退款行上:applyNotifyResult 返回 false ⇒ 订单侧不得再累加 LsyRefundEntity refund = new LsyRefundEntity(); refund.setId(9L); refund.setOrderId(1L); refund.setRefundNo("R001"); refund.setOutRefundNo("RF001"); refund.setRefundFee(30L); refund.setStatus("SUCCESS"); // 已被上一次回调写成终态 when(refundMapper.selectOne(any())).thenReturn(refund); when(refundService.applyNotifyResult(any(), any(), any(), any())).thenReturn(false); Map body = new LinkedHashMap<>(); body.put("return_code", "SUCCESS"); body.put("result_code", "TRADE_REFUND"); body.put("refund_no", "R001"); body.put("trade_no", "T001"); body.put("mch_no", "Y52211090004"); service.handleRefundNotify(signed(body), "{}"); verify(orderService, never()).applyRefundResult(any(), anyLong(), any()); verify(callbackService, never()).notifyOrder(any()); } @Test void firstRefundCallback_appliesAndNotifiesDownstream() { LsyRefundEntity refund = new LsyRefundEntity(); refund.setId(9L); refund.setOrderId(1L); refund.setRefundNo("R001"); refund.setOutRefundNo("RF001"); refund.setRefundFee(30L); refund.setStatus("REFUNDING"); when(refundMapper.selectOne(any())).thenReturn(refund); when(refundService.applyNotifyResult(any(), any(), any(), any())).thenReturn(true); when(orderMapper.selectById(1L)).thenReturn(order("SUCCESS")); when(refundService.settleOrderAfterRefund(any(), anyLong())).thenReturn(true); Map body = new LinkedHashMap<>(); body.put("return_code", "SUCCESS"); body.put("result_code", "TRADE_REFUND"); body.put("refund_no", "R001"); body.put("trade_no", "T001"); body.put("mch_no", "Y52211090004"); service.handleRefundNotify(signed(body), "{}"); // 订单侧收敛不在这里算状态 —— 交给 LsyRefundService.settleOrderAfterRefund, // 同步成功路径调的是同一个方法。这里只验证"调了、传对了金额"。 verify(refundService).settleOrderAfterRefund(any(), eq(30L)); verify(callbackService).notifyOrder(any()); } @Test void refundNotify_unknownResultCode_doesNotWriteTerminalFail() { // ★ 未知码落 FAIL 会同时放开两道防重复退款的锁: // FAIL 不在 LsyRefundStatus.PENDING 里 ⇒ 部分唯一索引 uk_lsy_refund_one_pending // 与 createRefund 的在途校验都放行 ⇒ 可以再退一次 = 重复退款。 // 退款侧没有兜底对账任务,这个错误没有自动恢复路径。 // 用 "SUCCESS" 作未知码:它是真实可能出现的值(与本模块其它端点的一致词表), // 且不等同于退款侧的两个合法码。 LsyRefundEntity refund = new LsyRefundEntity(); refund.setId(9L); refund.setOrderId(1L); refund.setRefundNo("R001"); refund.setOutRefundNo("RF001"); refund.setRefundFee(30L); refund.setStatus("REFUNDING"); when(refundMapper.selectOne(any())).thenReturn(refund); Map body = new LinkedHashMap<>(); body.put("return_code", "SUCCESS"); body.put("result_code", "SUCCESS"); body.put("refund_no", "R001"); body.put("trade_no", "T001"); body.put("mch_no", "Y52211090004"); String resp = service.handleRefundNotify(signed(body), "{}"); // 不落终态(退款行留在 REFUNDING)、不动订单、不通知下游;但仍应答上游 SUCCESS // (OK 是 LsyNotifyService 的 private 常量,测试类访问不到,故写字面量) assertEquals("{\"return_code\":\"SUCCESS\",\"return_msg\":\"成功\"}", resp); verify(refundService, never()).applyNotifyResult(any(), any(), any(), any()); verify(refundService, never()).settleOrderAfterRefund(any(), anyLong()); verify(callbackService, never()).notifyOrder(any()); } @Test void signIsExcludedFromVerification_recomputeSucceeds() { Map body = signed(payBody("PAY_SUCCESS")); assertTrue(signer.verify(body, KEY)); // 篡改 amount 后验签必须失败 body.put("total_amount", "999999"); assertFalse(signer.verify(body, KEY)); } @Test void logNotify_masksAuthCodeInRawBody_butKeepsDiagnosticFields() { // 回调报文里**故意带上** auth_code —— 不是因为我们知道上游会回显(未确认), // 而是因为「上游不会回显」是个我们控制不了的假设,防线不能建在它上面。 // 这条测试是「**写入侧真的调了脱敏器**」的唯一执行点: // Task 2 覆盖的是脱敏器本身,覆盖不了 LsyNotifyService 有没有把它接上 —— // 把 logNotify 里的 LeshouyingSensitive.mask(...) 去掉,Task 2 的 26 条照样全绿。 String rawBody = "{\"return_code\":\"SUCCESS\",\"result_code\":\"PAY_SUCCESS\"," + "\"trade_no\":\"200001102F46230306\",\"auth_code\":\"134573607383541001\"," + "\"pay_trace_no\":\"T20260921001\"}"; Map params = signed(payBody("PAY_SUCCESS")); params.put("pay_trace_no", "T20260921001"); params.put("trade_no", "200001102F46230306"); when(orderMapper.selectOne(any())).thenReturn(order("PAYING")); when(orderService.applyPayResult(any(), any(), any())).thenReturn(true); service.handlePayNotify(params, rawBody); // ★ 一条走完全程的支付回调会写**两行**审计(RECEIVED + APPLIED)。 // 所以这里**不能**写 `verify(notifyLogMapper).insert(captor.capture())` —— // 它等价于 times(1),会因为「调用次数过多」直接红,于是有人会去把断言改成 times(2), // 而真正要断言的是「**每一行**都不含付款码」。用 atLeastOnce + getAllValues 才是对的: // 漏遮任何一条都会被抓到,将来增多写入点也不会把测试写死。 var captor = org.mockito.ArgumentCaptor.forClass(LsyNotifyLogEntity.class); verify(notifyLogMapper, atLeastOnce()).insert(captor.capture()); assertFalse(captor.getAllValues().isEmpty()); for (LsyNotifyLogEntity row : captor.getAllValues()) { String stored = row.getRawBody(); assertNotNull(stored); assertFalse(stored.contains("134573607383541001"), "付款码不得落库"); assertTrue(stored.contains("***"), "长数字必须被遮成 ***"); } // 审计价值靠保留:trade_no 这类排障字段不能被一起遮掉 assertTrue(captor.getAllValues().stream() .anyMatch(r -> r.getRawBody().contains("200001102F46230306")), "trade_no 必须留着,否则审计表失去意义"); } @Test void refundNotify_unknownRefundNo_stillAcksSuccess_andLeavesAuditRow() { // 「上游说退款回调来了,我们这边却没有这笔退款单」是最需要留痕的一类事件: // 它意味着要么上游退了别人家的钱,要么我们的退款单没落库。 // 只写 log.error 会随日志轮转消失,事后无从查证,所以必须落审计行。 when(refundMapper.selectOne(any())).thenReturn(null); Map body = new LinkedHashMap<>(); body.put("return_code", "SUCCESS"); body.put("result_code", "TRADE_REFUND"); body.put("refund_no", "R_NOT_EXIST"); body.put("mch_no", "Y52211090004"); String resp = service.handleRefundNotify(signed(body), "{}"); // 仍应答 SUCCESS:与支付侧 ORDER_NOT_FOUND 同一取舍,免得上游无限重推 assertEquals("{\"return_code\":\"SUCCESS\",\"return_msg\":\"成功\"}", resp); var captor = org.mockito.ArgumentCaptor.forClass(LsyNotifyLogEntity.class); verify(notifyLogMapper, atLeastOnce()).insert(captor.capture()); assertTrue(captor.getAllValues().stream() .anyMatch(r -> "REFUND_NOT_FOUND".equals(r.getProcessStatus())), "找不到退款单必须留下审计行,否则事后无从查证"); // ★ 这条同时钉住一个**裁定**:归属不明的回调,tenant_id 必须是 null —— // 不是 0、也不是随便挑一个租户。pay_lsy_notify_log.tenant_id 可空正是为它留的。 // (为什么不写 0:拦截器对无认证上下文返回的正是 LongValue(0), // 若真写成 0,管理端按 tenant_id=<本租户> 查时照样查不到,且与"真的属于 0 号租户"无法区分。) assertTrue(captor.getAllValues().stream().allMatch(r -> r.getTenantId() == null), "归属不明的回调不得被赋予任何租户"); } } ``` - [ ] **Step 2: 运行测试确认失败** ```bash cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LsyNotifyServiceTest ``` Expected: 编译失败。 - [ ] **Step 3: 实现 LsyNotifyService** ```java package com.payment.platform.module.payment.leshouying.service; import cn.hutool.core.util.StrUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.payment.platform.module.payment.leshouying.client.LeshouyingSigner; import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties; import com.payment.platform.module.payment.leshouying.entity.LsyNotifyLogEntity; import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity; import com.payment.platform.module.payment.leshouying.entity.LsyRefundEntity; import com.payment.platform.module.payment.leshouying.enums.LsyOrderStatus; import com.payment.platform.module.payment.leshouying.enums.LsyRefundStatus; import com.payment.platform.module.payment.leshouying.mapper.LsyNotifyLogMapper; import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper; import com.payment.platform.module.payment.leshouying.mapper.LsyRefundMapper; import com.payment.platform.module.payment.leshouying.util.LsyLogSanitizer; import com.payment.platform.module.payment.leshouying.client.LeshouyingSensitive; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.util.Map; /** * 乐收赢回调处理。 * * 【安全边界】入口免登录,MD5 验签是唯一身份证明。 * 本类不提供任何跳过/禁用验签的路径。验签不通过 → 不写任何业务状态。 * * 【幂等】靠 LsyOrderService 的条件更新受影响行数判定, * 不用唯一约束 —— 那会误伤「撤销后到达的退款回调」。 * * 【应答】必须返回 {"return_code":"SUCCESS","return_msg":"成功"}, * 否则乐收赢会持续重推。查不到订单也应答成功(记 CRITICAL 告警), * 避免无效报文把上游拖入无限重试。 */ @Slf4j @Service @RequiredArgsConstructor public class LsyNotifyService { private static final String OK = "{\"return_code\":\"SUCCESS\",\"return_msg\":\"成功\"}"; private static final String BAD = "{\"return_code\":\"FAIL\",\"return_msg\":\"验签失败\"}"; private final LeshouyingSigner signer; private final LeshouyingProperties props; private final LsyOrderMapper orderMapper; private final LsyRefundMapper refundMapper; private final LsyNotifyLogMapper notifyLogMapper; private final LsyCallbackService callbackService; private final LsyOrderService orderService; private final LsyRefundService refundService; public String handlePayNotify(Map params, String rawBody) { if (!signer.verify(params, props.getKey())) { // ★ 顺序是「先脱敏、后 brief」,不能反。 // StrUtil.brief 是「头 + 尾」缩略(不是前缀截断),一个 18 位付款码若正好跨在截断点上, // 会被切成两段 6 位数字 —— 值层要求连续 12 位,两段都接不住,等于原样泄漏。 // 先 brief 再遮 = 把完整凭据拆成遮不住的小块。先遮再 brief 才安全。 // ★ 顺序的第二段:最外层还要过 flatten(CWE-117 压行终止符),不能反。 // brief 只做「头+尾」缩略、mask 只遮长数字(其自身 javadoc 逐字:「mask 只遮长数字」), // **两者都不压换行** ⇒ 未验签报文里的 \n 会原样进日志,伪造出一条不存在的日志行。 // flatten 必须在**最外层**:它若不压,里面两步都挡不住行终止符; // 而 flatten 声明**不封顶长度**,所以 brief 的 500 上限要在它**里面**。 log.error("乐收赢交易回调验签失败! payTraceNo={}, raw={}", LsyLogSanitizer.flatten(params.get("pay_trace_no")), LsyLogSanitizer.flatten(StrUtil.brief(LeshouyingSensitive.mask(rawBody), 500))); logNotify(params, rawBody, null, "FAIL", "REJECTED", "验签失败"); return BAD; } // 此刻还没查订单,归属未知 → tenantId 传 null(审计行只对超管可见,见 logNotify 的 javadoc) logNotify(params, rawBody, null, "SUCCESS", "RECEIVED", null); String payTraceNo = str(params.get("pay_trace_no")); LsyOrderEntity order = orderMapper.selectOne(new LambdaQueryWrapper() .eq(LsyOrderEntity::getPayTraceNo, payTraceNo) .last("LIMIT 1")); if (order == null) { log.error("乐收赢回调找不到订单,应答成功避免无限重推: payTraceNo={}", payTraceNo); logNotify(params, rawBody, null, "SUCCESS", "ORDER_NOT_FOUND", "订单不存在"); return OK; } String resultCode = str(params.get("result_code")); // ★ 必须三分支。**不能**写成 "PAY_SUCCESS".equals(rc) ? "SUCCESS" : "FAIL": // 那个写法把未知码判成 FAIL,与 Task 6/7/8/9 的同步路径**自相矛盾** // (那三处都写明「未知码既不成功也不失败,不写终态」),而且后果是真钱方向 —— // 把一笔**已经收到钱**的支付写成终态 FAIL,订单就退出了 PENDING, // 而 LsyReconcileScheduler.reconcilePendingOrders 只扫 status = 'PAYING', // 于是这笔再也不会被对账捞起,缺口就此固化。这正是本模块开头点名要防的那件事。 // // 为什么不能假设「回调只会回这两个值」:同一份上游文档里, // cancelorder / closeorder 的正文把成功写成 `PAY_SUCEESS`(少一个 S), // /v3/trade/* 的返回示例用的又是**小写** `success`。 // 文档明确没有解决这个拼写冲突,所以「上游不会回别的值」是个不该依赖的假设。 String newStatus; if ("PAY_SUCCESS".equals(resultCode)) { newStatus = "SUCCESS"; } else if ("PAY_FAIL".equals(resultCode)) { newStatus = "FAIL"; } else { newStatus = null; } if (newStatus == null) { // 上游报文原文一律过 flatten(CWE-117):return_msg 可能把调用方传的 // out_trade_no 原样回显,所以它不只是「上游的话」,也间接是**不可信文本**。 log.error("乐收赢支付回调返回未知 result_code,不写终态、订单留在 PENDING 待对账: " + "payTraceNo={}, return_code={}, result_code={}, return_msg={}", payTraceNo, LsyLogSanitizer.flatten(params.get("return_code")), LsyLogSanitizer.flatten(resultCode), LsyLogSanitizer.flatten(params.get("return_msg"))); // 仍应答 SUCCESS:免得上游无限重推(与上面 ORDER_NOT_FOUND 同一取舍)。 // 兜底不是靠重推,是靠订单留在 PAYING 被对账轮询捞起。 logNotify(params, rawBody, order.getTenantId(), "SUCCESS", "UNKNOWN_RESULT_CODE", null); return OK; } // 状态写入口在 LsyOrderService,本类不自己拼条件更新 // payPatch 是 LsyOrderService 的包内方法 —— 回调报文与查询报文同形,复用同一份映射 boolean changed = orderService.applyPayResult( order, newStatus, orderService.payPatch(params, null)); // ── 0 行更新有两种**截然不同**的含义,必须分开,不能都叫 DUPLICATE ────────── // ① 本单**已认过这笔成功收款**(状态 ∈ LsyOrderStatus.PAY_RECORDED: // SUCCESS / PARTIAL_REFUNDED / REFUNDED),上游重推 → 真·重复,幂等正常工作。 // ★ 「已退款」也属于①:款确实记过,重复的成功回调只是重推,不是差异。 // ② 本单落在**其余终态**(EXPIRED / CLOSED / CANCELED / FAIL)而上游说 // 「支付成功」→ 这**不是**重复,是**账实不符**:钱在上游收了,我们账上 // 却写着「已过期 / 已关闭」,且这笔单已退出全部对账集合。 // // ② 的根因见 spec §14 待确认第 1 项:H5 **没有关单接口**,本地置 EXPIRED 时 // 上游链接**仍然有效**(spec 风险表已记),用户 30 分钟后照样能付成功。 // 把 ② 记成 DUPLICATE 的后果是:**唯一能证明这笔差异存在的证据,被记成了 // 一条正常的重复回调**,与 spec 风险表承诺的「EXPIRED 状态可追溯」直接矛盾。 // // v1 只**暴露**、不自动纠正:受控的 EXPIRED→SUCCESS 纠正通道要等联调确认 // 「上游对已过期订单的回调语义」后再定(§14 第 1 项)。在此之前, // 基于未经确认的假设去自动改写资金终态,比不纠正更危险。 // // 判据 = PENDING 的补集 **去掉「已认过成功收款」的状态**(LsyOrderStatus.PAY_RECORDED)。 // ★ 不能只用 PENDING 的补集:那样 SUCCESS / PARTIAL_REFUNDED / REFUNDED 也会命中 —— // 而这三个状态**恰恰是「款已记过」**,上游重推成功回调时它们三个的合取项全为真 // ⇒ **正常的重复回调被误报成「账实不符」**,顶层告警每次重推都响, // 真差异来的时候没人看(告警疲劳)。 // 为什么用「补集再去掉」而不是**列举**那四个异常态:见 LsyOrderStatus.PAY_RECORDED 的 javadoc // (新增终态忘了归类时,补集写法多报一次响的告警,列举写法静默漏掉 —— 选响的那一侧)。 boolean latePaymentAfterTerminal = !changed && "SUCCESS".equals(newStatus) && !LsyOrderStatus.PENDING.contains(order.getStatus()) && !LsyOrderStatus.PAY_RECORDED.contains(order.getStatus()); if (latePaymentAfterTerminal) { log.error("★ 账实不符:上游回调称支付成功,但本单已落终态 {},本次回写被拒;" + "钱可能已在上游收取,需人工核对!outTradeNo={}, tradeNo={}", order.getStatus(), order.getOutTradeNo(), LsyLogSanitizer.flatten(params.get("trade_no"))); } if (changed) { callbackService.notifyOrder(order); } logNotify(params, rawBody, order.getTenantId(), "SUCCESS", changed ? "APPLIED" : (latePaymentAfterTerminal ? "LATE_PAYMENT_AFTER_TERMINAL" : "DUPLICATE"), null); return OK; } public String handleRefundNotify(Map params, String rawBody) { if (!signer.verify(params, props.getKey())) { // 与支付侧同一条顺序纪律:mask(仅遮长数字)→ brief(仅限长 500)→ flatten(压行终止符,最外层)。 // 前两步都不压换行,缺了 flatten 这一层,未验签报文里的 \n 就能伪造日志行。 log.error("乐收赢退款回调验签失败! raw={}", LsyLogSanitizer.flatten(StrUtil.brief(LeshouyingSensitive.mask(rawBody), 500))); logNotify(params, rawBody, null, "FAIL", "REJECTED", "验签失败"); return BAD; } // 此刻还没查退款单,归属未知 → tenantId 传 null logNotify(params, rawBody, null, "SUCCESS", "RECEIVED", null); String refundNo = str(params.get("refund_no")); LsyRefundEntity refund = refundMapper.selectOne(new LambdaQueryWrapper() .eq(LsyRefundEntity::getRefundNo, refundNo) .last("LIMIT 1")); if (refund == null) { log.error("乐收赢退款回调找不到退款单: refundNo={}", refundNo); // 与支付侧 ORDER_NOT_FOUND 对齐,补上审计行: // 「上游说退款回调来了,我们这边没有这笔退款单」是最需要留痕的一类事件, // 只写 log.error 会随日志轮转消失,事后无从查证。 logNotify(params, rawBody, null, "SUCCESS", "REFUND_NOT_FOUND", "退款单不存在"); return OK; } String resultCode = str(params.get("result_code")); // ★ 退款回调是**另一套词表**:TRADE_REFUND / TRADE_REFUND_FAIL, // 不是支付侧的 PAY_SUCCESS / PAY_FAIL(实测:上游「退款异步回调」页)。 // 同样必须三分支。写成二元的 "TRADE_REFUND".equals(rc) ? SUCCESS : FAIL, // 未知码就落 FAIL —— 而 FAIL **不在** LsyRefundStatus.PENDING 里, // 于是一笔其实**已经退成功**的退款会同时绕过两道防重复退款的锁: // (a) 部分唯一索引 uk_lsy_refund_one_pending(WHERE status IN ('CREATED','REFUNDING')) // (b) LsyRefundService.createRefund 的在途前置校验 // → 可以再退一次 = **重复退款,真钱**。 // 而且退款侧**没有**兜底对账任务(LsyReconcileScheduler 只扫订单表), // 这个错误没有任何自动恢复路径。 // 留在 REFUNDING 则三道锁继续生效,代价是这笔要人工核对 —— 方向是对的: // 「卡住等人工」远好于「静默判失败后放行重退」。 String outcome; if ("TRADE_REFUND".equals(resultCode)) { outcome = "SUCCESS"; } else if ("TRADE_REFUND_FAIL".equals(resultCode)) { outcome = "FAIL"; } else { outcome = null; } if (outcome == null) { log.error("乐收赢退款回调返回未知 result_code,不落终态、留在 REFUNDING 待人工核对: " + "refundNo={}, return_code={}, result_code={}, return_msg={}", refundNo, LsyLogSanitizer.flatten(params.get("return_code")), LsyLogSanitizer.flatten(resultCode), LsyLogSanitizer.flatten(params.get("return_msg"))); logNotify(params, rawBody, refund.getTenantId(), "SUCCESS", "UNKNOWN_RESULT_CODE", null); return OK; } boolean success = "SUCCESS".equals(outcome); // ★ 幂等闸门落在退款行上(applyNotifyResult 的条件更新),不是订单行。 // PARTIAL_REFUNDED 必须保持可写,所以订单侧的条件更新挡不住重复回调 —— // 同一笔退款重推两次会把 refunded_amount 累加两次。 boolean applied = refundService.applyNotifyResult( refund, resultCode, str(params.get("return_msg")), outcome); if (!applied) { // ── 0 行更新有两种**截然不同**的含义,与支付侧的 ①② **同构**(见 handlePayNotify)── // ① 本行已记过这笔退款成功(状态 = SUCCESS)→ 真·重复,幂等正常工作。 // ② 上游称退款**成功**,而我们记的是 **FAIL** → 这**不是**重复,是**账实不符**: // 钱可能已经退给用户,我们账上却写着「退款失败」。 // 退款侧**没有任何兜底对账**(LsyReconcileScheduler 只扫订单表,见 LsyRefundStatus 的 javadoc), // 把 ② 记成 DUPLICATE 的后果比支付侧更重:**审计表里连一个能描述这种情形的取值都不会出现**, // 事后人工翻审计也翻不出来。 // ★ 可达性在**本类内**闭合:`TRADE_REFUND_FAIL` 分支(outcome="FAIL")就把本行写成 FAIL, // 之后到达的成功回调必然命中这里。 // 判据与支付侧同形 —— 用**补集**而不是列举 FAIL:新增终态忘了归类时会**多报一次响的告警**, // 而不是静默漏掉。(此处没有再抽一个具名常量:退款侧「已记过成功」只有 SUCCESS 一个取值, // 单元素集合是噪音,而补集写法本身已经给出了「响的那一侧」这个性质。) // `refund.getStatus()` 读的是**更新前**的状态 —— applyNotifyResult 的 return false 在 setter 之前。 boolean lateRefundAfterTerminal = success && !LsyRefundStatus.PENDING.contains(refund.getStatus()) && !"SUCCESS".equals(refund.getStatus()); if (lateRefundAfterTerminal) { log.error("★ 账实不符:上游回调称退款成功,但本行已落终态 {},本次回写被拒;" + "钱可能已退给用户,需人工核对!outRefundNo={}, refundNo={}", refund.getStatus(), refund.getOutRefundNo(), LsyLogSanitizer.flatten(refundNo)); } else { log.info("退款回调重复,忽略: refundNo={}", LsyLogSanitizer.flatten(refundNo)); } logNotify(params, rawBody, refund.getTenantId(), "SUCCESS", lateRefundAfterTerminal ? "LATE_REFUND_AFTER_TERMINAL" : "DUPLICATE", null); return OK; } if (success) { LsyOrderEntity order = orderMapper.selectById(refund.getOrderId()); if (order != null) { long fee = refund.getRefundFee() == null ? 0L : refund.getRefundFee(); // 状态推导不在这里算 —— 同步成功的另一条路径(LsyRefundService.createRefund) // 也要得出同一个 REFUNDED/PARTIAL_REFUNDED。两份副本迟早漂移, // 而"退满了没有"算错的后果是可退余额算错,也就是能多退钱。 if (refundService.settleOrderAfterRefund(order, fee)) { callbackService.notifyOrder(order); } } } logNotify(params, rawBody, refund.getTenantId(), "SUCCESS", "APPLIED", null); return OK; } /** * 写一条回调审计行。 * * @param tenantId **这笔回调能归属到哪个租户**;归属不明时传 null * (验签失败 / 找不到订单 / 退款单不存在 —— 都属于平台级事件,本就不属于任何租户)。 * * ★ 为什么必须**显式**传,而不能指望自动填充: * `AutoFillMetaObjectHandler.insertFill` 只在 SecurityContext 里**有认证用户**时才填 `tenantId`, * 而回调是免登录入口(SecurityConfig 白名单)—— 不显式传就恒为 null。 * 而 `pay_lsy_notify_log` 在 `TenantInnerInterceptor.CONDITIONAL_TABLES` 里, * 管理端(**已认证**)查这张表时会被追加 `tenant_id = <本租户>`(该文件 `:117-119`), * 于是所有 `tenant_id` 为 null 的回调行**一行都查不出来** —— * Task 17 的 `/{outTradeNo}/notify-logs` 会**永远返回空列表**:接口看着是活的,其实读不到东西。 * 这类「不报错、只是空」的失效正是本模块反复要防的形态,所以在写入侧一次性修掉。 * * ★ null 行只对超管可见是有意为之:`TenantInnerInterceptor.ignoreTable` 对超管直接 `return true` * (该文件 `:101-105`),平台运营能查到全部归属不明的事件,普通租户查不到 —— 正是想要的语义。 */ private void logNotify(Map params, String rawBody, Long tenantId, String verifyStatus, String processStatus, String errorMsg) { try { LsyNotifyLogEntity logEntity = new LsyNotifyLogEntity(); logEntity.setTenantId(tenantId); logEntity.setTradeType(str(params.get("trade_type"))); logEntity.setTradeNo(str(params.get("trade_no"))); logEntity.setPayTraceNo(str(params.get("pay_trace_no"))); // ★ 只脱敏**字符串**,绝不能碰上面的 params map —— signer.verify(params, key) // 要的是原文,动了 map 就等于把验签改坏(而验签是绝不能跳过的)。 // ★ raw_body 是 pay_lsy_* 里**唯一**能让任意字段进库而不在实体、DDL、校验里留下痕迹的通道: // auth_code 在全部实体字段和建表脚本里都不存在,所以结构化存储是干净的 —— // 干净的代价就是这条通道没人替它把关。回调报文将来回显什么字段不由我们决定, // 所以脱敏放在写入口,而不是靠「上游应该不会回显」的假设。 // ★ 审计价值靠保留——保留的是报文结构、return_code/result_msg、trade_no 这些排障要读的字段; // 被遮掉的只有长数字。不要因为「遮了就不完整」而删掉 raw_body 这一列。 logEntity.setRawBody(LeshouyingSensitive.mask(rawBody)); logEntity.setVerifyStatus(verifyStatus); logEntity.setProcessStatus(processStatus); logEntity.setErrorMsg(errorMsg); notifyLogMapper.insert(logEntity); } catch (Exception e) { log.error("回调审计日志写入失败", e); } } private String str(Object o) { return o == null ? null : String.valueOf(o); } } ``` - [ ] **Step 4: 实现 LeshouyingNotifyController** ```java package com.payment.platform.module.payment.leshouying.controller; import com.payment.platform.module.payment.leshouying.service.LsyNotifyService; import com.payment.platform.module.payment.leshouying.util.LsyLogSanitizer; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.Map; /** * 乐收赢回调入口 —— 免登录(须加入 SecurityConfig 白名单)。 * * 上游约定:POST + Content-Type: application/json,参数含 sign。 * 必须应答 {"return_code":"SUCCESS","return_msg":"成功"},否则上游持续重推。 * * 【安全】入口无认证,签名是唯一身份证明。不提供任何跳过验签的路径。 */ @Slf4j @RestController @RequestMapping("/payment/leshouying/notify") @RequiredArgsConstructor public class LeshouyingNotifyController { private final LsyNotifyService notifyService; @PostMapping(value = "/pay", produces = MediaType.APPLICATION_JSON_VALUE) public String payNotify(@RequestBody Map body) { // ★ 本入口**免登录且验签尚未发生** ⇒ 这两个实参是完全不可信的,必须过 flatten(CWE-117)。 // 攻击者连签名都不用伪造,直接 POST 一个带 \n 的 pay_trace_no 就能往日志里插行。 log.info("收到乐收赢交易回调: payTraceNo={}, resultCode={}", LsyLogSanitizer.flatten(body.get("pay_trace_no")), LsyLogSanitizer.flatten(body.get("result_code"))); return notifyService.handlePayNotify(body, toJson(body)); } @PostMapping(value = "/refund", produces = MediaType.APPLICATION_JSON_VALUE) public String refundNotify(@RequestBody Map body) { // 同 payNotify:免登录 + 未验签 ⇒ 同样不可信,同样 flatten。 log.info("收到乐收赢退款回调: refundNo={}, resultCode={}", LsyLogSanitizer.flatten(body.get("refund_no")), LsyLogSanitizer.flatten(body.get("result_code"))); return notifyService.handleRefundNotify(body, toJson(body)); } private String toJson(Map body) { try { return cn.hutool.json.JSONUtil.toJsonStr(body); } catch (Exception e) { return String.valueOf(body); } } } ``` - [ ] **Step 5: 运行测试确认全绿** ```bash cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LsyNotifyServiceTest ``` Expected: `Tests run: 13, Failures: 0, Errors: 0` (13 = 基础 9 条 + `payNotify_unknownResultCode_doesNotWriteTerminalStatus` + `refundNotify_unknownResultCode_doesNotWriteTerminalFail` + `logNotify_masksAuthCodeInRawBody_butKeepsDiagnosticFields` + `refundNotify_unknownRefundNo_stillAcksSuccess_andLeavesAuditRow`。 **中间那两条不是覆盖率凑数**,各自是某条规则的**唯一**执行点: - 两条 unknown-code:回调体不经 parser,`result_code` 是在本类里裸比较的, 任何 grep 都看不见 `x ? A : B` 这种形态,只有测试能挡住它被改回二元分派。 - 脱敏那条:Task 2 覆盖的是脱敏器**本身**,覆盖不了 `LsyNotifyService` 有没有把脱敏器**接上**。 去掉 `logNotify` 里的 `LeshouyingSensitive.mask(...)`,Task 2 的 26 条照样全绿 —— 而 `pay_lsy_notify_log.raw_body` 是本模块唯一能把任意字段写进库的通道。 - 找不到退款单那条:钉住新增的 `REFUND_NOT_FOUND` 审计行,且同时钉住 「归属不明的回调 `tenant_id` 必须是 null」这个裁定。) - [ ] **Step 6: 确认工作区状态(不提交)** ```bash git status --short ``` --- ## Task 13: 下游回调推送 LsyCallbackService **Files:** - Create: `java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyCallbackService.java` - Test: `java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyCallbackServiceTest.java` **Interfaces:** - Consumes: `LsyOrderMapper`, `LsyRefundMapper`, `OpenConfMapper`, `TenantApiKeyMapper`, `RestTemplate`, `LeshouyingProperties`(**构造器共 6 参**) - Produces: - `void LsyCallbackService.notifyOrder(LsyOrderEntity order)` - `void LsyCallbackService.notifyRefund(LsyRefundEntity refund)` - `void LsyCallbackService.retryFailed()` —— 供对账调度器调用 - [ ] **Step 1: 写失败的测试** ```java package com.payment.platform.module.payment.leshouying.service; import com.payment.platform.module.payment.apikey.entity.TenantApiKeyEntity; import com.payment.platform.module.payment.apikey.mapper.TenantApiKeyMapper; import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties; import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity; import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper; import com.payment.platform.module.payment.leshouying.mapper.LsyRefundMapper; import com.payment.platform.module.payment.openapi.mapper.OpenConfMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpEntity; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.client.RestTemplate; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class LsyCallbackServiceTest { @Mock private LsyOrderMapper orderMapper; @Mock private LsyRefundMapper refundMapper; @Mock private TenantApiKeyMapper apiKeyMapper; @Mock private OpenConfMapper confMapper; @Mock private RestTemplate restTemplate; private LsyCallbackService service; @BeforeEach void setUp() { LeshouyingProperties props = new LeshouyingProperties(); props.setDownstreamNotifyMaxRetries(8); service = new LsyCallbackService(orderMapper, refundMapper, apiKeyMapper, confMapper, restTemplate, props); } private LsyOrderEntity order(String notifyUrl) { LsyOrderEntity o = new LsyOrderEntity(); o.setId(1L); o.setTenantId(1L); o.setOutTradeNo("OUT001"); o.setStatus("SUCCESS"); o.setTotalAmount(100L); o.setNotifyStatus("PENDING"); o.setNotifyCount(0); o.setNotifyUrl(notifyUrl); return o; } @Test void notifyOrder_usesUrlSnapshotFromOrder_notCurrentConfig() { when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(String.class))) .thenReturn(new ResponseEntity<>("ok", HttpStatus.OK)); service.notifyOrder(order("https://merchant.example.com/cb")); verify(restTemplate).postForEntity(eq("https://merchant.example.com/cb"), any(HttpEntity.class), eq(String.class)); verify(apiKeyMapper, never()).selectOne(any()); } @Test void notifyOrder_success_setsNotifyStatusSuccess() { when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(String.class))) .thenReturn(new ResponseEntity<>("ok", HttpStatus.OK)); service.notifyOrder(order("https://merchant.example.com/cb")); var captor = org.mockito.ArgumentCaptor.forClass(LsyOrderEntity.class); verify(orderMapper).updateById(captor.capture()); org.junit.jupiter.api.Assertions.assertEquals("SUCCESS", captor.getValue().getNotifyStatus()); } @Test void notifyOrder_fallsBackToApiKeyReturnUrl_whenOrderSnapshotMissing() { TenantApiKeyEntity key = new TenantApiKeyEntity(); key.setReturnUrl("https://fallback.example.com/cb"); when(apiKeyMapper.selectById(2L)).thenReturn(key); when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(String.class))) .thenReturn(new ResponseEntity<>("ok", HttpStatus.OK)); LsyOrderEntity o = order(null); o.setApiKeyId(2L); service.notifyOrder(o); verify(restTemplate).postForEntity(eq("https://fallback.example.com/cb"), any(HttpEntity.class), eq(String.class)); } @Test void notifyOrder_noUrlAnywhere_recordsFailWithoutThrowing() { LsyOrderEntity o = order(null); service.notifyOrder(o); // 不应抛异常 verify(restTemplate, never()).postForEntity(anyString(), any(), eq(String.class)); } @Test void notifyOrder_httpError_setsFailStatus_andIncrementsCount() { when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(String.class))) .thenThrow(new RuntimeException("connection refused")); service.notifyOrder(order("https://merchant.example.com/cb")); var captor = org.mockito.ArgumentCaptor.forClass(LsyOrderEntity.class); verify(orderMapper).updateById(captor.capture()); org.junit.jupiter.api.Assertions.assertEquals("FAIL", captor.getValue().getNotifyStatus()); org.junit.jupiter.api.Assertions.assertEquals(1, captor.getValue().getNotifyCount()); } } ``` - [ ] **Step 2: 运行测试确认失败** ```bash cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LsyCallbackServiceTest ``` Expected: 编译失败。 - [ ] **Step 3: 实现 LsyCallbackService** ```java package com.payment.platform.module.payment.leshouying.service; import cn.hutool.core.util.StrUtil; import cn.hutool.json.JSONUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.payment.platform.common.utils.SnowflakeIdGenerator; import com.payment.platform.module.payment.apikey.entity.TenantApiKeyEntity; import com.payment.platform.module.payment.apikey.mapper.TenantApiKeyMapper; import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties; import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity; import com.payment.platform.module.payment.leshouying.entity.LsyRefundEntity; import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper; import com.payment.platform.module.payment.leshouying.mapper.LsyRefundMapper; import com.payment.platform.module.payment.openapi.entity.OpenConfEntity; import com.payment.platform.module.payment.openapi.mapper.OpenConfMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.web.client.RestTemplate; import java.time.OffsetDateTime; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** * 下游回调推送。 * * 报文格式沿用现有 OpenapiService.sendCallback(表单 POST: notify_id/timestamp/content)。 * 差异:本类**持久化推送状态**,失败由 LsyReconcileScheduler 重试, * 而不是像现有实现那样失败即丢。 * * 回调地址优先级:订单快照的 notify_url → api_key.return_url → open_conf.return_url。 * 优先快照是为了避免商户改配置导致历史订单回调漂移。 */ @Slf4j @Service @RequiredArgsConstructor public class LsyCallbackService { private final LsyOrderMapper orderMapper; private final LsyRefundMapper refundMapper; private final TenantApiKeyMapper apiKeyMapper; private final OpenConfMapper confMapper; private final RestTemplate restTemplate; private final LeshouyingProperties props; public void notifyOrder(LsyOrderEntity order) { String url = resolveUrl(order.getNotifyUrl(), order.getApiKeyId(), order.getTenantId()); if (StrUtil.isBlank(url)) { log.warn("下游回调地址缺失,跳过: outTradeNo={}", order.getOutTradeNo()); return; } Map content = new LinkedHashMap<>(); content.put("out_trade_no", order.getOutTradeNo()); content.put("trade_no", order.getTradeNo()); content.put("status", order.getStatus()); content.put("total_amount", order.getTotalAmount()); content.put("receipt_amount", order.getReceiptAmount()); content.put("refunded_amount", order.getRefundedAmount()); content.put("pay_scene", order.getPayScene()); content.put("attach", order.getAttach()); boolean ok = push(url, content, "订单回调"); recordOrderNotifyResult(order, ok); } public void notifyRefund(LsyRefundEntity refund) { String url = resolveUrl(null, null, refund.getTenantId()); if (StrUtil.isBlank(url)) { return; } Map content = new LinkedHashMap<>(); content.put("out_trade_no", refund.getOutTradeNo()); content.put("out_refund_no", refund.getOutRefundNo()); content.put("refund_no", refund.getRefundNo()); content.put("status", refund.getStatus()); content.put("refund_fee", refund.getRefundFee()); boolean ok = push(url, content, "退款回调"); LsyRefundEntity patch = new LsyRefundEntity(); patch.setId(refund.getId()); patch.setNotifyStatus(ok ? "SUCCESS" : "FAIL"); patch.setNotifyCount((refund.getNotifyCount() == null ? 0 : refund.getNotifyCount()) + 1); patch.setLastNotifyTime(OffsetDateTime.now()); refundMapper.updateById(patch); } /** 供对账调度器重试所有推送失败的回调 */ public void retryFailed() { List orders = orderMapper.selectList( new LambdaQueryWrapper() .eq(LsyOrderEntity::getNotifyStatus, "FAIL") .lt(LsyOrderEntity::getNotifyCount, props.getDownstreamNotifyMaxRetries()) .orderByAsc(LsyOrderEntity::getLastNotifyTime) .last("LIMIT 50")); for (LsyOrderEntity o : orders) { try { notifyOrder(o); } catch (Exception e) { log.error("下游回调重试异常: outTradeNo={}", o.getOutTradeNo(), e); } } List refunds = refundMapper.selectList( new LambdaQueryWrapper() .eq(LsyRefundEntity::getNotifyStatus, "FAIL") .lt(LsyRefundEntity::getNotifyCount, props.getDownstreamNotifyMaxRetries()) .orderByAsc(LsyRefundEntity::getLastNotifyTime) .last("LIMIT 50")); for (LsyRefundEntity r : refunds) { try { notifyRefund(r); } catch (Exception e) { log.error("下游退款回调重试异常: outRefundNo={}", r.getOutRefundNo(), e); } } } /** 地址优先级:订单快照 → API Key → 开放配置 */ private String resolveUrl(String snapshotUrl, Long apiKeyId, Long tenantId) { if (StrUtil.isNotBlank(snapshotUrl)) { return snapshotUrl; } if (apiKeyId != null) { TenantApiKeyEntity key = apiKeyMapper.selectById(apiKeyId); if (key != null && StrUtil.isNotBlank(key.getReturnUrl())) { return key.getReturnUrl(); } } if (tenantId != null) { OpenConfEntity conf = confMapper.selectOne(new LambdaQueryWrapper() .eq(OpenConfEntity::getTenantId, tenantId) .orderByDesc(OpenConfEntity::getUpdatedTime) .last("LIMIT 1")); if (conf != null && StrUtil.isNotBlank(conf.getReturnUrl())) { return conf.getReturnUrl(); } } return null; } /** 单次推送,不重试(重试交给调度器,避免占用回调线程) */ private boolean push(String url, Map content, String label) { try { MultiValueMap form = new LinkedMultiValueMap<>(); form.add("notify_id", "n" + SnowflakeIdGenerator.nextIdStr()); form.add("timestamp", String.valueOf(System.currentTimeMillis())); form.add("content", JSONUtil.toJsonStr(content)); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); ResponseEntity resp = restTemplate.postForEntity( url, new HttpEntity<>(form, headers), String.class); boolean ok = resp.getStatusCode().is2xxSuccessful(); log.info("{}推送{}: url={}, status={}", label, ok ? "成功" : "失败", url, resp.getStatusCode()); return ok; } catch (Exception e) { log.warn("{}推送异常: url={}, error={}", label, url, e.getMessage()); return false; } } private void recordOrderNotifyResult(LsyOrderEntity order, boolean ok) { LsyOrderEntity patch = new LsyOrderEntity(); patch.setId(order.getId()); patch.setNotifyStatus(ok ? "SUCCESS" : "FAIL"); patch.setNotifyCount((order.getNotifyCount() == null ? 0 : order.getNotifyCount()) + 1); patch.setLastNotifyTime(OffsetDateTime.now()); orderMapper.updateById(patch); order.setNotifyStatus(patch.getNotifyStatus()); order.setNotifyCount(patch.getNotifyCount()); } } ``` - [ ] **Step 4: 运行测试确认全绿** ```bash cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LsyCallbackServiceTest ``` Expected: `Tests run: 5, Failures: 0, Errors: 0` - [ ] **Step 5: 确认工作区状态(不提交)** ```bash git status --short ``` --- ## Task 14: 三个共享文件必改点 **漏改任意一处都会造成静默故障。** 这是本任务存在的唯一理由。 **Files:** - Modify: `java/src/main/java/com/payment/platform/core/security/TenantApiKeyAuthFilter.java:92-96` - Modify: `java/src/main/java/com/payment/platform/core/security/SecurityConfig.java:38-70` - Modify: `java/src/main/java/com/payment/platform/core/tenant/TenantInnerInterceptor.java:53-76` - Test: `java/src/test/java/com/payment/platform/core/security/LeshouyingWiringTest.java` **Interfaces:** - Consumes: 无 - Produces: 无(纯配置改动) - [ ] **Step 1: 写失败的测试** ```java package com.payment.platform.core.security; import com.payment.platform.core.tenant.TenantInnerInterceptor; import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockHttpServletRequest; import java.lang.reflect.Field; import java.util.Set; import static org.junit.jupiter.api.Assertions.*; /** * 防止三处共享文件漏改的守卫测试。 * 漏改的后果分别是:完全无鉴权 / 回调 403 / 回调回写静默失效。 */ class LeshouyingWiringTest { private static final String[] OPENAPI_PATHS = { "/payment/openapi/leshouying/barcode/pay", "/payment/openapi/leshouying/qrcode/create", "/payment/openapi/leshouying/h5/create", "/payment/openapi/leshouying/order/query", "/payment/openapi/leshouying/order/close", "/payment/openapi/leshouying/refund", "/payment/openapi/leshouying/refund/query", }; @Test void openapiPaths_areInFilterWhitelist_otherwiseNoAuthAtAll() throws Exception { Field f = TenantApiKeyAuthFilter.class.getDeclaredField("OPENAPI_ACCOUNT_PATHS"); f.setAccessible(true); String[] paths = (String[]) f.get(null); for (String expected : OPENAPI_PATHS) { assertTrue(java.util.Arrays.asList(paths).contains(expected), "路径未加入 OPENAPI_ACCOUNT_PATHS 会导致过滤器整体跳过 → 完全无鉴权: " + expected); } } @Test void shouldNotFilter_returnsFalseForLeshouyingPaths() throws Exception { TenantApiKeyAuthFilter filter = new TenantApiKeyAuthFilter(null, null, null); for (String path : OPENAPI_PATHS) { MockHttpServletRequest req = new MockHttpServletRequest("POST", path); req.setServletPath(path); assertFalse(filter.shouldNotFilter(req), "shouldNotFilter 必须返回 false(=需要过滤): " + path); } } @Test void notifyPath_isNotFilteredByApiKeyFilter_butStillWhitelistedInSecurityConfig() throws Exception { TenantApiKeyAuthFilter filter = new TenantApiKeyAuthFilter(null, null, null); MockHttpServletRequest req = new MockHttpServletRequest( "POST", "/payment/leshouying/notify/pay"); req.setServletPath("/payment/leshouying/notify/pay"); assertTrue(filter.shouldNotFilter(req), "回调入口不走 ApiKey 认证,走 MD5 验签"); // SecurityConfig 白名单必须放行,否则被挡成 403 Field f = SecurityConfig.class.getDeclaredField("WHITE_LIST"); f.setAccessible(true); String[] whiteList = (String[]) f.get(null); assertTrue(java.util.Arrays.asList(whiteList).contains("/payment/leshouying/notify/**"), "回调地址未加入 SecurityConfig.WHITE_LIST 会被 Spring Security 挡成 403"); } @Test void conditionalTables_containAllLeshouyingTables() throws Exception { Field f = TenantInnerInterceptor.class.getDeclaredField("CONDITIONAL_TABLES"); f.setAccessible(true); @SuppressWarnings("unchecked") Set tables = (Set) f.get(null); for (String t : new String[]{"pay_lsy_order", "pay_lsy_refund", "pay_lsy_merchant", "pay_lsy_notify_log"}) { assertTrue(tables.contains(t), "未加入 CONDITIONAL_TABLES → 无认证场景查询被追加 tenant_id=0 → 回调回写静默失效: " + t); } } } ``` - [ ] **Step 2: 运行测试确认失败** ```bash cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LeshouyingWiringTest ``` Expected: 4 个测试全部 FAIL。 - [ ] **Step 3: 改 TenantApiKeyAuthFilter** 把 `OPENAPI_ACCOUNT_PATHS`(第 92-96 行)改为: ```java /** 仅对 OpenAPI 账户接口生效 (对应 Python OpenapiRouter 中使用 TenantApiKeyAuth 的路由) */ private static final String[] OPENAPI_ACCOUNT_PATHS = { "/payment/openapi/account/transfer", "/payment/openapi/account/transfer/query", "/payment/openapi/account/balance/query", // 乐收赢支付通道 —— 必须逐条列出。 // shouldNotFilter 用的是 path.equals(p) 精确匹配,不支持通配符; // 漏列一条 = 该路径过滤器整体跳过 = 完全无鉴权。 "/payment/openapi/leshouying/barcode/pay", "/payment/openapi/leshouying/qrcode/create", "/payment/openapi/leshouying/h5/create", "/payment/openapi/leshouying/order/query", "/payment/openapi/leshouying/order/close", "/payment/openapi/leshouying/refund", "/payment/openapi/leshouying/refund/query", }; ``` - [ ] **Step 4: 改 SecurityConfig** 在 `WHITE_LIST` 中 `/payment/aplipay/auth` 之后插入: ```java // 乐收赢回调入口(免登录;身份由 MD5 验签保证,不通过则不写任何业务状态) "/payment/leshouying/notify/**", ``` - [ ] **Step 5: 改 TenantInnerInterceptor** `pay_batch_authorize` 是当前最后一项、**末尾没有逗号**。把那一行改成下面这样(先补逗号,再追加四项): ```java "pay_batch_authorize", // 制单授权(授权签约通知 + OAuth openid 回调均无认证上下文; // 无认证时按 out_biz_no(雪花ID全局唯一)反查,无跨租户风险; // 此前不在条件表导致通知回写查询恒带 tenant_id=0 而失效, // 实际靠前端 queryAuthorize 兜底 — 加入后通知回写真正生效) "pay_lsy_order", // 乐收赢订单(回调/调度无认证上下文;同类表漏加会导致回写静默失效) "pay_lsy_refund", // 乐收赢退款单(同上) "pay_lsy_merchant", // 乐收赢商户映射(回调按 mch_no 反查) "pay_lsy_notify_log" // 乐收赢回调审计(回调入口无认证上下文) ); ``` - [ ] **Step 6: 运行测试确认全绿** ```bash cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LeshouyingWiringTest ``` Expected: `Tests run: 4, Failures: 0, Errors: 0` - [ ] **Step 7: 跑全量测试确认没有破坏既有功能** ```bash cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test ``` Expected: 全绿。若有既有测试因 `OPENAPI_ACCOUNT_PATHS` 长度变化而失败,同步更新其断言。 - [ ] **Step 8: 确认工作区状态(不提交)** ```bash git status --short ``` --- ## Task 15: 下游 OpenAPI 控制器(7 端点) **Files:** - Create: `java/src/main/java/com/payment/platform/module/payment/leshouying/dto/*.java`(**7 个请求 DTO + 1 个响应 VO**。响应 VO 只有 `LsyPayResultVO` 一个;`orderQuery` / `orderClose` / `refund` / `refundQuery` 四个端点返回 `Result>`,**不为它们建 VO**) - Create: `java/src/main/java/com/payment/platform/module/payment/leshouying/util/LsyBizNoPattern.java`(Step 1 的 `@Pattern` 唯一字符集来源,**与 `LsyLogSanitizer` 逐字一致**) - Create: `java/src/main/java/com/payment/platform/module/payment/leshouying/controller/open/LeshouyingOpenController.java` - Modify: `java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyRefundService.java`(Step 5 追加租户限定的 `requireByOutRefundNo`) - Test: `java/src/test/java/com/payment/platform/module/payment/leshouying/controller/open/LeshouyingOpenControllerTest.java` **Interfaces:** - Consumes: `LsyMerchantService.requireByEnterprise`, `LsyOrderService.create{Barcode,Qr,H5}Order`, `LsyQueryService.queryOrder`, `LsyCancelService.closeOrder`, `LsyRefundService.createRefund/queryRefund`, `LsyCallbackService.notifyOrder` - Produces: - 7 个 POST 端点,路径见 Task 14 的 `OPENAPI_PATHS` - **构造器(7 参)**:`LeshouyingOpenController(LsyOrderService, LsyQueryService, LsyCancelService, LsyRefundService, LsyMerchantService, LsyOrderMapper, HttpServletRequest)` —— `HttpServletRequest` 是构造注入的请求作用域代理,写法与既有 `OpenapiController` 一致 - 私有查找方法**一律以 `Long tenantId` 作首参**:`findExisting(Long, String)`、`requireOrder(Long, String, String)` - `LsyRefundService.requireByOutRefundNo(Long tenantId, String outRefundNo)` > ### ★ 本任务的核心安全约束:显式租户隔离 > > **★★ 2026-09-21 lead 就地更正:原文「ApiKey 请求 = 无认证上下文」是**假**的**(实测 > `TenantApiKeyAuthFilter:222-231` 确实放了带 `tenantId` 的 `LoginUser`,拦截器**会**正常追加) > **但显式条件仍必须写** —— 它是**第二道**防线,唯一真正需要它的场景是「某路径漏加进白名单 > ⇒ 无认证对象 ⇒ 拦截器整条跳过」。所以下游每个按业务单号的查找都必须自己拼 > `.eq(LsyOrderEntity::getTenantId, tenantId)`,且 `tenantId` 只能来自 > `request.getAttribute("openapi.tenantId")`,**不得**读请求体里的租户字段;漏掉即跨租户越权。 - [ ] **Step 1: 写请求/响应 DTO** > ### ★ 租户可控的业务单号一律在**入口**加 `@Pattern`(本步 7 个 DTO 共 9 个字段) > > **为什么在入口堵,而不是在每个日志位点套 `flatten`**:`outTradeNo` / `outRefundNo` 会进 > `log.warn` / `log.error`(关单、撤销、下单失败等位点,Task 9 的 `撤销失败` 就是一处)。 > 下游租户可控 = **请求体里可以直接塞换行符**,把一条日志伪造成多条(CWE-117)。 > 模块里已有 `LsyLogSanitizer.flatten`,但它只在**部分**位点被调用 > (`LsyOrderService:438` 调用它的理由正是「租户可控」)。**只在部分位点堵 = 换个位点就绕过。** > 堵住 7 个 `@Valid @RequestBody` 端点,即一次覆盖全部日志位点 —— > 这一点是本计划对 Task 9 的 B-1/B-3 书面承诺的前提,**漏掉本步那两条承诺就不成立**。 > > **字符集不许另起一套**:必须与 `leshouying/util/LsyLogSanitizer`(模块内唯一一份 CWE-117 字符集) > **逐字一致** —— `\r`、`\n`、U+2028、U+2029、U+0085、VT(U+000B)、FF(U+000C)。 > 该 javadoc 已经写明它自己有**两处拷贝**并强调「改动其一必须同步另一处」, > 所以本步**不再新增 9 份拷贝**:正则与 message 都从 `LsyBizNoPattern` 取。 > > **正则用 `\x{...}` 写,绝不用 `\uXXXX`**:`LsyLogSanitizer` 的 javadoc 明确警告 —— > javac 在词法分析**之前**就还原 `\uXXXX`(JLS §3.3),源码行会被直接拆断、整模块编译失败。 > 写成 `"\\x{000A}"`(Java 字符串)→ 正则收到 `\x{000A}`,完全绕开这条规则。 > > **上界 `{0,64}` 的两点理由**:① 与列定义对齐(`out_trade_no VARCHAR(64)`, > 超长从「DB 报错 → 500」变成「入口 400」);② 用 `{0,64}` 而非 `{1,64}`, > 是因为 `LsyOrderQueryDTO` / `LsyRefundQueryDTO` 的字段**选填**,空串必须放行 > (必填由既有的 `@NotBlank` 管,两者不冲突)。 `LsyBizNoPattern.java`(**先建它**,下面 7 个 DTO 都要引用): ```java package com.payment.platform.module.payment.leshouying.util; /** * 租户可控业务单号(`out_trade_no` / `out_refund_no`)的**入口**校验字符集。 * * 【与 {@link LsyLogSanitizer} 的关系 —— 这是一份**引用**,不是拷贝】 * 本类只声明「哪些字符不许出现」,与 `LsyLogSanitizer.flatten` 压掉的集合**逐字一致**: * \r、\n、U+2028、U+2029、U+0085、VT(U+000B)、FF(U+000C)。 * 那 7 个字符为什么是这 7 个,理由全在 `LsyLogSanitizer` 的 javadoc 里,此处不重复。 * **改一处必须改另一处** —— 这正是 `@Pattern` 不内联正则、而要从这里取常量的原因。 * * 【为什么写成 `\x{...}` 而不是 `\uXXXX`】 * javac 在词法分析**之前**就还原 `\uXXXX`(JLS §3.3)。源码里直接写 `\u000A` * 会把源码行拆断、整模块编译失败;`\x{000A}` 是**正则引擎**层面的转义, * 不经过 javac 那条规则。 * * 【上界 64】与 `pay_lsy_order.out_trade_no VARCHAR(64)` / `pay_lsy_refund` 两列一致。 * 用 `{0,64}` 而非 `{1,64}`:两个查询 DTO 的字段是选填的,空串要放行。 */ public final class LsyBizNoPattern { /** 不含任何行终止符 / 控制字符,长度 0-64。供 `@Pattern(regexp = ...)` 用。 */ public static final String REGEXP = "^[^\\x{000A}\\x{000D}\\x{0085}\\x{2028}\\x{2029}\\x{000B}\\x{000C}]{0,64}$"; /** 与 {@link #REGEXP} 配套的提示语;各字段自行前缀字段名,如 `"out_trade_no " + MESSAGE`。 */ public static final String MESSAGE = "不能超过 64 位,且不得含换行符或控制字符"; private LsyBizNoPattern() { } } ``` `LsyBarcodePayDTO.java`: ```java package com.payment.platform.module.payment.leshouying.dto; import com.payment.platform.module.payment.leshouying.util.LsyBizNoPattern; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Positive; import lombok.Data; @Data public class LsyBarcodePayDTO { @NotBlank(message = "out_trade_no 不能为空") @Pattern(regexp = LsyBizNoPattern.REGEXP, message = "out_trade_no " + LsyBizNoPattern.MESSAGE) private String outTradeNo; @NotBlank(message = "enterprise_id 不能为空") private String enterpriseId; /** 用户付款码,18 位纯数字,以 10-15 开头 */ @NotBlank(message = "auth_code 不能为空") private String authCode; /** 金额,单位分 */ @NotNull @Positive(message = "total_amount 必须大于 0") private Long totalAmount; /** * 000 自动识别 / 300 支付宝 / 400 微信,默认 000。 * * ★ **选填**(null 由 `createBarcodeOrder` 里的 `StrUtil.blankToDefault(payType, AUTO)` 兜成 000), * 所以这里**不加** `@NotBlank`。但 `@Pattern` 要加 —— 理由同 `LsyH5CreateDTO.payType`: * 不支持的码会让 `LsyPayType.ofRequired` 抛 `IllegalArgumentException`, * 而 `GlobalExceptionHandler` 没有这个异常的映射,会落到兜底 → **HTTP 500** 而不是 400。 * `@Pattern` 对 **null 视为通过**,正好与「选填」一致,不会把默认值这条路堵死。 */ @Pattern(regexp = "^(000|300|400)$", message = "pay_type 只支持 000 自动识别 / 300 支付宝 / 400 微信") private String payType; private String orderBody; private String attach; private String notifyUrl; } ``` `LsyQrCreateDTO.java`: ```java package com.payment.platform.module.payment.leshouying.dto; import com.payment.platform.module.payment.leshouying.util.LsyBizNoPattern; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Positive; import lombok.Data; @Data public class LsyQrCreateDTO { @NotBlank @Pattern(regexp = LsyBizNoPattern.REGEXP, message = "out_trade_no " + LsyBizNoPattern.MESSAGE) private String outTradeNo; @NotBlank private String enterpriseId; @NotNull @Positive private Long totalAmount; private String orderBody; private String attach; private String notifyUrl; /** 有效期(分钟),默认 15,上限 4320(3天,上游硬限制) */ private Integer timeExpire; } ``` `LsyH5CreateDTO.java`: ```java package com.payment.platform.module.payment.leshouying.dto; import com.payment.platform.module.payment.leshouying.util.LsyBizNoPattern; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Positive; import lombok.Data; @Data public class LsyH5CreateDTO { @NotBlank @Pattern(regexp = LsyBizNoPattern.REGEXP, message = "out_trade_no " + LsyBizNoPattern.MESSAGE) private String outTradeNo; @NotBlank private String enterpriseId; @NotNull @Positive private Long totalAmount; /** * 必填:300 支付宝 / 400 微信。H5 不支持自动识别。 * * ★ `@Pattern` 不是装饰,是**错误码的归属**:`LsyPayType.ofRequired` 对不支持的码抛 * `IllegalArgumentException`,而 `GlobalExceptionHandler` **没有** `IllegalArgumentException` * 的映射(只有 `BusinessException` / `AccessDeniedException` / 参数校验 / 兜底 `Exception`), * 于是它会落到 `:53` 的兜底 → **HTTP 500「服务器内部错误」**。 * 调用方传个 `pay_type=600` 或拼错一个字母,就得到 500 —— 既误导调用方(像是我们崩了), * 又会把告警噪音灌进 5xx 指标,把真正的服务端故障埋掉。 * 用 `@Pattern` 拦在入口,走的是既有 `:35` 的 `MethodArgumentNotValidException` → **400**, * 与其它参数校验同一个出口、同一套 message 风格。 */ @NotBlank(message = "pay_type 必填(300 支付宝 / 400 微信)") @Pattern(regexp = "^(300|400)$", message = "pay_type 只支持 300 支付宝 / 400 微信") private String payType; private String orderBody; private String attach; private String notifyUrl; } ``` `LsyOrderQueryDTO.java`: ```java package com.payment.platform.module.payment.leshouying.dto; import com.payment.platform.module.payment.leshouying.util.LsyBizNoPattern; import jakarta.validation.constraints.Pattern; import lombok.Data; @Data public class LsyOrderQueryDTO { /** 与 tradeNo 二选一 */ @Pattern(regexp = LsyBizNoPattern.REGEXP, message = "out_trade_no " + LsyBizNoPattern.MESSAGE) private String outTradeNo; private String tradeNo; } ``` `LsyOrderCloseDTO.java`: ```java package com.payment.platform.module.payment.leshouying.dto; import com.payment.platform.module.payment.leshouying.util.LsyBizNoPattern; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; import lombok.Data; @Data public class LsyOrderCloseDTO { @NotBlank @Pattern(regexp = LsyBizNoPattern.REGEXP, message = "out_trade_no " + LsyBizNoPattern.MESSAGE) private String outTradeNo; } ``` `LsyRefundDTO.java`: ```java package com.payment.platform.module.payment.leshouying.dto; import com.payment.platform.module.payment.leshouying.util.LsyBizNoPattern; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Positive; import lombok.Data; @Data public class LsyRefundDTO { @NotBlank @Pattern(regexp = LsyBizNoPattern.REGEXP, message = "out_trade_no " + LsyBizNoPattern.MESSAGE) private String outTradeNo; @NotBlank @Pattern(regexp = LsyBizNoPattern.REGEXP, message = "out_refund_no " + LsyBizNoPattern.MESSAGE) private String outRefundNo; @NotNull @Positive private Long refundFee; } ``` `LsyRefundQueryDTO.java`: ```java package com.payment.platform.module.payment.leshouying.dto; import com.payment.platform.module.payment.leshouying.util.LsyBizNoPattern; import jakarta.validation.constraints.Pattern; import lombok.Data; @Data public class LsyRefundQueryDTO { @Pattern(regexp = LsyBizNoPattern.REGEXP, message = "out_refund_no " + LsyBizNoPattern.MESSAGE) private String outRefundNo; @Pattern(regexp = LsyBizNoPattern.REGEXP, message = "out_trade_no " + LsyBizNoPattern.MESSAGE) private String outTradeNo; } ``` `LsyPayResultVO.java`(下单统一响应): ```java package com.payment.platform.module.payment.leshouying.dto; import lombok.Data; @Data public class LsyPayResultVO { private String outTradeNo; /** * CREATED / PAYING / SUCCESS / FAIL / CANCELED / CLOSED / EXPIRED / PARTIAL_REFUNDED / REFUNDED。 * * ★ 主扫与 H5 的**下单**成功落 `PAYING`(不是 SUCCESS)—— 下单成功 ≠ 收款成功; * 只有被扫(用户当场出示付款码、我们当场提交)的 PAY_SUCCESS 才是真实支付结果,落 SUCCESS。 * ★ 主扫/H5 下单响应里的 `receiptAmount` 在用户付款前为 **null** —— 上游没给就是未知,不替它编数字。 */ private String status; private String tradeNo; private Long totalAmount; private Long receiptAmount; /** 主扫:二维码链接;H5:跳转链接。被扫为空 */ private String codeUrl; private String message; } ``` - [ ] **Step 2: 写失败的测试** ```java package com.payment.platform.module.payment.leshouying.controller.open; import com.payment.platform.module.payment.leshouying.dto.*; import com.payment.platform.module.payment.leshouying.entity.LsyMerchantEntity; import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity; import com.payment.platform.module.payment.leshouying.entity.LsyRefundEntity; import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper; import com.payment.platform.module.payment.leshouying.service.*; import com.payment.platform.common.exception.BusinessException; import jakarta.validation.Validation; import jakarta.validation.Validator; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.mock.web.MockHttpServletRequest; import java.util.Map; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class LeshouyingOpenControllerTest { private static final Long TENANT = 7L; @Mock private LsyOrderService orderService; @Mock private LsyQueryService queryService; @Mock private LsyCancelService cancelService; @Mock private LsyRefundService refundService; @Mock private LsyMerchantService merchantService; @Mock private LsyOrderMapper orderMapper; private MockHttpServletRequest request; private LeshouyingOpenController controller; @BeforeEach void setUp() { request = new MockHttpServletRequest(); request.setAttribute("openapi.tenantId", TENANT); // 模拟 TenantApiKeyAuthFilter 注入 request.setAttribute("openapi.apiKeyId", 10L); controller = new LeshouyingOpenController(orderService, queryService, cancelService, refundService, merchantService, orderMapper, request); } private LsyMerchantEntity merchant() { LsyMerchantEntity m = new LsyMerchantEntity(); m.setMchNo("Y52211090004"); return m; } private LsyPayResultDTO result(String status) { LsyPayResultDTO d = new LsyPayResultDTO(); d.setOutTradeNo("OUT001"); d.setStatus(status); d.setTotalAmount(100L); return d; } @Test void barcodePay_mapsDtoToEntityAndReturnsResult() { when(merchantService.requireByEnterprise(any(), eq("ENT1"))).thenReturn(merchant()); when(orderService.createBarcodeOrder(any(), eq("134567890123456789"), eq("000"))) .thenReturn(result("SUCCESS")); LsyBarcodePayDTO dto = new LsyBarcodePayDTO(); dto.setOutTradeNo("OUT001"); dto.setEnterpriseId("ENT1"); dto.setAuthCode("134567890123456789"); dto.setTotalAmount(100L); LsyPayResultVO vo = controller.barcodePay(dto); assertEquals("SUCCESS", vo.getStatus()); assertEquals("OUT001", vo.getOutTradeNo()); } @Test void barcodePay_duplicateOutTradeNo_returnsExistingWithoutNewUpstreamCall() { LsyOrderEntity existing = new LsyOrderEntity(); existing.setOutTradeNo("OUT001"); existing.setStatus("SUCCESS"); existing.setTradeNo("T001"); existing.setTotalAmount(100L); when(orderMapper.selectOne(any())).thenReturn(existing); LsyBarcodePayDTO dto = new LsyBarcodePayDTO(); dto.setOutTradeNo("OUT001"); dto.setEnterpriseId("ENT1"); dto.setAuthCode("134567890123456789"); dto.setTotalAmount(100L); LsyPayResultVO vo = controller.barcodePay(dto); assertEquals("SUCCESS", vo.getStatus()); verify(orderService, never()).createBarcodeOrder(any(), any(), any()); } @Test void h5Create_passesPayTypeThrough() { when(merchantService.requireByEnterprise(any(), any())).thenReturn(merchant()); when(orderService.createH5Order(any(), eq("400"))).thenReturn(result("SUCCESS")); LsyH5CreateDTO dto = new LsyH5CreateDTO(); dto.setOutTradeNo("OUT002"); dto.setEnterpriseId("ENT1"); dto.setTotalAmount(200L); dto.setPayType("400"); controller.h5Create(dto); verify(orderService).createH5Order(any(), eq("400")); } @Test void barcodePay_missingTenantAttribute_throws401() { MockHttpServletRequest anon = new MockHttpServletRequest(); LeshouyingOpenController c = new LeshouyingOpenController(orderService, queryService, cancelService, refundService, merchantService, orderMapper, anon); LsyBarcodePayDTO dto = new LsyBarcodePayDTO(); dto.setOutTradeNo("OUT001"); dto.setEnterpriseId("ENT1"); dto.setAuthCode("134567890123456789"); dto.setTotalAmount(100L); assertThrows(BusinessException.class, () -> c.barcodePay(dto)); } @Test void orderQuery_orderNotVisibleInThisTenant_throws404WithoutHittingUpstream() { // 别的租户的单 / 不存在的单,在本租户视角下都必须是 404, // 且不得拿着别人的单号去查上游(那本身就是一次越权信息探测)。 when(orderMapper.selectOne(any())).thenReturn(null); LsyOrderQueryDTO dto = new LsyOrderQueryDTO(); dto.setOutTradeNo("OUT_OF_OTHER_TENANT"); BusinessException e = assertThrows(BusinessException.class, () -> controller.orderQuery(dto)); assertEquals(404, e.getCode()); verify(queryService, never()).queryOrder(any()); } @Test void orderClose_orderNotVisibleInThisTenant_throws404WithoutClosingAnything() { when(orderMapper.selectOne(any())).thenReturn(null); LsyOrderCloseDTO dto = new LsyOrderCloseDTO(); dto.setOutTradeNo("OUT_OF_OTHER_TENANT"); assertThrows(BusinessException.class, () -> controller.orderClose(dto)); verify(cancelService, never()).closeOrder(any()); } // ── DTO 校验:本类其它测试**全都证明不了** `@Pattern` 挂上去了 ──────────────── // 上面每条都是 `controller.barcodePay(dto)` 这样的**普通方法直调**, // Spring 的参数校验压根不参与;把 `@Pattern` 整行删掉,上面 6 条照样全绿。 // 所以要用 `Validation.buildDefaultValidatorFactory()` 直接跑注解本身 —— // 不启 Spring 上下文,与 `AlipayBatchPayServiceTest` 里 // `batchCreateDTO_transferSceneNameBlank_failsValidation` 同一套写法 // (那条已实测通过,Hibernate Validator 8.0.1.Final 在单测 classpath 上)。 @Test void h5CreateDTO_unsupportedPayType_failsValidation() { // H5 不支持自动识别(000)。放行的话 `LsyPayType.ofRequired("000")` 抛 // IllegalArgumentException,而 GlobalExceptionHandler 没有它的映射 // ⇒ 落到兜底的 Exception ⇒ HTTP 500 而不是 400。 LsyH5CreateDTO dto = new LsyH5CreateDTO(); dto.setOutTradeNo("OUT002"); dto.setEnterpriseId("ENT1"); dto.setTotalAmount(200L); dto.setPayType("000"); assertTrue(validator().validate(dto).stream() .anyMatch(v -> "payType".equals(v.getPropertyPath().toString())), "H5 的 pay_type=000 应被 @Pattern 拦下(否则是 500 不是 400)"); } @Test void barcodeDTO_unsupportedPayType_failsValidation() { // barcode 侧的白名单是 000|300|400,乱填的码同样要停在 400。 LsyBarcodePayDTO dto = new LsyBarcodePayDTO(); dto.setOutTradeNo("OUT001"); dto.setEnterpriseId("ENT1"); dto.setAuthCode("134567890123456789"); dto.setTotalAmount(100L); dto.setPayType("999"); assertTrue(validator().validate(dto).stream() .anyMatch(v -> "payType".equals(v.getPropertyPath().toString())), "pay_type=999 应被 @Pattern 拦下"); } @Test void barcodeDTO_nullPayType_passesValidation() { // ★ 这条钉的是一个**取舍**,不是一段逻辑:barcode 的 pay_type 是选填 // (null ⇒ `StrUtil.blankToDefault(payType, AUTO)` 兜成 000),所以那里 // **故意不加** `@NotBlank`。`@Pattern` 对 null 视为通过,两条正好兼容。 // 哪天有人「顺手补个 @NotBlank」,默认 000 的路径就被堵死了 —— 这条会红。 // 断言的是「payType 上无违规」而**不是** `violations.isEmpty()`: // DTO 里还有别的 @NotBlank 字段,这条只关心 payType。 LsyBarcodePayDTO dto = new LsyBarcodePayDTO(); dto.setOutTradeNo("OUT001"); dto.setEnterpriseId("ENT1"); dto.setAuthCode("134567890123456789"); dto.setTotalAmount(100L); dto.setPayType(null); assertTrue(validator().validate(dto).stream() .noneMatch(v -> "payType".equals(v.getPropertyPath().toString())), "pay_type=null(选填)不得被判违规,否则默认 000 的路径被堵死"); } private static Validator validator() { return Validation.buildDefaultValidatorFactory().getValidator(); } } // ⚠️ 说明:`tenant_id` 条件是否真的拼进了 WHERE 子句,**无法用 Mockito 单测证明**。 // // ★ 但**理由不是**「`getSqlSegment()` 不可用」—— 那样说与本计划的 Task 5 自相矛盾: // Task 5 的两条 `LsyOrderServiceStateTest` 用例正是在**纯 Mockito 单测**里调 // `getSqlSegment()` 并期望通过。Task 5 预检时用真探针在 MP 3.5.5 上实测过: // 用**字符串列名**构造的 `UpdateWrapper`,`getSqlSegment()` 直接返回可用 SQL 段 // (`(id = #{ew.paramNameValuePairs.MPGENVAL1} AND status IN (...))`),**不需要 TableInfo**。 // (需要 TableInfo 的是走 `lambda()` 的写法 —— 那条路要把 `SFunction` 反解成列名。 // 此处**未实测**,不作为结论使用,写在这里只是防止有人再把它当成「所有 wrapper 都炸」。) // // 真正的原因是:**`tenant_id` 这个条件根本不在控制器构造的 wrapper 里。** // 它由 `TenantInnerInterceptor` 在 SQL 真正下发的最后一刻追加,而 Mockito 里的 mapper // 是个 mock,MyBatis 的插件链路**一步都不执行**。所以单测能断言的只有 // 「控制器自己拼了什么」,断言不到「拦截器加了什么」—— 而后者才是这条防线的主体。 // 换句话说:即使把 wrapper 断言写得完美,它证明的也**不是**你想证明的那件事。 // // 因此这条防线靠两件事守:(1) `findExisting`/`requireOrder`/`requireByOutRefundNo` // 的 `tenantId` 是**必传第一个参数**,漏传编译不过;(2) Task 18 之后在真实 // Spring 上下文里跑一次 `/order/query`,把 SQL 日志打开确认 WHERE 里有 tenant_id。 // 已登记在文末「交付后仍需联调确认」。 ``` - [ ] **Step 3: 运行测试确认失败** ```bash cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LeshouyingOpenControllerTest ``` Expected: 编译失败。 - [ ] **Step 4: 实现 LeshouyingOpenController** ```java package com.payment.platform.module.payment.leshouying.controller.open; import cn.hutool.core.util.IdUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.payment.platform.common.exception.BusinessException; import com.payment.platform.common.response.Result; import com.payment.platform.module.payment.leshouying.dto.*; import com.payment.platform.module.payment.leshouying.entity.LsyMerchantEntity; import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity; import com.payment.platform.module.payment.leshouying.entity.LsyRefundEntity; import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper; import com.payment.platform.module.payment.leshouying.service.*; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.*; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.LinkedHashMap; import java.util.Map; /** * 乐收赢下游 OpenAPI —— 供平台租户调用。 * * 认证:TenantApiKeyAuthFilter(Authorization: ApiKey + Signature HMAC-SHA256)。 * ★ 这 7 个路径逐条列于 TenantApiKeyAuthFilter.OPENAPI_ACCOUNT_PATHS(清单一致性); * 认证判定按**前缀**(OPENAPI_PREFIXES),**不读**本数组 ⇒ 漏列不会失去认证。 * * 租户身份从 request attribute 取(由 filter 注入),不信任请求体里的 tenant_id。 */ @Slf4j @RestController @RequestMapping("/payment/openapi/leshouying") @RequiredArgsConstructor public class LeshouyingOpenController { private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); private final LsyOrderService orderService; private final LsyQueryService queryService; private final LsyCancelService cancelService; private final LsyRefundService refundService; private final LsyMerchantService merchantService; private final LsyOrderMapper orderMapper; /** 请求作用域代理 —— 与既有 OpenapiController 同一写法,从 attribute 取认证上下文 */ private final HttpServletRequest request; @PostMapping("/barcode/pay") public LsyPayResultVO barcodePay(@Valid @RequestBody LsyBarcodePayDTO dto) { Long tenantId = tenantId(); LsyOrderEntity existing = findExisting(tenantId, dto.getOutTradeNo()); if (existing != null) { return toVo(existing, "订单已存在,返回原结果"); } LsyOrderEntity order = newOrder(dto.getOutTradeNo(), tenantId, apiKeyId(), "BARCODE", "000"); order.setEnterpriseId(dto.getEnterpriseId()); order.setTotalAmount(dto.getTotalAmount()); order.setOrderBody(dto.getOrderBody()); order.setAttach(dto.getAttach()); order.setNotifyUrl(dto.getNotifyUrl()); LsyMerchantEntity merchant = merchantService.requireByEnterprise( tenantId, dto.getEnterpriseId()); order.setMchNo(merchant.getMchNo()); order.setShopNo(merchant.getShopNo()); // 注意:auth_code 不写进订单实体(不落库),只透传给上游 return toVo(orderService.createBarcodeOrder( order, dto.getAuthCode(), dto.getPayType()), null); } @PostMapping("/qrcode/create") public LsyPayResultVO qrcodeCreate(@Valid @RequestBody LsyQrCreateDTO dto) { Long tenantId = tenantId(); LsyOrderEntity existing = findExisting(tenantId, dto.getOutTradeNo()); if (existing != null) { return toVo(existing, "订单已存在,返回原结果"); } LsyOrderEntity order = newOrder(dto.getOutTradeNo(), tenantId, apiKeyId(), "QRCODE", null); order.setEnterpriseId(dto.getEnterpriseId()); order.setTotalAmount(dto.getTotalAmount()); order.setOrderBody(dto.getOrderBody()); order.setAttach(dto.getAttach()); order.setNotifyUrl(dto.getNotifyUrl()); LsyMerchantEntity merchant = merchantService.requireByEnterprise( tenantId, dto.getEnterpriseId()); order.setMchNo(merchant.getMchNo()); order.setShopNo(merchant.getShopNo()); return toVo(orderService.createQrOrder(order, dto.getTimeExpire()), null); } @PostMapping("/h5/create") public LsyPayResultVO h5Create(@Valid @RequestBody LsyH5CreateDTO dto) { Long tenantId = tenantId(); LsyOrderEntity existing = findExisting(tenantId, dto.getOutTradeNo()); if (existing != null) { return toVo(existing, "订单已存在,返回原结果"); } LsyOrderEntity order = newOrder(dto.getOutTradeNo(), tenantId, apiKeyId(), "H5", dto.getPayType()); order.setEnterpriseId(dto.getEnterpriseId()); order.setTotalAmount(dto.getTotalAmount()); order.setOrderBody(dto.getOrderBody()); order.setAttach(dto.getAttach()); order.setNotifyUrl(dto.getNotifyUrl()); LsyMerchantEntity merchant = merchantService.requireByEnterprise( tenantId, dto.getEnterpriseId()); order.setMchNo(merchant.getMchNo()); order.setShopNo(merchant.getShopNo()); return toVo(orderService.createH5Order(order, dto.getPayType()), null); } @PostMapping("/order/query") public Result> orderQuery(@Valid @RequestBody LsyOrderQueryDTO dto) { Long tenantId = tenantId(); LsyOrderEntity order = requireOrder(tenantId, dto.getOutTradeNo(), dto.getTradeNo()); Map upstream = queryService.queryOrder(order); // 本地状态为准(回调可能比查询更早到达);上游报文一并返回便于对账 Map data = new LinkedHashMap<>(); data.put("local_status", order.getStatus()); data.put("trade_no", order.getTradeNo()); data.put("upstream", upstream); return Result.ok(data); } /** * 撤销/关单 —— 按 pay_scene 内部路由。 * 被扫走 cancelorder(先查后撤:已支付则转退款);主扫走 qrclose;H5 无上游关单。 */ @PostMapping("/order/close") public Result> orderClose(@Valid @RequestBody LsyOrderCloseDTO dto) { Long tenantId = tenantId(); LsyOrderEntity order = requireOrder(tenantId, dto.getOutTradeNo(), null); String target = cancelService.closeOrder(order); if (target == null) { return Result.fail(500, "上游拒绝撤销/关单,请稍后重试"); } Map data = new LinkedHashMap<>(); if ("SUCCESS".equals(target)) { data.put("status", order.getStatus()); data.put("message", "该订单已支付,已转退款流程,请调用退款接口"); return Result.ok(data); } order.setStatus(target); orderMapper.updateById(order); data.put("status", target); return Result.ok(data); } @PostMapping("/refund") public Result> refund(@Valid @RequestBody LsyRefundDTO dto) { Long tenantId = tenantId(); LsyOrderEntity order = requireOrder(tenantId, dto.getOutTradeNo(), null); LsyRefundEntity refund = refundService.createRefund( order, dto.getOutRefundNo(), dto.getRefundFee()); Map data = new LinkedHashMap<>(); data.put("out_refund_no", refund.getOutRefundNo()); data.put("refund_no", refund.getRefundNo()); data.put("status", refund.getStatus()); return Result.ok(data); } @PostMapping("/refund/query") public Result> refundQuery(@Valid @RequestBody LsyRefundQueryDTO dto) { Long tenantId = tenantId(); LsyRefundEntity refund = refundService.requireByOutRefundNo(tenantId, dto.getOutRefundNo()); Map upstream = refundService.queryRefund(refund); Map data = new LinkedHashMap<>(); data.put("local_status", refund.getStatus()); data.put("upstream", upstream); return Result.ok(data); } // ==================== 私有 ==================== /** tenantId 是租户边界的唯一来源:不信任请求体里的任何 tenants 字段。 */ private Long tenantId() { Object v = request.getAttribute("openapi.tenantId"); if (!(v instanceof Long)) { throw new BusinessException(401, "缺少 API Key 认证上下文"); } return (Long) v; } private Long apiKeyId() { Object v = request.getAttribute("openapi.apiKeyId"); return v instanceof Long ? (Long) v : null; } private LsyOrderEntity newOrder(String outTradeNo, Long tenantId, Long apiKeyId, String scene, String payType) { LsyOrderEntity o = new LsyOrderEntity(); o.setTenantId(tenantId); o.setApiKeyId(apiKeyId); o.setOutTradeNo(outTradeNo); o.setPayScene(scene); o.setPayType(payType); o.setPayTraceNo(IdUtil.fastSimpleUUID()); o.setPayTime(LocalDateTime.now().format(FMT)); o.setStatus("CREATED"); o.setRefundedAmount(0L); o.setQueryCount(0); o.setNotifyStatus("PENDING"); o.setNotifyCount(0); orderMapper.insert(o); return o; } /** * 幂等查找:同 out_trade_no 重复下单返回原结果而非新建。 * ★ 必须带 tenantId —— 不能只靠租户拦截器:它只在 filter 白名单覆盖到本路径时才生效, * 漏配即整条跳过;漏掉这一条会让租户 B 拿到租户 A 的订单。 */ private LsyOrderEntity findExisting(Long tenantId, String outTradeNo) { if (outTradeNo == null) { return null; } return orderMapper.selectOne(new LambdaQueryWrapper() .eq(LsyOrderEntity::getTenantId, tenantId) .eq(LsyOrderEntity::getOutTradeNo, outTradeNo) .last("LIMIT 1")); } /** ★ 同样必须带 tenantId:否则可查/可关别的租户的订单。 */ private LsyOrderEntity requireOrder(Long tenantId, String outTradeNo, String tradeNo) { LambdaQueryWrapper w = new LambdaQueryWrapper<>(); w.eq(LsyOrderEntity::getTenantId, tenantId); if (outTradeNo != null) { w.eq(LsyOrderEntity::getOutTradeNo, outTradeNo); } else if (tradeNo != null) { w.eq(LsyOrderEntity::getTradeNo, tradeNo); } else { throw new BusinessException(400, "out_trade_no 与 trade_no 至少提供一个"); } LsyOrderEntity o = orderMapper.selectOne(w.last("LIMIT 1")); if (o == null) { throw new BusinessException(404, "订单不存在"); } return o; } private LsyPayResultVO toVo(LsyOrderEntity order, String msg) { LsyPayResultVO vo = new LsyPayResultVO(); vo.setOutTradeNo(order.getOutTradeNo()); vo.setStatus(order.getStatus()); vo.setTradeNo(order.getTradeNo()); vo.setTotalAmount(order.getTotalAmount()); vo.setReceiptAmount(order.getReceiptAmount()); vo.setCodeUrl(order.getCodeUrl()); vo.setMessage(msg); return vo; } private LsyPayResultVO toVo(LsyPayResultDTO dto, String msg) { LsyPayResultVO vo = new LsyPayResultVO(); vo.setOutTradeNo(dto.getOutTradeNo()); vo.setStatus(dto.getStatus()); vo.setTradeNo(dto.getTradeNo()); vo.setTotalAmount(dto.getTotalAmount()); vo.setReceiptAmount(dto.getReceiptAmount()); vo.setCodeUrl(dto.getCodeUrl()); vo.setMessage(msg == null ? dto.getMessage() : msg); return vo; } } ``` - [ ] **Step 5: 给 LsyRefundService 补 requireByOutRefundNo** 在 `LsyRefundService` 中追加(`LsyRefundMapper` 已在构造注入)。 **`tenantId` 是必传参数,不是可选项** —— 见下面「跨租户隔离」说明: ```java /** 下游 OpenAPI 专用:必须带 tenantId 限定,否则会读到别的租户的退款单。 */ public LsyRefundEntity requireByOutRefundNo(Long tenantId, String outRefundNo) { if (cn.hutool.core.util.StrUtil.isBlank(outRefundNo)) { throw new com.payment.platform.common.exception.BusinessException(400, "out_refund_no 不能为空"); } LsyRefundEntity r = refundMapper.selectOne( new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() .eq(LsyRefundEntity::getTenantId, tenantId) .eq(LsyRefundEntity::getOutRefundNo, outRefundNo) .last("LIMIT 1")); if (r == null) { throw new com.payment.platform.common.exception.BusinessException(404, "退款单不存在"); } return r; } ``` > ### ★ 跨租户隔离:本控制器**不依赖** `TenantInnerInterceptor`,必须自己带租户条件 > > **★★ 2026-09-21 由 lead 就地更正(原文的机理写错了,实测推翻)。原文见下方引用块。** > > **实测事实(三条独立证据)**: > 1. `TenantApiKeyAuthFilter:222-231` **确实**往 `SecurityContextHolder` 放了一个 > **带 `tenantId` 的 `LoginUser`**(第 219-221 行的注释还专门写明「principal 必须是 `LoginUser` > (带 tenantId)而非裸 Long」,理由是 `pay_account` 等既有表要靠它取到正确租户)。 > ⇒ **ApiKey 请求有认证上下文,不是「无认证」。** > 2. `TenantInnerInterceptor` 的判别式是 **`auth.getPrincipal() instanceof LoginUser`**(`:151`、`:168`)。 > 命中该分支且 `tenantId != null` ⇒ `ignoreTable` 返回 **`false`** ⇒ **照常追加** > `AND tenant_id = <该 ApiKey 的租户>`。 > 3. 测试 `TenantInnerInterceptorTest.ignoreTable_authenticatedTenantUser_stillFiltersLsyTables` > 用 `tenantId=7L` 的 `LoginUser` 断言四张 `pay_lsy_*` 表**全部返回 `false`** —— 钉住的就是这个分支。 > > **⇒ 显式租户条件不是「唯一防线」,是「第二道防线」;但它仍然必须写,理由有三条:** > 1. **拦截器的保护以「filter 已经跑过」为前提。** 若某条路径漏加进 `OPENAPI_ACCOUNT_PATHS` > (正是 Task 14 要防的那个静默故障),该请求就**没有**认证对象 ⇒ `ignoreTable` 走无认证分支 > 返回 `true` ⇒ **整条跳过租户过滤**。此时显式条件是唯一的兜底。 > (该情形下 `openapi.tenantId` 属性也为 null ⇒ 控制器应 401,两重保护。) > 2. **该行为依赖一个非常窄的分支**(`principal instanceof LoginUser`)。 > 任何对 filter principal 类型的改动(例如图省事塞个裸 `Long`)都会**静默**改变它。 > 3. 与既有 `OpenapiService` 的做法一致(`w.eq(OpenTransferEntity::getTenantId, tenantId)`)。 > > **⇒ 硬要求不变:所有下游按业务单号的查询,一律显式 `tenantId(request)` + `.eq(getTenantId, ...)`。** > 与既有做法一致,且是上面第 1 条的兜底。 > > **唯一的例外是 `LsyMerchantService.requireByMchNo`** —— 它只服务回调路径(真正的无认证上下文, > 手上只有 `mch_no`,没有 `tenant_id`),`mch_no` 在上游全局唯一,用它反查是设计如此。 > (2026-09-21 实测更正:该方法**当前零生产调用点**,`grep -rn "requireByMchNo|getByMchNo" src` > 只命中定义、其单测、以及 `TenantInnerInterceptor:117` 的注释。此处写的是**将来**的用法约束。) > >
原文(**机理部分是错的,保留供追溯**) > > ~~4 张 `pay_lsy_*` 表加进了 `CONDITIONAL_TABLES`,含义是「**无认证上下文时跳过租户过滤**」。 > 而 OpenAPI 请求走的是 `TenantApiKeyAuthFilter`,它**不往 SecurityContext 里放认证对象** —— > 所以在 `TenantInnerInterceptor.getTenantId()` 眼里这是「无认证」, > `ignoreTable` 返回 `true`,**`tenant_id` 条件根本不会被追加**。 > > 后果:租户 B 用租户 A 已用过的 `out_trade_no` 调 `/order/query`、`/order/close`、`/refund/query`, > 就能读到甚至操作 A 的订单/退款单 —— 跨租户越权。~~ > >
- [ ] **Step 6: 运行测试确认全绿** ```bash cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LeshouyingOpenControllerTest ``` Expected: `Tests run: 9, Failures: 0, Errors: 0` (9 = 控制器 6 条 + DTO 校验 3 条。后 3 条**不是覆盖率凑数**:另外 6 条全是方法直调, `@Valid` 不参与,把两个 DTO 上的 `@Pattern` 整行删掉它们照样全绿 —— 也就是说这 3 条是「错误的 `pay_type` 返回 400 而不是 500」这条契约的**唯一**执行点。) - [ ] **Step 7: 确认工作区状态(不提交)** ```bash git status --short ``` --- ## Task 16: 两个调度任务 **Files:** - Create: `java/src/main/java/com/payment/platform/module/payment/leshouying/scheduler/LsyOrderPollScheduler.java` - Create: `java/src/main/java/com/payment/platform/module/payment/leshouying/scheduler/LsyReconcileScheduler.java` - Test: `java/src/test/java/com/payment/platform/module/payment/leshouying/scheduler/LsySchedulerTest.java` **Interfaces:** - Consumes: `LsyOrderMapper`, `LsyQueryService`, `LsyCancelService`, `LsyOrderService.settleFromQuery`(Task 6 已定义), `LsyCallbackService`, `LeshouyingProperties` - Produces: - `void LsyOrderPollScheduler.pollBarcodeOrders()` - `void LsyReconcileScheduler.reconcile()` > 两个调度器**都不注入 `LeshouyingResponseParser`**:报文怎么解释是 `LsyOrderService.settleFromQuery` > 的职责。调度器只决定「何时查、查几次、耗尽后怎么办」。 - [ ] **Step 1: 写失败的测试** ```java package com.payment.platform.module.payment.leshouying.scheduler; import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties; import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity; import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper; import com.payment.platform.module.payment.leshouying.service.*; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class LsySchedulerTest { @Mock private LsyOrderMapper orderMapper; @Mock private LsyQueryService queryService; @Mock private LsyCancelService cancelService; @Mock private LsyCallbackService callbackService; @Mock private LsyOrderService orderService; private LeshouyingProperties props; @BeforeEach void setUp() { props = new LeshouyingProperties(); props.setBarcodePollMaxTimes(15); props.setBarcodePollIntervalMs(0); } private LsyOrderEntity paying(int queryCount) { LsyOrderEntity o = new LsyOrderEntity(); o.setId(1L); o.setOutTradeNo("OUT001"); o.setPayScene("BARCODE"); o.setStatus("PAYING"); o.setQueryCount(queryCount); o.setMchNo("Y52211090004"); return o; } private Map resp(String rc) { Map m = new LinkedHashMap<>(); m.put("return_code", "SUCCESS"); m.put("result_code", rc); return m; } @Test void pollBarcode_singleFailure_doesNotInterruptOthers() { LsyOrderEntity o1 = paying(0); LsyOrderEntity o2 = paying(0); o2.setId(2L); when(orderMapper.selectList(any())).thenReturn(List.of(o1, o2)); when(queryService.queryOrder(o1)).thenThrow(new RuntimeException("网络超时")); when(queryService.queryOrder(o2)).thenReturn(resp("PAY_SUCCESS")); when(orderService.settleFromQuery(eq(o2), any())).thenReturn("SUCCESS"); LsyOrderPollScheduler scheduler = new LsyOrderPollScheduler( orderMapper, queryService, cancelService, callbackService, orderService, props); assertDoesNotThrow(scheduler::pollBarcodeOrders); verify(queryService).queryOrder(o2); } @Test void pollBarcode_success_updatesAndNotifiesDownstream() { LsyOrderEntity o = paying(0); when(orderMapper.selectList(any())).thenReturn(List.of(o)); when(queryService.queryOrder(o)).thenReturn(resp("PAY_SUCCESS")); when(orderService.settleFromQuery(eq(o), any())).thenReturn("SUCCESS"); new LsyOrderPollScheduler(orderMapper, queryService, cancelService, callbackService, orderService, props) .pollBarcodeOrders(); verify(callbackService).notifyOrder(o); } @Test void pollBarcode_stillPaying_doesNotNotify_andIncrementsQueryCount() { LsyOrderEntity o = paying(0); when(orderMapper.selectList(any())).thenReturn(List.of(o)); when(queryService.queryOrder(o)).thenReturn(resp("PAY_IN_PROCESS")); when(orderService.settleFromQuery(eq(o), any())).thenReturn(null); // 未流转 new LsyOrderPollScheduler(orderMapper, queryService, cancelService, callbackService, orderService, props) .pollBarcodeOrders(); verify(callbackService, never()).notifyOrder(any()); var captor = org.mockito.ArgumentCaptor.forClass(LsyOrderEntity.class); verify(orderMapper).updateById(captor.capture()); assertEquals(1, captor.getValue().getQueryCount()); assertNotNull(captor.getValue().getNextQueryTime()); } @Test void pollBarcode_exhausted_triggersAutoCancel() { LsyOrderEntity o = paying(15); // 已达上限 when(orderMapper.selectList(any())).thenReturn(List.of(o)); when(queryService.queryOrder(o)).thenReturn(resp("PAY_IN_PROCESS")); when(orderService.settleFromQuery(eq(o), any())).thenReturn(null); // 仍未流转 when(orderMapper.selectById(1L)).thenReturn(paying(15)); // 复查:仍是 PAYING when(cancelService.closeOrder(o)).thenReturn("CANCELED"); new LsyOrderPollScheduler(orderMapper, queryService, cancelService, callbackService, orderService, props) .pollBarcodeOrders(); verify(cancelService).closeOrder(o); verify(callbackService).notifyOrder(o); } @Test void pollBarcode_exhaustedButCallbackAlreadyWon_doesNotCancel() { LsyOrderEntity o = paying(15); when(orderMapper.selectList(any())).thenReturn(List.of(o)); when(queryService.queryOrder(o)).thenReturn(resp("PAY_SUCCESS")); // 回调抢先写了 SUCCESS → 条件更新命中 0 行 → settleFromQuery 返回 null when(orderService.settleFromQuery(eq(o), any())).thenReturn(null); LsyOrderEntity won = paying(15); won.setStatus("SUCCESS"); // DB 里已是终态 when(orderMapper.selectById(1L)).thenReturn(won); new LsyOrderPollScheduler(orderMapper, queryService, cancelService, callbackService, orderService, props) .pollBarcodeOrders(); verify(cancelService, never()).closeOrder(any()); verify(callbackService, never()).notifyOrder(any()); } @Test void pollBarcode_emptyList_doesNothing() { when(orderMapper.selectList(any())).thenReturn(List.of()); LsyOrderPollScheduler scheduler = new LsyOrderPollScheduler( orderMapper, queryService, cancelService, callbackService, orderService, props); scheduler.pollBarcodeOrders(); verify(queryService, never()).queryOrder(any()); } @Test void reconcile_canceledTargetNull_doesNotOverwriteStatus() { LsyOrderEntity o = paying(0); o.setPayScene("QRCODE"); o.setCodeUrl("https://x"); when(cancelService.closeOrder(o)).thenReturn(null); LsyReconcileScheduler scheduler = new LsyReconcileScheduler( orderMapper, queryService, cancelService, callbackService, orderService, props); assertDoesNotThrow(() -> scheduler.applyCloseResult(o)); verify(orderMapper, never()).updateById(any()); } @Test void reconcile_pendingOrder_settlesViaOrderService() { LsyOrderEntity o = paying(0); o.setPayScene("QRCODE"); when(orderMapper.selectList(any())).thenReturn(List.of(o)); when(queryService.queryOrder(o)).thenReturn(resp("PAY_SUCCESS")); when(orderService.settleFromQuery(eq(o), any())).thenReturn("SUCCESS"); LsyReconcileScheduler scheduler = new LsyReconcileScheduler( orderMapper, queryService, cancelService, callbackService, orderService, props); scheduler.reconcilePendingOrders(); verify(callbackService).notifyOrder(o); } } ``` Expected: `Tests run: 8, Failures: 0, Errors: 0` - [ ] **Step 2: 运行测试确认失败** ```bash cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LsySchedulerTest ``` Expected: 编译失败。 - [ ] **Step 3: 实现 LsyOrderPollScheduler** ```java package com.payment.platform.module.payment.leshouying.scheduler; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties; import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity; import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper; import com.payment.platform.module.payment.leshouying.service.LsyCallbackService; import com.payment.platform.module.payment.leshouying.service.LsyCancelService; import com.payment.platform.module.payment.leshouying.service.LsyOrderService; import com.payment.platform.module.payment.leshouying.service.LsyQueryService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import java.time.OffsetDateTime; import java.util.List; import java.util.Map; /** * 被扫订单密集轮询 + 超时撤销。 * * 上游规定的节奏是「5 秒后开始,15 次,间隔 5 秒,最长 ~80 秒」。 * 我们的同步 3 次(6 秒)叠加在前,是用户体验优化,量级一致。 * * 【不做状态判断,只做调度】上游报文怎么解释、状态怎么落库,全部交给 * LsyOrderService.settleFromQuery —— 它是全模块唯一的状态写入口。 * 本类只负责「什么时候查」「查多少次」「查完要不要撤销」。 * * 单条异常 catch 记日志不中断 —— try/catch 必须放在**循环内**(放循环外会让第一笔失败吃掉整批)。 * 既有同形状参考 `BatchStatusPollScheduler.pollPendingBatches`,但本类**刻意**有一处不同: * 那里打 `e.getMessage()`,本类把 `e` 整个交给日志。理由是堆栈 —— 本模块的失败多为上游协议异常, * `cause`(如 JSONException)是分辨走哪条分支的唯一线索(见 Task 2 的两条 PARSE_ERROR 分支)。 * 这么写是安全的,依据是已核实的事实:`LeshouyingApiException` **不重写 `toString()`**, * 因此 `log.error(..., e)` 只打印经过 sanitize 的 `getMessage()` 加栈帧, * `rawJson` / `rawReturnMsg` 不会随栈泄漏。 * ★ 反过来说:**谁给该异常加一个把 Raw 字段拼进 `toString()` 的重写, * 本行以及全模块所有 `log.error(..., e)` 站点会一起变成泄漏点。** 别加。 */ @Slf4j @Component @RequiredArgsConstructor public class LsyOrderPollScheduler { private final LsyOrderMapper orderMapper; private final LsyQueryService queryService; private final LsyCancelService cancelService; private final LsyCallbackService callbackService; private final LsyOrderService orderService; private final LeshouyingProperties props; @Scheduled(fixedDelay = 30 * 1000) public void pollBarcodeOrders() { List orders = orderMapper.selectList( new LambdaQueryWrapper() .eq(LsyOrderEntity::getStatus, "PAYING") .eq(LsyOrderEntity::getPayScene, "BARCODE") .le(LsyOrderEntity::getNextQueryTime, OffsetDateTime.now()) .orderByAsc(LsyOrderEntity::getNextQueryTime) .last("LIMIT 100")); if (orders.isEmpty()) { return; } log.info("被扫订单轮询: {} 条", orders.size()); for (LsyOrderEntity order : orders) { try { pollOne(order); } catch (Exception e) { log.error("被扫订单轮询异常: outTradeNo={}", order.getOutTradeNo(), e); } } } void pollOne(LsyOrderEntity order) { int count = order.getQueryCount() == null ? 0 : order.getQueryCount(); Map resp = queryService.queryOrder(order); // 已终态:settleFromQuery 返回非 null String settled = orderService.settleFromQuery(order, resp); if (settled != null) { log.info("被扫订单轮询确认结果: outTradeNo={}, status={}", order.getOutTradeNo(), settled); callbackService.notifyOrder(order); return; } count++; if (count >= props.getBarcodePollMaxTimes()) { // 【撤销前必须复查】settleFromQuery 返回 null 有两种含义: // (a) 上游仍是支付中 (b) 回调已抢先写了终态,条件更新命中 0 行 // 内存里的 order 在 (b) 情况下仍是 PAYING,据此撤销会去关一张已付款的单。 // 复查一次 DB 状态,非 PAYING 就放弃撤销。 LsyOrderEntity fresh = orderMapper.selectById(order.getId()); if (fresh == null || !"PAYING".equals(fresh.getStatus())) { log.info("轮询耗尽但订单已非 PAYING,跳过撤销: outTradeNo={}, status={}", order.getOutTradeNo(), fresh == null ? "null" : fresh.getStatus()); return; } log.warn("被扫订单轮询耗尽({}次),触发自动撤销: outTradeNo={}", count, order.getOutTradeNo()); String target = cancelService.closeOrder(order); if (target != null) { LsyOrderEntity patch = new LsyOrderEntity(); patch.setId(order.getId()); patch.setStatus(target); patch.setUpdatedTime(OffsetDateTime.now()); orderMapper.updateById(patch); order.setStatus(target); callbackService.notifyOrder(order); } return; } LsyOrderEntity patch = new LsyOrderEntity(); patch.setId(order.getId()); patch.setQueryCount(count); patch.setLastQueryTime(OffsetDateTime.now()); patch.setNextQueryTime(OffsetDateTime.now() .plusSeconds(props.getBarcodePollIntervalMs() / 1000)); patch.setUpdatedTime(OffsetDateTime.now()); orderMapper.updateById(patch); } } ``` - [ ] **Step 4: 实现 LsyReconcileScheduler** ```java package com.payment.platform.module.payment.leshouying.scheduler; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties; import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity; import com.payment.platform.module.payment.leshouying.enums.LsyOrderStatus; import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper; import com.payment.platform.module.payment.leshouying.service.LsyCallbackService; import com.payment.platform.module.payment.leshouying.service.LsyCancelService; import com.payment.platform.module.payment.leshouying.service.LsyOrderService; import com.payment.platform.module.payment.leshouying.service.LsyQueryService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import java.time.OffsetDateTime; import java.util.List; import java.util.Map; /** * 主扫/H5 兜底对账 + 超时关单 + 下游回调重试。 * * 为什么不给主扫/H5 排密集轮询:回调是主路径,上游自己会重试回调。 * 轮询只为兜底「回调彻底丢失」,低频扫表足够;给每个订单排密集轮询会压垮调度器。 * * 【不做状态判断】查询结果怎么解释、状态怎么落库,全部交给 * LsyOrderService.settleFromQuery。四条子任务里只有「兜底对账」会改支付状态, * 走同一个入口;关单/过期收敛是本地状态收敛,不经过上游报文解析。 */ @Slf4j @Component @RequiredArgsConstructor public class LsyReconcileScheduler { private final LsyOrderMapper orderMapper; private final LsyQueryService queryService; private final LsyCancelService cancelService; private final LsyCallbackService callbackService; private final LsyOrderService orderService; private final LeshouyingProperties props; @Scheduled(fixedDelay = 5 * 60 * 1000) public void reconcile() { try { reconcilePendingOrders(); } catch (Exception e) { log.error("对账任务异常", e); } try { closeExpiredQrOrders(); } catch (Exception e) { log.error("聚合码关单任务异常", e); } try { expireStaleOrders(); } catch (Exception e) { log.error("过期收敛任务异常", e); } try { callbackService.retryFailed(); } catch (Exception e) { log.error("下游回调重试任务异常", e); } } /** 兜底:非终态且已超过 reconcileDelaySeconds 的主扫/H5 订单,查一次上游 */ void reconcilePendingOrders() { OffsetDateTime threshold = OffsetDateTime.now() .minusSeconds(props.getReconcileDelaySeconds()); List orders = orderMapper.selectList( new LambdaQueryWrapper() .eq(LsyOrderEntity::getStatus, "PAYING") .in(LsyOrderEntity::getPayScene, List.of("QRCODE", "H5")) .le(LsyOrderEntity::getCreatedTime, threshold) .last("LIMIT 100")); for (LsyOrderEntity order : orders) { try { Map resp = queryService.queryOrder(order); String settled = orderService.settleFromQuery(order, resp); if (settled != null) { log.info("对账兜底确认结果: outTradeNo={}, status={}", order.getOutTradeNo(), settled); callbackService.notifyOrder(order); } } catch (Exception e) { log.error("对账查询异常: outTradeNo={}", order.getOutTradeNo(), e); } } } /** 聚合码到期 → 调 v3/qrclose 关单 */ void closeExpiredQrOrders() { List orders = orderMapper.selectList( new LambdaQueryWrapper() .eq(LsyOrderEntity::getStatus, "PAYING") .eq(LsyOrderEntity::getPayScene, "QRCODE") .isNotNull(LsyOrderEntity::getCodeUrl) .le(LsyOrderEntity::getCodeUrlExpireTime, OffsetDateTime.now()) .last("LIMIT 100")); for (LsyOrderEntity order : orders) { try { applyCloseResult(order); } catch (Exception e) { log.error("聚合码关单异常: outTradeNo={}", order.getOutTradeNo(), e); } } } /** H5 与任意超长挂起的订单 → 本地收敛为 EXPIRED */ void expireStaleOrders() { OffsetDateTime h5Threshold = OffsetDateTime.now() .minusMinutes(props.getH5ExpireMinutes()); List h5Orders = orderMapper.selectList( new LambdaQueryWrapper() .eq(LsyOrderEntity::getStatus, "PAYING") .eq(LsyOrderEntity::getPayScene, "H5") .le(LsyOrderEntity::getCreatedTime, h5Threshold) .last("LIMIT 100")); for (LsyOrderEntity order : h5Orders) { markExpired(order); } OffsetDateTime absolute = OffsetDateTime.now() .minusHours(props.getAbsoluteExpireHours()); List stale = orderMapper.selectList( new LambdaQueryWrapper() // 用常量而不是字面量 List.of("CREATED","PAYING"): // 这正是 LsyOrderStatus.PENDING 的定义,写死会让两边将来各改各的。 .in(LsyOrderEntity::getStatus, LsyOrderStatus.PENDING) .le(LsyOrderEntity::getCreatedTime, absolute) .last("LIMIT 100")); for (LsyOrderEntity order : stale) { log.warn("订单超过 {} 小时未终态,强制收敛为 EXPIRED: outTradeNo={}", props.getAbsoluteExpireHours(), order.getOutTradeNo()); markExpired(order); } } /** 包成独立方法便于单测:target 为 null 时不得覆盖状态 */ void applyCloseResult(LsyOrderEntity order) { String target = cancelService.closeOrder(order); if (target == null) { log.info("关单未成功,保持原状态等待下次重试: outTradeNo={}", order.getOutTradeNo()); return; } LsyOrderEntity patch = new LsyOrderEntity(); patch.setId(order.getId()); patch.setStatus(target); patch.setUpdatedTime(OffsetDateTime.now()); orderMapper.updateById(patch); order.setStatus(target); callbackService.notifyOrder(order); } private void markExpired(LsyOrderEntity order) { LsyOrderEntity patch = new LsyOrderEntity(); patch.setId(order.getId()); patch.setStatus("EXPIRED"); patch.setFinishTime(OffsetDateTime.now()); patch.setUpdatedTime(OffsetDateTime.now()); orderMapper.updateById(patch); order.setStatus("EXPIRED"); callbackService.notifyOrder(order); } } ``` > **注意 `markExpired` 与 `applyCloseResult` 用 `updateById` 而非条件更新**:它们是 > 本地状态收敛(上游根本没成交),不参与支付幂等放行集合;且调用前已由查询条件 > 限定为 `status = 'PAYING'`。真正需要防重复的是**上游报文的回写**,那条路径 > 全部收在 `LsyOrderService.applyPayResult`。 - [ ] **Step 5: 运行测试确认全绿** ```bash cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LsySchedulerTest ``` Expected: `Tests run: 8, Failures: 0, Errors: 0` - [ ] **Step 6: 跑本模块全部测试** ```bash cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest='Lsy*Test,Leshouying*Test' ``` - [ ] **Step 7: 确认工作区状态(不提交)** ```bash git status --short ``` --- ## Task 17: 管理端控制器 **Files:** - Create: `java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingOrderController.java` - Create: `java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingRefundController.java` - Create: `java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingMerchantController.java` - Test: `java/src/test/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingAdminWiringTest.java` **Interfaces:** - Consumes: `LsyOrderMapper`, `LsyRefundMapper`, `LsyNotifyLogMapper`, `LsyMerchantService` - Produces: 管理端 REST 端点(走 JWT 认证,不在 ApiKey 白名单里) > **本任务不做 test-first。** 三个控制器是纯 CRUD 适配层——分页条件拼装 + 转发给 Service/Mapper, > 没有分支逻辑,给它写 mock 测试只能测出「我调了我自己」,是负价值。 > 业务逻辑的测试在 Task 11(`LsyMerchantServiceTest`)已经覆盖。 > 这里唯一值得守的是一个**安全边界**:管理端路径绝不能混进 ApiKey 白名单, > 否则任何持 API Key 的租户都能读到全平台订单。所以下面的测试守的是这条线。 - [ ] **Step 1: 实现三个管理端控制器** `LeshouyingMerchantController.java`: ```java package com.payment.platform.module.payment.leshouying.controller; import com.payment.platform.common.response.PageResult; import com.payment.platform.common.response.Result; import com.payment.platform.module.payment.leshouying.entity.LsyMerchantEntity; import com.payment.platform.module.payment.leshouying.service.LsyMerchantService; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; /** * 乐收赢商户映射管理 —— 走 JWT 认证(不在 ApiKey 白名单)。 * A 阶段联调必需:不配 mch_no 就无法下单。 */ @RestController @RequestMapping("/payment/leshouying/merchant") @RequiredArgsConstructor public class LeshouyingMerchantController { private final LsyMerchantService merchantService; @GetMapping("/page") public Result> page( @RequestParam(defaultValue = "1") int pageNo, @RequestParam(defaultValue = "20") int pageSize, @RequestParam(required = false) String keyword) { return Result.ok(merchantService.page(pageNo, pageSize, keyword)); } @PostMapping public Result save(@RequestBody LsyMerchantEntity entity) { return Result.ok(merchantService.save(entity)); } @DeleteMapping("/{id}") public Result delete(@PathVariable Long id) { merchantService.delete(id); return Result.ok(); } } ``` `LeshouyingOrderController.java`: ```java package com.payment.platform.module.payment.leshouying.controller; import cn.hutool.core.util.StrUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.payment.platform.common.exception.BusinessException; import com.payment.platform.common.response.PageResult; import com.payment.platform.common.response.Result; import com.payment.platform.module.payment.leshouying.entity.LsyNotifyLogEntity; import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity; import com.payment.platform.module.payment.leshouying.mapper.LsyNotifyLogMapper; import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; import java.util.List; /** 乐收赢订单管理 —— 走 JWT 认证 */ @RestController @RequestMapping("/payment/leshouying/order") @RequiredArgsConstructor public class LeshouyingOrderController { private final LsyOrderMapper orderMapper; private final LsyNotifyLogMapper notifyLogMapper; @GetMapping("/page") public Result> page( @RequestParam(defaultValue = "1") int pageNo, @RequestParam(defaultValue = "20") int pageSize, @RequestParam(required = false) String status, @RequestParam(required = false) String payScene, @RequestParam(required = false) String keyword) { Page page = new Page<>(pageNo, pageSize); LambdaQueryWrapper w = new LambdaQueryWrapper<>(); if (StrUtil.isNotBlank(status)) { w.eq(LsyOrderEntity::getStatus, status); } if (StrUtil.isNotBlank(payScene)) { w.eq(LsyOrderEntity::getPayScene, payScene); } if (StrUtil.isNotBlank(keyword)) { w.and(q -> q.like(LsyOrderEntity::getOutTradeNo, keyword) .or().like(LsyOrderEntity::getTradeNo, keyword) .or().like(LsyOrderEntity::getPayTraceNo, keyword)); } w.orderByDesc(LsyOrderEntity::getCreatedTime); Page r = orderMapper.selectPage(page, w); return Result.ok(PageResult.of(pageNo, pageSize, r.getTotal(), r.getRecords())); } /** 详情含回调审计轨迹,便于对账排查 */ @GetMapping("/{outTradeNo}") public Result detail(@PathVariable String outTradeNo) { LsyOrderEntity o = requireOrder(outTradeNo); return Result.ok(o); } /** * 某订单的回调审计轨迹。 * * ★ 必须**先按 outTradeNo 取到本租户可见的订单**,再用它的 payTraceNo 去查审计表。 * 不能反过来「直接拿入参 payTraceNo 查日志」—— 那等于把租户边界交给调用方传参: * 传一个别的租户的 payTraceNo 就能读到别人的回调报文(报文里有 trade_no、金额)。 * 经订单中转之后,越权查询在第一步就变成 404 了。 * * ★ 这里也修掉一个静默失效:原写法把 `outTradeNo` 这个 `@PathVariable` **完全没用上** * (只用了可选的 `payTraceNo`),于是 `/notify-logs` 对任何订单都返回同样的 * 「本租户最近 200 条」—— 路径参数被无声忽略,前端却会以为拿到的是这笔单的轨迹。 */ @GetMapping("/{outTradeNo}/notify-logs") public Result> notifyLogs(@PathVariable String outTradeNo) { LsyOrderEntity order = requireOrder(outTradeNo); LambdaQueryWrapper w = new LambdaQueryWrapper<>(); w.eq(LsyNotifyLogEntity::getPayTraceNo, order.getPayTraceNo()); w.orderByDesc(LsyNotifyLogEntity::getCreatedTime).last("LIMIT 200"); return Result.ok(notifyLogMapper.selectList(w)); } /** * 取本租户可见的订单,取不到一律 404。 * ★ 与 Task 15 的 OpenAPI 侧同一条纪律:**绝不把「不存在」和「不属于你」区分开** —— * 区分开就等于提供了一个探测别人订单号是否存在的接口。 */ private LsyOrderEntity requireOrder(String outTradeNo) { LsyOrderEntity o = orderMapper.selectOne(new LambdaQueryWrapper() .eq(LsyOrderEntity::getOutTradeNo, outTradeNo).last("LIMIT 1")); if (o == null) { throw new BusinessException(404, "订单不存在"); } return o; } } ``` `LeshouyingRefundController.java`: ```java package com.payment.platform.module.payment.leshouying.controller; import cn.hutool.core.util.StrUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.payment.platform.common.response.PageResult; import com.payment.platform.common.response.Result; import com.payment.platform.module.payment.leshouying.entity.LsyRefundEntity; import com.payment.platform.module.payment.leshouying.mapper.LsyRefundMapper; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; /** 乐收赢退款管理 —— 走 JWT 认证 */ @RestController @RequestMapping("/payment/leshouying/refund") @RequiredArgsConstructor public class LeshouyingRefundController { private final LsyRefundMapper refundMapper; @GetMapping("/page") public Result> page( @RequestParam(defaultValue = "1") int pageNo, @RequestParam(defaultValue = "20") int pageSize, @RequestParam(required = false) String status, @RequestParam(required = false) String keyword) { Page page = new Page<>(pageNo, pageSize); LambdaQueryWrapper w = new LambdaQueryWrapper<>(); if (StrUtil.isNotBlank(status)) { w.eq(LsyRefundEntity::getStatus, status); } if (StrUtil.isNotBlank(keyword)) { w.and(q -> q.like(LsyRefundEntity::getOutRefundNo, keyword) .or().like(LsyRefundEntity::getOutTradeNo, keyword) .or().like(LsyRefundEntity::getRefundNo, keyword)); } w.orderByDesc(LsyRefundEntity::getCreatedTime); Page r = refundMapper.selectPage(page, w); return Result.ok(PageResult.of(pageNo, pageSize, r.getTotal(), r.getRecords())); } @GetMapping("/{outRefundNo}") public Result detail(@PathVariable String outRefundNo) { return Result.ok(refundMapper.selectOne(new LambdaQueryWrapper() .eq(LsyRefundEntity::getOutRefundNo, outRefundNo).last("LIMIT 1"))); } } ``` 三个控制器的 `Result` 工厂必须用 **`Result.ok(...)`**(成功码 0)。 `Result` 类**没有** `success()` / `error()` 方法 —— 写错会直接编译失败。 - [ ] **Step 2: 写安全边界守卫测试** ```java package com.payment.platform.module.payment.leshouying.controller; import com.payment.platform.core.security.TenantApiKeyAuthFilter; import com.payment.platform.core.security.SecurityConfig; import org.junit.jupiter.api.Test; import java.lang.reflect.Field; import java.util.Arrays; import static org.junit.jupiter.api.Assertions.*; /** * 管理端安全边界守卫。 * * 管理端接口(/payment/leshouying/**)必须走 JWT 认证。 * 一旦被误加进 ApiKey 白名单或 SecurityConfig 白名单,任何持 API Key 的租户 * 就能读到全平台订单/退款数据 —— 这是静默的越权,不会报错,只会漏数据。 */ class LeshouyingAdminWiringTest { private static final String[] ADMIN_PATHS = { "/payment/leshouying/order/page", "/payment/leshouying/order/{outTradeNo}", "/payment/leshouying/refund/page", "/payment/leshouying/refund/{outRefundNo}", "/payment/leshouying/merchant/page", "/payment/leshouying/merchant", }; @Test void adminPaths_mustNotBeInApiKeyFilterWhitelist() throws Exception { Field f = TenantApiKeyAuthFilter.class.getDeclaredField("OPENAPI_ACCOUNT_PATHS"); f.setAccessible(true); String[] paths = (String[]) f.get(null); for (String admin : ADMIN_PATHS) { assertFalse(Arrays.asList(paths).contains(admin), "管理端路径不得进入 ApiKey 认证白名单,否则租户可越权读全平台数据: " + admin); } } @Test void onlyNotifyPaths_areWhitelistedInSecurityConfig() throws Exception { Field f = SecurityConfig.class.getDeclaredField("WHITE_LIST"); f.setAccessible(true); String[] whiteList = (String[]) f.get(null); // 回调入口放行是必须的(无登录态,靠 MD5 验签) assertTrue(Arrays.asList(whiteList).contains("/payment/leshouying/notify/**")); // 管理端路径绝不能出现在白名单里 for (String admin : ADMIN_PATHS) { assertFalse(Arrays.asList(whiteList).contains(admin), "管理端路径不得放行: " + admin); } // 也不能用通配符把整个 leshouying 放行 assertFalse(Arrays.asList(whiteList).contains("/payment/leshouying/**"), "整段放行会让管理端接口失去认证"); } @Test void notifyPath_usesTheSameRequestMappingPrefixAsAdmin() { // 防止有人把回调入口挪到 /payment/openapi/leshouying/ 下 —— 那会被 ApiKey 过滤器拦截, // 而上游乐收赢不会带 ApiKey 头,回调全部 401 String notifyPath = "/payment/leshouying/notify/pay"; assertFalse(notifyPath.startsWith("/payment/openapi/"), "回调入口不得放在 openapi 前缀下,否则被 ApiKey 过滤器拦截"); } } ``` - [ ] **Step 3: 运行测试确认全绿** ```bash cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LeshouyingAdminWiringTest ``` Expected: `Tests run: 3, Failures: 0, Errors: 0` (需要 Task 14 已完成 —— 本测试读的就是 Task 14 改的那两个常量) - [ ] **Step 4: 编译全量确认控制器无语法问题** ```bash cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test-compile ``` - [ ] **Step 5: 确认工作区状态(不提交)** ```bash git status --short ``` --- ## Task 18: 前端管理页 + 菜单 SQL **Files:** - Create: `frontend/src/api/module_payment/leshouying.ts` - Create: `frontend/src/views/module_payment/leshouying/order/index.vue` - Create: `frontend/src/views/module_payment/leshouying/refund/index.vue` - Create: `frontend/src/views/module_payment/leshouying/merchant/index.vue` - Create: `java/src/main/resources/db/lsy-menu.sql`(**不执行**) **Interfaces:** - Consumes: Task 17 的管理端端点 - Produces: 三个页面 + 菜单数据 - [ ] **Step 1: 写 API 封装** `frontend/src/api/module_payment/leshouying.ts`: ```ts import request from '@/utils/request' // ==================== 订单 ==================== export function getLsyOrderPage(params: { pageNo: number pageSize: number status?: string payScene?: string keyword?: string }) { return request({ url: '/payment/leshouying/order/page', method: 'get', params }) } export function getLsyOrderDetail(outTradeNo: string) { return request({ url: `/payment/leshouying/order/${outTradeNo}`, method: 'get' }) } export function getLsyNotifyLogs(outTradeNo: string) { return request({ url: `/payment/leshouying/order/${outTradeNo}/notify-logs`, method: 'get' }) } // ==================== 退款 ==================== export function getLsyRefundPage(params: { pageNo: number pageSize: number status?: string keyword?: string }) { return request({ url: '/payment/leshouying/refund/page', method: 'get', params }) } export function getLsyRefundDetail(outRefundNo: string) { return request({ url: `/payment/leshouying/refund/${outRefundNo}`, method: 'get' }) } // ==================== 商户映射 ==================== export function getLsyMerchantPage(params: { pageNo: number pageSize: number keyword?: string }) { return request({ url: '/payment/leshouying/merchant/page', method: 'get', params }) } export function saveLsyMerchant(data: Record) { return request({ url: '/payment/leshouying/merchant', method: 'post', data }) } export function deleteLsyMerchant(id: number) { return request({ url: `/payment/leshouying/merchant/${id}`, method: 'delete' }) } ``` - [ ] **Step 2: 写订单页** `frontend/src/views/module_payment/leshouying/order/index.vue` —— **照 `views/module_payment/batch/index.vue` 的既有列表页写法**(`har/` 尚在 stash 中,工作区里不存在,不要去找):`app-container` + `el-card shadow="never"` + 顶部 `el-form :inline="true"` 查询条 + `el-table` + `el-pagination`。 ```vue ``` - [ ] **Step 3: 写退款页** `frontend/src/views/module_payment/leshouying/refund/index.vue` —— 结构与订单页同构,字段换成退款单: ```vue ``` - [ ] **Step 4: 写商户映射页** `frontend/src/views/module_payment/leshouying/merchant/index.vue`: ```vue ``` - [ ] **Step 5: 写菜单 SQL** `java/src/main/resources/db/lsy-menu.sql`(**不执行**): ```sql -- 乐收赢支付通道 —— 菜单与权限 -- 【注意】本脚本未经执行。执行前请确认菜单 ID 段未被占用。 -- 参照 db/batch-menu.sql 的写法。 -- 一级目录:乐收赢 INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) VALUES (2200, '乐收赢支付', 0, 80, 'leshouying', NULL, 1, 0, 'M', '0', '0', '', 'Money', 'admin', now(), '乐收赢支付通道'); -- 订单管理 INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) VALUES (2201, '支付订单', 2200, 1, 'order', 'module_payment/leshouying/order/index', 1, 0, 'C', '0', '0', 'payment:leshouying:order:list', 'List', 'admin', now(), ''); -- 退款管理 INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) VALUES (2202, '退款记录', 2200, 2, 'refund', 'module_payment/leshouying/refund/index', 1, 0, 'C', '0', '0', 'payment:leshouying:refund:list', 'RefreshLeft', 'admin', now(), ''); -- 商户映射 INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) VALUES (2203, '商户映射', 2200, 3, 'merchant', 'module_payment/leshouying/merchant/index', 1, 0, 'C', '0', '0', 'payment:leshouying:merchant:list', 'Shop', 'admin', now(), ''); ``` - [ ] **Step 6: 前端类型检查与构建** ```bash cd frontend && npx vue-tsc --noEmit --skipLibCheck && npx vite build ``` Expected: 无类型错误,构建成功。 - [ ] **Step 7: 确认工作区状态(不提交)** ```bash git status --short ``` --- ## 完成标准 - [ ] `cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn test` 全绿 (**★ 不许加 `-q`**:实测 `-q` 会让 stdout 全空,连 `Tests run:` 都看不到 —— 与 Global Constraints 一致。 本行 2026-09-21 由 lead 修正,原来写的是 `mvn -q test`,是本计划内部的一处自相矛盾。) - [ ] `cd frontend && npx vue-tsc --noEmit --skipLibCheck` 无错误 - [ ] `git log --oneline -1` 仍停在 `dbe364c` - [ ] `git status --short` 显示所有新增文件为 `??`,无 `M` 之外的非预期改动 - [ ] SQL 文件存在但**未对任何数据库执行** - [ ] `grep -rn "!isPaySuccess" java/src` **结果为空**。未知码不得被判成失败 —— 上游某端点若回通用成功码 `"SUCCESS"`,三个谓词全 false,`!isPaySuccess` 会把**一笔已收到钱的支付**判成失败落库。 正确形态是三谓词分派 + 未知码走「记日志 + 留在非终态 + 待人工核对」(Task 6/7/8/9/12 已如此实现)。 - [ ] `grep -rn "isPayFail" java/src/main/java --include=*.java` **结果非空**。 这是**弱**检查,只证明「显式失败判据真的有人用」;一个都没有,说明失败判定又回到了 裸比较或 `!isPaySuccess`。把它留在这里是因为它机械、且**不会因注释而误报** —— 见下条。 - [ ] ⚠️ **不要**试图用「grep 字面量」来兜住未知码这件事,我试过,那条路是错的。 曾写下这条:`grep -rlnE '"(PAY_SUCCESS|PAY_SUCEESS|PAY_FAIL|...)"' java/src` 命中文件只允许是 parser 与 `LsyNotifyService` 两处。**实测它是误报源**:本计划的行文风格是**在注释里引用被禁止的写法** (Task 12 的注释原文就写着 `"PAY_SUCCESS".equals(rc) ? "SUCCESS" : "FAIL"`, `LsyOrderService` 的注释写着「如 "SUCCESS" 而非 "PAY_SUCCESS"」), 仅计划正文里带引号的码字面量就有 **79 行**。于是这条 grep 会把每一个「解释过这件事的文件」都判成违规, 而真正的违规(`x ? A : B`)它照样看不见。 **误报的检查比没有检查更坏**:它会被忽略,而被忽略的检查会制造「已经有人守着了」的假信心 —— 我上一条就正是这么把自己骗过去的。**所以这条已删除,只保留「靠测试守」这一条路。** - [ ] ★ **未知码这件事,唯一的执行点是测试,不是 grep。** `"PAY_SUCCESS".equals(rc) ? "SUCCESS" : "FAIL"` 既没有 `!isPaySuccess`, 也没有裸字面量落在「不该有的文件」里 —— 上面第一条 grep 放行, 「字面量按文件白名单」那条(已删除)也放行。**但它是真钱缺陷**(见 Task 12 的两段 ★ 注释)。 能挡住它的是可执行的测试: `LsyNotifyServiceTest` 必须包含 `payNotify_unknownResultCode_doesNotWriteTerminalStatus` 与 `refundNotify_unknownResultCode_doesNotWriteTerminalFail` 两条,且**必须真的跑到**(`mvn test` 全绿)。 这两条喂的都是合法签名的 `result_code: "SUCCESS"`,断言「不写终态、不通知下游」—— 谁把三分支改回二元,它们立刻变红。 确认命令:`grep -rn "unknownResultCode" java/src --include=*.java` 恰好命中这两个测试方法。 同族的另外三条:`LsyCancelServiceTest.barcode_unknownResultCode_refusesToCancel`、 `barcode_payInProcess_refusesToCancel`、`barcode_docSpellingPaySuceess_isNotAcceptedAsSuccess` —— 它们守的是**不可逆动作**(撤销/关单)在「不知道能不能做」时朝不执行的方向失败。 **教训:机械检查只能覆盖它能表达的形状。写不出来的形状要靠可执行的测试,不能靠「我读过代码了」; 而一条会误报的检查比没有检查更坏 —— 它会被忽略,被忽略的检查制造假信心。** - [ ] `grep -rnE "getRawJson|getRawReturnMsg|getRawReturnCode" java/src --include=*.java` 只命中 `LeshouyingApiException` 自身与其测试。三个 Raw getter 是**逐字上游原文**, 进了日志就等于把未脱敏报文写进日志文件;命名是唯一的护栏,所以要用这条 grep 兜住。 - [ ] 没有任何测试把 `getRaw*` 的结果直接喂给日志调用(同上,命名挡不住 `log.info("{}", ex.getRawJson())`)。 ## 交付后仍需联调确认(spec §14) 这 7 项**不影响实现**,但会影响线上正确性,需与乐收赢确认: 1. H5 订单如何关闭(当前本地置 `EXPIRED`,上游链接仍有效) 2. 响应报文签名拼装规则(当前不做响应验签) 3. `h5pay` 的轮询规则(当前靠回调 + 5 分钟兜底) 4. `v3/qrclose` 的返回语义措辞与关单场景不符 5. **回调报文是否回显 `auth_code`。** 注意:这**不改变**任何代码 —— Task 12 已按「不假设上游不回显」 实现(脱敏放在写入口,不放在「上游应该不会」的假设上)。问它是为了知道这道防线**实际有没有被用到**: 若上游确实回显,脱敏就是每天都在生效的必需控制;若不回显,它是针对将来的防御。两种结论都不导致改代码。 6. **★ 是否存在 `/v3/*` 端点返回通用成功码 `result_code: "SUCCESS"`(而非 `"PAY_SUCCESS"`)。** 这条**阻塞 Task 5/6 的状态机**,且**无法从仓库内验证**。见下节。 顺带会解答一个次生问题:退款接口(Task 10)若回未知码,退款会停在 `REFUNDING` 不再前进 —— 不是「误报失败」那么严重,但会积压。知道取值域才能判断这是否真会发生。 注意 Task 10 的在途互斥**恰好依赖它不会永久停在 REFUNDING**:真停住了,这张单就再也退不了款, 得人工改状态。所以这条不只是"积压",对退款侧是**阻塞性**的。 7. **同步成功的退款要不要通知下游。** 现状是不通知 —— 只有回调路径会 `callbackService.notifyOrder`, 而 `createRefund` 同步成功时是直接返回给调用方(管理端界面能看到),不触发下游 webhook。 如果租户的系统靠 webhook 感知退款,这条就得补;如果退款只走管理端查询,现状即可。 **属于产品语义问题,不是缺陷** —— 所以没动代码,列在这里等答案。 ### ★ 本计划**未覆盖**的既有风险(与上面 7 条不同:这些不需要问乐收赢,需要独立排期) 1. **★ 共享 `RestTemplate` 没有任何超时。** 来源见 Task 4 的 Minor #3(逐任务评审查出,我复核确认)。 - **事实**:`WebMvcConfig.java:24-27` 定义的 `RestTemplate` 是无参 `new RestTemplate()`, 计划全文中**没有任何一处**设置 `connectTimeout` / `readTimeout`;Task 4 的 `LeshouyingClient` 直接注入这个共享 bean(注入第二个 bean 会 `NoUniqueBeanDefinitionException`)。 - **后果**:上游 TCP 连上但不回包时,请求线程**无限阻塞**(默认 JDK `HttpURLConnection` 读超时为 0 = 永不超时)。Task 16 的调度器线程同样会被卡住 —— 那不是「这一笔慢」, 是**对账/兜底轮询整体停摆**。 - **为什么不在本计划里改**:Task 14 改的三个共享文件是 `TenantApiKeyAuthFilter.java:92-96` / `SecurityConfig.java:38-70` / `TenantInnerInterceptor.java:53-76`, **不含 `WebMvcConfig`**。而这个 bean 同时被既有的 `OpenapiService` 使用 —— 给它加超时会**一并改变既有下游回调路径的行为**,属于本计划范围之外, 该独立成一笔带自己审查的变更,不能夹在乐收赢通道里顺手做。 - **改法**:`WebMvcConfig` 里换成显式工厂,例如 ```java SimpleClientHttpRequestFactory f = new SimpleClientHttpRequestFactory(); f.setConnectTimeout(5_000); // 5s f.setReadTimeout(30_000); // 30s —— 乐收赢下单是同步调用,不宜过长 return new RestTemplate(f); ``` 或 `ClientHttpRequestFactorySettings` 的 builder 形式。**超时值要和乐收赢确认** (付款码支付是同步扣款,超时给太短会在用户已付款时误报失败)。 - **不改的代价**:一次上游网络抖动即可拖垮线程池。这条**不该**带着上线。 ### 另需人工验证(单测覆盖不到,属实现正确性而非上游问题) 启动 Spring 上下文,打开 MyBatis SQL 日志,然后: 1. **跨租户隔离** —— 用租户 A 的 API Key 调 `/payment/openapi/leshouying/order/query`, 确认打印出的 SQL 的 `WHERE` 里**确实有 `tenant_id = ?`**。 说明(2026-09-21 lead 更正):`pay_lsy_*` 在 `CONDITIONAL_TABLES` 中,**但 ApiKey 请求是有认证上下文的** (该 filter 放入带租户的 `LoginUser`),拦截器**会**追加 `tenant_id`;要人验是因为 Mockito 单测里拦截器不跑。 同样确认 `/order/close`、`/refund`、`/refund/query`、三个 `*/create` 的幂等查找。 2. **回调入口无鉴权** —— 不带任何鉴权头 `curl` 一次 `/payment/leshouying/notify/pay`, 必须返回 `{"return_code":"FAIL","return_msg":"验签失败"}`,且**不产生任何业务状态变更**。 3. **条件更新落库** —— 同一订单重复投递一次支付成功回调,确认第二次不再触发下游回调 (即 `pay_lsy_order` 无二次 UPDATE,`pay_lsy_notify_log` 有两条记录)。 4. **付款码没有落盘(两处都要看)** —— 跑一笔真实的被扫支付,然后: - `SELECT raw_body FROM pay_lsy_notify_log ORDER BY id DESC LIMIT 1;` —— 报文结构、 `return_code`、`trade_no` 应完好,**不含 18 位付款码**(应为 `***`); - 翻 `logs/payment-platform-error.log`,确认解析失败路径下也**不含完整付款码**。 单测只覆盖了脱敏器本身;「真实报文长什么样」只有联调能证明。 同时确认:`sign` 字段仍在 `raw_body` 里 —— 它是审计与排障的依据,**不该**被值层遮掉(`sign` 是 32 位十六进制, 含字母,连续数字段远短于 12 位,故不受影响;若发现被遮,说明值层阈值或报文形态与预期不符,需要复核)。 5. **部分唯一索引真的建上了,且并发双发确实被挡住** —— 防重复退款的三道锁里,只有索引是结构性的, 而它依赖 PostgreSQL 的**部分索引**能力(`CREATE UNIQUE INDEX ... WHERE`,MySQL 没有对等物), 脚本必须真的跑到目标库上才算数(`V1.8` + `lsy-menu.sql` 目前按约定未执行,所以这条现在只能人工验)。 验证两步: - `\d pay_lsy_refund` 应看到 `uk_lsy_refund_one_pending` 且带 `WHERE status IN (...)`; - 手工对同一 `order_id` 连插两行 `status='REFUNDING'`,第二行必须报唯一约束冲突。 若插得进去,说明索引没生效(写法或库版本问题),此时**并发双发就没有任何防线** —— Task 10 的 应用层校验是读-判-写,挡不住并发。这条不通过就不能上线退款功能。 ### ★ 阻塞项:`result_code` 取值域(进 Task 5/6 之前必须有答案) Task 2 的 `LeshouyingResponseParser` 提供三个互斥谓词:`isPaySuccess` / `isPayInProcess` / `isPayFail`。 但**存在一个三者皆为 false 的输入**:`{"return_code":"SUCCESS","result_code":"SUCCESS"}`。 - 若 `/v3/*` 的成功响应**只会**回 `PAY_SUCCESS`,则这不是问题(未知码自然落到人工核对分支)。 - 若某个 `/v3/*` 端点确实回通用成功码 `"SUCCESS"`,那么**一笔已经收到钱的支付会落进「未知码」分支** —— 比误判为失败好,但仍然是每笔都要人工核对。 审查者已确认**无法从仓库内判定**,需要上游文档(`doc.leshouying.cn` 的 `/v3/*` 响应码表)。 拿到答复前: - **不得**把这三个谓词当作穷尽; - 调用方(Task 6/7/9)**不得**写 `!isPaySuccess(...)` 来判失败 —— 那会把「成功」判成「失败」落库; - 未知码必须走「记日志 + 待人工核对」,不得静默归类。 - 回调路径(Task 12)走同一规则的另一种写法:它不经 parser,直接比较 `result_code`, 因此 `LsyNotifyService` 的两处比较**都必须是三分支**,并由 `payNotify_unknownResultCode_*` / `refundNotify_unknownResultCode_*` 两条测试钉住。 这两条不是锦上添花 —— 它们是这条规则在回调路径上的**唯一**执行点(grep 看不见 `x ? A : B`)。