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 节,含完整目录树与逐页参数 —— 实现时查参数以此为准)
dbe364c。每个任务末尾用 git status --short 确认,不执行任何 git add / git commit。V1.8__create_pay_lsy_tables.sql 与 lsy-menu.sql 只写文件,不对任何数据库执行(建表/写菜单不可逆)。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 条命令。)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)。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。)
新建(后端)
| 文件 | 职责 |
|---|---|
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
整个模块的安全基石。规则来自上游「接入指引」:按参数名 ASCII 升序,拼 k=v&k=v,尾部接 &key=<机构密钥>,MD5 取 32 位小写;null 不参与,空字符串参与。
Files:
java/src/main/java/com/payment/platform/module/payment/leshouying/client/LeshouyingSigner.javajava/src/test/java/com/payment/platform/module/payment/leshouying/client/LeshouyingSignerTest.javaInterfaces:
Produces:
String LeshouyingSigner.sign(Map<String,Object> params, String key)boolean LeshouyingSigner.verify(Map<String,Object> params, String key)[ ] Step 1: 写失败的测试
创建 LeshouyingSignerTest.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<String, Object> params(Object... kv) {
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> blank = params("inst_no", "900112");
blank.put("sign", "");
assertFalse(signer.verify(blank, KEY));
Map<String, Object> nullSign = params("inst_no", "900112");
nullSign.put("sign", null);
assertFalse(signer.verify(nullSign, KEY));
}
@Test
void verify_signIsCaseInsensitiveOnInput() {
Map<String, Object> p = params("inst_no", "900112");
p.put("sign", signer.sign(p, KEY).toUpperCase());
assertTrue(signer.verify(p, KEY));
}
}
[ ] Step 2: 运行测试确认失败
cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LeshouyingSignerTest
Expected: 编译失败 —— LeshouyingSigner 不存在。
[ ] Step 3: 实现 LeshouyingSigner
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<String, Object> params, String key) {
Map<String, Object> 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<String, Object> 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<String, Object> rest = new LinkedHashMap<>(params);
rest.remove(SIGN_FIELD);
String expected = sign(rest, key);
return constantTimeEquals(expected, actual);
}
/** null 排除、空字符串保留、按 key 自然序(ASCII)升序 */
private String buildSource(Map<String, Object> 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: 运行测试确认全绿
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: 确认工作区状态(不提交)
git status --short
Expected: 两个文件显示为 ??(未跟踪)。不要 git add。
上游有两套返回结构,混用会导致状态判断错误。这是本项目已确认的坑(spec §2)。
| 体系 | 成功判定 | 业务结果字段 |
|---|---|---|
支付类 /v3/* |
return_code == "SUCCESS" |
result_code = PAY_SUCCESS / PAY_IN_PROCESS / PAY_FAIL |
进件类 /api/* |
result_code == "SUCCESS" |
result_msg |
Files:
java/src/main/java/com/payment/platform/module/payment/leshouying/client/LeshouyingResponseParser.javajava/src/main/java/com/payment/platform/module/payment/leshouying/client/LeshouyingApiException.javajava/src/main/java/com/payment/platform/module/payment/leshouying/client/LeshouyingSensitive.java(报文脱敏。Task 4 出站日志与 Task 12 回调落库都要复用它,不要各写一份正则)java/src/test/java/com/payment/platform/module/payment/leshouying/client/LeshouyingResponseParserTest.javajava/src/test/java/com/payment/platform/module/payment/leshouying/client/LeshouyingApiExceptionTest.javajava/src/test/java/com/payment/platform/module/payment/leshouying/client/LeshouyingSensitiveTest.javaInterfaces:
Produces:
Map<String,Object> LeshouyingResponseParser.parseV3(String json) —— 通信失败抛 LeshouyingApiExceptionMap<String,Object> LeshouyingResponseParser.parseApi(String json)boolean LeshouyingResponseParser.isPaySuccess(Map<String,Object> resp)boolean LeshouyingResponseParser.isPayInProcess(Map<String,Object> resp)boolean LeshouyingResponseParser.isPayFail(Map<String,Object> 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: 写失败的测试
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<String, Object> 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<String, Object> 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<String, Object> 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 对 `<html>502</html>` **不抛**,而是解析成 {"html":502},
// 于是 return_code 缺失。故必须断言 PARSE_ERROR —— 若只断言异常类型,
// 一个把它错分类成 EMPTY 或 null 的回归照样能通过。
LeshouyingApiException ex = assertThrows(LeshouyingApiException.class,
() -> parser.parseV3("<html>502</html>"));
assertEquals("PARSE_ERROR", ex.getRawReturnCode());
assertEquals("<html>502</html>", 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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** —— 上面所有畸形报文
// (`<html>502</html>`、`{}`)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<ILoggingEvent> 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("<html>502</html>")); // 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:
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: 运行测试确认失败
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
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」被误当成了泄漏计数 —— 两个不同的量。 已按原正则逐条实跑改正。)
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("<html>502<br>req={\"auth_code\":\"***\"}</html>",
LeshouyingSensitive.mask("<html>502<br>req={\"auth_code\":\"134573607383541003\"}</html>"));
}
@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("<auth_code>***</auth_code>",
LeshouyingSensitive.mask("<auth_code>134573607383541004</auth_code>"));
}
@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("<auth_code>994573607383541004</auth_code>")
.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 遮不住 —— 这正是它只配用于「我们自己构造的报文」的原因");
}
}
运行确认失败:
cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LeshouyingSensitiveTest
Expected: 编译失败 —— LeshouyingSensitive 不存在。
[ ] Step 5: 实现 LeshouyingSensitive
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 标签(`<auth_code>…`)、嵌套转义 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
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<String, Object> parseV3(String json) {
Map<String, Object> map = toMap(json);
String returnCode = str(map.get("return_code"));
if (StrUtil.isBlank(returnCode)) {
// /v3/* 报文必有 return_code。没有它,说明这根本不是乐收赢的 v3 响应
// (网关 HTML 错误页、{}、被截断后恰好仍可解析的片段),而不是「上游回了 FAIL」。
// 实测 Hutool 对 `<html>502</html>` **不抛**,而是解成 {"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<String, Object> parseApi(String json) {
Map<String, Object> 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<String, Object> resp) {
return resp != null && PAY_SUCCESS.equals(str(resp.get("result_code")));
}
public boolean isPayInProcess(Map<String, Object> 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<String, Object> resp) {
return resp != null && PAY_FAIL.equals(str(resp.get("result_code")));
}
private Map<String, Object> toMap(String json) {
if (StrUtil.isBlank(json)) {
throw new LeshouyingApiException("EMPTY", "乐收赢返回空报文", json);
}
try {
JSONObject obj = JSONUtil.parseObj(json);
Map<String, Object> 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: 运行测试确认全绿
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: 0LeshouyingApiExceptionTest → Tests run: 9, Failures: 0, Errors: 0LeshouyingSensitiveTest → 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: 确认工作区状态(不提交)
git status --short
Files:
java/src/main/resources/db/migration/V1.8__create_pay_lsy_tables.sql(不执行)java/src/main/java/com/payment/platform/module/payment/leshouying/enums/{LsyPayScene,LsyPayType,LsyOrderStatus,LsyRefundStatus,LsyNotifyStatus}.javajava/src/main/java/com/payment/platform/module/payment/leshouying/entity/{LsyMerchant,LsyOrder,LsyRefund,LsyNotifyLog}Entity.javaCreate: 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:
Produces: 4 个实体类 + 4 个 Mapper 接口。字段名见下方 SQL 与实体定义,后续任务全部依赖这些 getter/setter 名。
[ ] Step 1: 写迁移脚本
创建 V1.8__create_pay_lsy_tables.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 '乐收赢回调原始报文审计(追加写)';
LsyPayScene.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:
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:
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<String> PAY_WRITE_FROM = Set.of("CREATED", "PAYING");
/** 允许被退款结果回写的来源状态 */
public static final Set<String> 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<String> 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<String> PAY_RECORDED = Set.of("SUCCESS", "PARTIAL_REFUNDED", "REFUNDED");
}
LsyRefundStatus.java:
package com.payment.platform.module.payment.leshouying.enums;
import java.util.Set;
public enum LsyRefundStatus {
CREATED, REFUNDING, SUCCESS, FAIL;
/** 退款结果可回写的起始状态:本行只有从这里出发才能被写一次终态。 */
public static final Set<String> 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<String> PENDING = Set.of("CREATED", "REFUNDING");
}
LsyNotifyStatus.java:
package com.payment.platform.module.payment.leshouying.enums;
/** 下游回调推送状态 */
public enum LsyNotifyStatus {
PENDING, SUCCESS, FAIL
}
LsyMerchantEntity.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:
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:
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:
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;
}
四个文件形状相同,但逐个写全(不要写「同理创建」—— 四份实体类型名各自不同, 而实现者可能只读到自己那一段,让它去推断映射关系就是让它猜):
文件 1/4 mapper/LsyOrderMapper.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<LsyOrderEntity> {
}
文件 2/4 mapper/LsyMerchantMapper.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<LsyMerchantEntity> {
}
文件 3/4 mapper/LsyRefundMapper.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<LsyRefundEntity> {
}
文件 4/4 mapper/LsyNotifyLogMapper.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<LsyNotifyLogEntity> {
}
关于「4 张表要不要注册进
TenantInnerInterceptor.CONDITIONAL_TABLES」——要,但不在本任务做。 它归 Task 14 Step 5(「三个共享文件必改点」之一),且带一条会红的钉子LeshouyingWiringTest.conditionalTables_containAllLeshouyingTables。 本任务只建表,不改任何既有文件。 原因:改动与它的钉子测试必须同处一个任务,否则「改了没测」或「测了没改」两头都看不出来; 而且这个失效只在运行时显现,Task 14 早于任何一次真实调用。 (评审若再提这一条,请看计划顶部预检表core/tenant/TenantInnerInterceptor.java:53-76一行 —— 已登记。)
[ ] Step 5: 写实体映射测试(防止注解写错)
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<String> 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<String> columns = tableColumns(sql, table);
Set<String> 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<String> 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<String> 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<String> 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: 运行测试确认全绿
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 条的有效性已做变异验证(实测,非推断):
'REFUNDING' 改成 'REFUNDINGX' 后重跑 → Tests run: 5, Failures: 1,
只有 refundPendingSetMatchesPartialUniqueIndexPredicate 变红,另外 4 条保持绿
(说明钉子是精确定位、不是碰巧全红)。变异后已还原,文件 sha256 与变异前逐位一致。everyEntityFieldHasAColumnInTheMigration 是 C-1 的机械防线。它有两个方向,
两个方向都要做变异验证 —— 否则等于有一条断言从来没被执行过(写了不等于守住了):
status 列之前它必红 ——
pay_lsy_notify_log 实体侧 13 个字段、建表侧 12 列,缺的正是基类带来的 status;
补上之后四张表字段/列数是 11/11、37/37、22/22、13/13 全等。auth_code VARCHAR(64) → 必红;删掉即恢复绿。
两个方向都先红后绿、可复现,所以这条不是「永远绿的断言」。三条护栏的分工是互补、不可互替的(将来有人想删其中一条,请先回来看这段):
orderEntity_hasNoAuthCodeField —— authCode 出现在实体里(只看 entity 侧);auth_code 溜进 DDL 的形态)——
★ 这种形态下前两条都是绿的,只有方向二拦得住。[ ] Step 7: 确认工作区状态(不提交)
git status --short
本任务应当只新增文件,不修改任何既有文件。 若 git diff --cached 非空,说明有人 git add 了 —— 立即回退。
SQL 文件到此为止 —— 不要对任何数据库执行。
Files:
java/src/main/java/com/payment/platform/module/payment/leshouying/config/LeshouyingProperties.javajava/src/main/java/com/payment/platform/module/payment/leshouying/client/LeshouyingClient.javajava/src/main/resources/application.yml(新增 leshouying: 段)java/src/test/java/com/payment/platform/module/payment/leshouying/client/LeshouyingClientTest.javaInterfaces:
LeshouyingSigner.sign(Map,String), LeshouyingResponseParser.parseV3(String)LeshouyingSensitive.maskByKey(String)(Task 2) —— 出站 trace 日志脱敏。用 maskByKey 不要用 mask(见 Task 2 Produces 的判据)。脱敏逻辑已在 Task 2 单测覆盖,本任务不要为它再写日志捕获测试(脆弱且重复)。Produces:
Map<String,Object> LeshouyingClient.post(String path, Map<String,Object> bizParams)
—— 自动补 inst_no、sign,POST JSON,返回解析后的 map(通信失败抛 LeshouyingApiException)LeshouyingProperties.getInstNo() / getKey() / getBaseUrl() / getSyncPollTimes() / getSyncPollIntervalMs() / getBarcodePollMaxTimes() / getH5ExpireMinutes() / getDefaultQrExpireMinutes()[ ] Step 1: 写配置类
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<String> 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 ls-files 查得到),key 后面直接跟明文就等于把机构密钥提交进仓库。
同一文件里 alipay-public-key / app-id / app-private-key 都留着空值 "",
可见本仓库的既有意图正是不把通道密钥写进这个文件。同一个配置块里的 notify-url 已经是这个写法 —— 密钥留明文会让这个块自相矛盾。
# 乐收赢支付通道
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: 写失败的测试
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<String, Object> 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<String, Object> sent = (Map<String, Object>) 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<String, Object> 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<String, Object> sent = (Map<String, Object>) 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: 运行测试确认失败
cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LeshouyingClientTest
Expected: 编译失败 —— LeshouyingClient 不存在。
[ ] Step 5: 实现 LeshouyingClient
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<String, Object> post(String path, Map<String, Object> bizParams) {
Map<String, Object> 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<Map<String, Object>> entity = new HttpEntity<>(payload, headers);
long start = System.currentTimeMillis();
ResponseEntity<String> 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: 运行测试确认全绿
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: 确认工作区状态(不提交)
git status --short
幂等的实现基础。受影响行数 = 1 才触发下游回调(spec §5.2)。
Files:
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyOrderService.java(本任务只写状态流转部分)java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyQueryService.java(仅骨架:持有 LeshouyingClient 一个字段 + 构造器,本任务不加任何方法。Task 6 Step 4 再追加 queryOrder)java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyOrderServiceStateTest.javaInterfaces:
LsyOrderEntity, LsyOrderMapper, LsyOrderStatus.PAY_WRITE_FROM / REFUND_WRITE_FROM, LeshouyingClient, LeshouyingResponseParser, LeshouyingProperties, LsyQueryServiceLsyOrderService(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: 写失败的测试
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<UpdateWrapper> 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<String> 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<LsyOrderEntity> 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<UpdateWrapper> 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<String> 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<LsyOrderEntity> 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: 运行测试确认失败
cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LsyOrderServiceStateTest
Expected: 编译失败。
创建 LsyOrderService.java,本任务只写以下方法(下单方法在 Task 6-8 追加):
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<String> 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<LsyOrderEntity> 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<LsyOrderEntity> 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: 运行测试确认全绿
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: 确认工作区状态(不提交)
git status --short
唯一需要同步轮询的场景。 用户就站在收银台前,要有即时反馈。
Files:
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyOrderService.java:1(追加 createBarcodeOrder)java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyQueryService.java(本任务只写 queryOrder)java/src/main/java/com/payment/platform/module/payment/leshouying/dto/LsyPayResultDTO.javajava/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyBarcodePayTest.javaInterfaces:
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 messageMap<String,Object> LsyQueryService.queryOrder(LsyOrderEntity order)LsyOrderEntity LsyOrderService.payPatch(Map<String,Object> resp, Long receiptFallback)
包内可见(非 private):Task 12 的回调报文与查询报文同形,直接复用它,不要自己再写一份映射。String LsyOrderService.settleFromQuery(LsyOrderEntity order, Map<String,Object> resp)
"SUCCESS" / "FAIL" / null(支付中或条件更新未命中)。
全模块唯一解释「上游支付报文」的地方;Task 16 两个调度器、Task 7/8 都调它。
调用方见 null 时不得据此撤销订单 —— 它无法区分「仍支付中」和「已被回调抢先」,
要撤销必须另查一次交易(见 Task 16 Step 3 的复查逻辑)。[ ] Step 1: 写失败的测试
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<String, Object> resp(String resultCode) {
Map<String, Object> 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<String, Object> 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<String, Object> 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: 运行测试确认失败
cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LsyBarcodePayTest
Expected: 编译失败。
[ ] Step 3: 写 LsyPayResultDTO
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
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<String, Object> queryOrder(LsyOrderEntity order) {
Map<String, Object> 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 个字段和构造器,只在类中追加常量和下列方法:
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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<LsyOrderEntity>()
.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:
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: 运行测试确认全绿
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 的状态机测试
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: 确认工作区状态(不提交)
git status --short
关键约束:qrpay 不返回 PAY_IN_PROCESS(spec §2.1),因此不做同步轮询。
Files:
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyOrderService.java(追加 createQrOrder)java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyQrPayTest.javaInterfaces:
LeshouyingClient.post, LsyOrderService.settleAfterPay(本任务复用但 allowSyncPoll=false)Produces:
LsyPayResultDTO LsyOrderService.createQrOrder(LsyOrderEntity order, Integer timeExpireMinutes)[ ] Step 1: 写失败的测试
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<String, Object> 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<String, Object> 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<String, Object> sent = captor.getValue();
assertFalse(sent.containsKey("pay_type"),
"聚合码自动识别通道,传 pay_type 会被上游拒绝");
}
@Test
void qr_defaultExpireApplied_whenNotSpecified() {
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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: 运行测试确认失败
cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LsyQrPayTest
Expected: 编译失败 —— createQrOrder 不存在。
追加到 LsyOrderService:
/** 上游硬限制:聚合码有效期最大 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<String, Object> 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<String, Object> 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<LsyOrderEntity>()
.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 的
LsyPayResultVOjavadoc(已写明 「主扫与 H5 的下单成功落PAYING(不是 SUCCESS)—— 下单成功 ≠ 收款成功」), 以及 Task 15 各端点方法的 javadoc。 本计划不产出独立的「下游接入文档」文件(18 个任务的 Files 段里没有任何.md交付物), 所以不要把这句话读成一个待建文档 —— 语义靠代码内的 javadoc 承载, 对接方要的说明由交付时另行提供。
[ ] Step 4: 运行测试确认全绿
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: 确认工作区状态(不提交)
git status --short
pay_type 必填(300/400),与聚合码相反;会返回 PAY_IN_PROCESS,但用户被跳转到支付 App,不做同步轮询。
Files:
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyOrderService.java(追加 createH5Order)java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyH5PayTest.javaInterfaces:
LeshouyingClient.post, LsyOrderService.settleAfterPayProduces: LsyPayResultDTO LsyOrderService.createH5Order(LsyOrderEntity order, String payType)
[ ] Step 1: 写失败的测试
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<String, Object> resp(String rc) {
Map<String, Object> 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<String, Object> 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: 运行测试确认失败
cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LsyH5PayTest
Expected: 编译失败。
追加到 LsyOrderService:
/**
* 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<String, Object> 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<String, Object> 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<String, Object> 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<LsyOrderEntity>()
.eq("id", order.getId()));
order.setCodeUrl(codeUrl);
order.setPayType(payType);
}
[ ] Step 4: 运行测试确认全绿
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: 跑一遍本模块全部测试
cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest='Lsy*Test'
[ ] Step 6: 确认工作区状态(不提交)
git status --short
本任务的核心是规避 spec §2.4 的陷阱:cancelorder 对已支付的单会退款给用户,但响应无法区分"关单"与"退款"。
Files:
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyCancelService.javajava/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyCancelServiceTest.javaInterfaces:
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: 写失败的测试
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<String, Object> resp(String resultCode) {
Map<String, Object> 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<String, Object> 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: 运行测试确认失败
cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LsyCancelServiceTest
Expected: 编译失败。
[ ] Step 3: 实现 LsyCancelService
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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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: 运行测试确认全绿
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: 确认工作区状态(不提交)
git status --short
字段语义陷阱:上游退款接口的 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:
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyRefundService.javajava/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyRefundServiceTest.javaInterfaces:
LeshouyingClient.post, LeshouyingResponseParser, LsyRefundMapper(含 selectList / insert / update), LsyRefundStatus.PENDING, LsyOrderService.applyRefundResultProduces:
LsyRefundEntity LsyRefundService.createRefund(LsyOrderEntity order, String outRefundNo, long refundFee)
—— 同步成功时会连同订单侧一起收敛,调用方不要再自己累加 refundedAmountMap<String,Object> 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。LsyRefundService(LeshouyingClient, LeshouyingResponseParser, LsyRefundMapper, LsyOrderService)[ ] Step 1: 写失败的测试
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<String, Object> resp(String rc) {
Map<String, Object> 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<String, Object> 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: 运行测试确认失败
cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LsyRefundServiceTest
Expected: 编译失败。
[ ] Step 3: 实现 LsyRefundService
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<String> 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<LsyRefundEntity> pending = refundMapper.selectList(new LambdaQueryWrapper<LsyRefundEntity>()
.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<String, Object> 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<String, Object> 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<String, Object> queryRefund(LsyRefundEntity refund) {
Map<String, Object> 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<LsyRefundEntity> 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:
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.payment.platform.module.payment.leshouying.enums.LsyRefundStatus;
import java.time.OffsetDateTime;
[ ] Step 4: 运行测试确认全绿
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: 确认工作区状态(不提交)
git status --short
三种下单都需要把企业映射成 mch_no。本任务很小但为 Task 12 铺路,单独成一个测试周期。
Files:
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyMerchantService.javajava/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyMerchantServiceTest.javaInterfaces:
LsyMerchantMapperProduces:
LsyMerchantEntity LsyMerchantService.requireByEnterprise(Long tenantId, String enterpriseId)LsyMerchantEntity LsyMerchantService.requireByMchNo(String mchNo)PageResult<LsyMerchantEntity> LsyMerchantService.page(int pageNo, int pageSize, String keyword)LsyMerchantEntity LsyMerchantService.save(LsyMerchantEntity entity)[ ] Step 1: 写失败的测试
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: 运行测试确认失败
cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LsyMerchantServiceTest
Expected: 编译失败。
[ ] Step 3: 实现 LsyMerchantService
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<LsyMerchantEntity>()
.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<LsyMerchantEntity>()
.eq(LsyMerchantEntity::getMchNo, mchNo)
.last("LIMIT 1"));
if (m == null) {
throw new BusinessException(404, "乐收赢商户号不存在: " + mchNo);
}
return m;
}
public PageResult<LsyMerchantEntity> page(int pageNo, int pageSize, String keyword) {
Page<LsyMerchantEntity> page = new Page<>(pageNo, pageSize);
LambdaQueryWrapper<LsyMerchantEntity> 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<LsyMerchantEntity> 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: 运行测试确认全绿
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: 确认工作区状态(不提交)
git status --short
本任务是安全边界。入口免登录,MD5 验签是唯一身份证明。
Files:
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyNotifyService.javajava/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingNotifyController.javajava/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyNotifyServiceTest.javaInterfaces:
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 / LsyCallbackServiceLsyNotifyService(LeshouyingSigner, LeshouyingProperties, LsyOrderMapper, LsyRefundMapper, LsyNotifyLogMapper, LsyCallbackService, LsyOrderService, LsyRefundService)String LsyNotifyService.handlePayNotify(Map<String,Object> params, String rawBody) —— 返回应答 JSON 字符串String LsyNotifyService.handleRefundNotify(Map<String,Object> 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。三条硬约束:
- 只脱敏字符串,绝不改
paramsmap。signer.verify(params, key)吃的是原文; 动了 map 就是改坏验签 —— 而验签是绝不能跳过的。- 先 mask 再
StrUtil.brief,顺序不能反。brief是「头 + 尾」缩略而非前缀截断, 一个 18 位付款码跨在截断点上会被切成两段 6 位,值层要求连续 12 位、两段都接不住 —— 等于原样泄漏。- 不要删
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: 写失败的测试
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<String, Object> signed(Map<String, Object> body) {
Map<String, Object> m = new LinkedHashMap<>(body);
m.put("sign", signer.sign(m, KEY));
return m;
}
private Map<String, Object> payBody(String resultCode) {
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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: 运行测试确认失败
cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LsyNotifyServiceTest
Expected: 编译失败。
[ ] Step 3: 实现 LsyNotifyService
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<String, Object> 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<LsyOrderEntity>()
.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<String, Object> 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<LsyRefundEntity>()
.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<String, Object> 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
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<String, Object> 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<String, Object> 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<String, Object> body) {
try {
return cn.hutool.json.JSONUtil.toJsonStr(body);
} catch (Exception e) {
return String.valueOf(body);
}
}
}
[ ] Step 5: 运行测试确认全绿
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。
中间那两条不是覆盖率凑数,各自是某条规则的唯一执行点:
result_code 是在本类里裸比较的,
任何 grep 都看不见 x ? A : B 这种形态,只有测试能挡住它被改回二元分派。LsyNotifyService 有没有把脱敏器接上。
去掉 logNotify 里的 LeshouyingSensitive.mask(...),Task 2 的 26 条照样全绿 ——
而 pay_lsy_notify_log.raw_body 是本模块唯一能把任意字段写进库的通道。找不到退款单那条:钉住新增的 REFUND_NOT_FOUND 审计行,且同时钉住
「归属不明的回调 tenant_id 必须是 null」这个裁定。)
[ ] Step 6: 确认工作区状态(不提交)
git status --short
Files:
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyCallbackService.javajava/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyCallbackServiceTest.javaInterfaces:
LsyOrderMapper, LsyRefundMapper, OpenConfMapper, TenantApiKeyMapper, RestTemplate, LeshouyingProperties(构造器共 6 参)Produces:
void LsyCallbackService.notifyOrder(LsyOrderEntity order)void LsyCallbackService.notifyRefund(LsyRefundEntity refund)void LsyCallbackService.retryFailed() —— 供对账调度器调用[ ] Step 1: 写失败的测试
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: 运行测试确认失败
cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LsyCallbackServiceTest
Expected: 编译失败。
[ ] Step 3: 实现 LsyCallbackService
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<String, Object> 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<String, Object> 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<LsyOrderEntity> orders = orderMapper.selectList(
new LambdaQueryWrapper<LsyOrderEntity>()
.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<LsyRefundEntity> refunds = refundMapper.selectList(
new LambdaQueryWrapper<LsyRefundEntity>()
.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<OpenConfEntity>()
.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<String, Object> content, String label) {
try {
MultiValueMap<String, Object> 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<String> 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: 运行测试确认全绿
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: 确认工作区状态(不提交)
git status --short
漏改任意一处都会造成静默故障。 这是本任务存在的唯一理由。
Files:
java/src/main/java/com/payment/platform/core/security/TenantApiKeyAuthFilter.java:92-96java/src/main/java/com/payment/platform/core/security/SecurityConfig.java:38-70java/src/main/java/com/payment/platform/core/tenant/TenantInnerInterceptor.java:53-76java/src/test/java/com/payment/platform/core/security/LeshouyingWiringTest.javaInterfaces:
Produces: 无(纯配置改动)
[ ] Step 1: 写失败的测试
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<String> tables = (Set<String>) 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: 运行测试确认失败
cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LeshouyingWiringTest
Expected: 4 个测试全部 FAIL。
把 OPENAPI_ACCOUNT_PATHS(第 92-96 行)改为:
/** 仅对 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",
};
在 WHITE_LIST 中 /payment/aplipay/auth 之后插入:
// 乐收赢回调入口(免登录;身份由 MD5 验签保证,不通过则不写任何业务状态)
"/payment/leshouying/notify/**",
pay_batch_authorize 是当前最后一项、末尾没有逗号。把那一行改成下面这样(先补逗号,再追加四项):
"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: 运行测试确认全绿
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: 跑全量测试确认没有破坏既有功能
cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test
Expected: 全绿。若有既有测试因 OPENAPI_ACCOUNT_PATHS 长度变化而失败,同步更新其断言。
[ ] Step 8: 确认工作区状态(不提交)
git status --short
Files:
java/src/main/java/com/payment/platform/module/payment/leshouying/dto/*.java(7 个请求 DTO + 1 个响应 VO。响应 VO 只有 LsyPayResultVO 一个;orderQuery / orderClose / refund / refundQuery 四个端点返回 Result<Map<String,Object>>,不为它们建 VO)java/src/main/java/com/payment/platform/module/payment/leshouying/util/LsyBizNoPattern.java(Step 1 的 @Pattern 唯一字符集来源,与 LsyLogSanitizer 逐字一致)java/src/main/java/com/payment/platform/module/payment/leshouying/controller/open/LeshouyingOpenController.javajava/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyRefundService.java(Step 5 追加租户限定的 requireByOutRefundNo)java/src/test/java/com/payment/platform/module/payment/leshouying/controller/open/LeshouyingOpenControllerTest.javaInterfaces:
LsyMerchantService.requireByEnterprise, LsyOrderService.create{Barcode,Qr,H5}Order, LsyQueryService.queryOrder, LsyCancelService.closeOrder, LsyRefundService.createRefund/queryRefund, LsyCallbackService.notifyOrderOPENAPI_PATHSLeshouyingOpenController(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"),不得读请求体里的租户字段;漏掉即跨租户越权。
★ 租户可控的业务单号一律在入口加
@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 都要引用):
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:
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:
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:
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:
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:
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:
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:
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(下单统一响应):
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: 写失败的测试
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: 运行测试确认失败
cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LeshouyingOpenControllerTest
Expected: 编译失败。
[ ] Step 4: 实现 LeshouyingOpenController
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<Map<String, Object>> orderQuery(@Valid @RequestBody LsyOrderQueryDTO dto) {
Long tenantId = tenantId();
LsyOrderEntity order = requireOrder(tenantId, dto.getOutTradeNo(), dto.getTradeNo());
Map<String, Object> upstream = queryService.queryOrder(order);
// 本地状态为准(回调可能比查询更早到达);上游报文一并返回便于对账
Map<String, Object> 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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> refundQuery(@Valid @RequestBody LsyRefundQueryDTO dto) {
Long tenantId = tenantId();
LsyRefundEntity refund = refundService.requireByOutRefundNo(tenantId, dto.getOutRefundNo());
Map<String, Object> upstream = refundService.queryRefund(refund);
Map<String, Object> 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<LsyOrderEntity>()
.eq(LsyOrderEntity::getTenantId, tenantId)
.eq(LsyOrderEntity::getOutTradeNo, outTradeNo)
.last("LIMIT 1"));
}
/** ★ 同样必须带 tenantId:否则可查/可关别的租户的订单。 */
private LsyOrderEntity requireOrder(Long tenantId, String outTradeNo, String tradeNo) {
LambdaQueryWrapper<LsyOrderEntity> 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 是必传参数,不是可选项 —— 见下面「跨租户隔离」说明:
/** 下游 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<LsyRefundEntity>()
.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 就地更正(原文的机理写错了,实测推翻)。原文见下方引用块。
实测事实(三条独立证据):
TenantApiKeyAuthFilter:222-231确实往SecurityContextHolder放了一个 带tenantId的LoginUser(第 219-221 行的注释还专门写明「principal 必须是LoginUser(带 tenantId)而非裸 Long」,理由是pay_account等既有表要靠它取到正确租户)。 ⇒ ApiKey 请求有认证上下文,不是「无认证」。TenantInnerInterceptor的判别式是auth.getPrincipal() instanceof LoginUser(:151、:168)。 命中该分支且tenantId != null⇒ignoreTable返回false⇒ 照常追加AND tenant_id = <该 ApiKey 的租户>。- 测试
TenantInnerInterceptorTest.ignoreTable_authenticatedTenantUser_stillFiltersLsyTables用tenantId=7L的LoginUser断言四张pay_lsy_*表全部返回false—— 钉住的就是这个分支。⇒ 显式租户条件不是「唯一防线」,是「第二道防线」;但它仍然必须写,理由有三条:
- 拦截器的保护以「filter 已经跑过」为前提。 若某条路径漏加进
OPENAPI_ACCOUNT_PATHS(正是 Task 14 要防的那个静默故障),该请求就没有认证对象 ⇒ignoreTable走无认证分支 返回true⇒ 整条跳过租户过滤。此时显式条件是唯一的兜底。 (该情形下openapi.tenantId属性也为 null ⇒ 控制器应 401,两重保护。)- 该行为依赖一个非常窄的分支(
principal instanceof LoginUser)。 任何对 filter principal 类型的改动(例如图省事塞个裸Long)都会静默改变它。- 与既有
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: 运行测试确认全绿
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: 确认工作区状态(不提交)
git status --short
Files:
java/src/main/java/com/payment/platform/module/payment/leshouying/scheduler/LsyOrderPollScheduler.javajava/src/main/java/com/payment/platform/module/payment/leshouying/scheduler/LsyReconcileScheduler.javajava/src/test/java/com/payment/platform/module/payment/leshouying/scheduler/LsySchedulerTest.javaInterfaces:
LsyOrderMapper, LsyQueryService, LsyCancelService, LsyOrderService.settleFromQuery(Task 6 已定义), LsyCallbackService, LeshouyingPropertiesvoid LsyOrderPollScheduler.pollBarcodeOrders()void LsyReconcileScheduler.reconcile()两个调度器都不注入
LeshouyingResponseParser:报文怎么解释是LsyOrderService.settleFromQuery的职责。调度器只决定「何时查、查几次、耗尽后怎么办」。
[ ] Step 1: 写失败的测试
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<String, Object> resp(String rc) {
Map<String, Object> 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: 运行测试确认失败
cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LsySchedulerTest
Expected: 编译失败。
[ ] Step 3: 实现 LsyOrderPollScheduler
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<LsyOrderEntity> orders = orderMapper.selectList(
new LambdaQueryWrapper<LsyOrderEntity>()
.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<String, Object> 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
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<LsyOrderEntity> orders = orderMapper.selectList(
new LambdaQueryWrapper<LsyOrderEntity>()
.eq(LsyOrderEntity::getStatus, "PAYING")
.in(LsyOrderEntity::getPayScene, List.of("QRCODE", "H5"))
.le(LsyOrderEntity::getCreatedTime, threshold)
.last("LIMIT 100"));
for (LsyOrderEntity order : orders) {
try {
Map<String, Object> 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<LsyOrderEntity> orders = orderMapper.selectList(
new LambdaQueryWrapper<LsyOrderEntity>()
.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<LsyOrderEntity> h5Orders = orderMapper.selectList(
new LambdaQueryWrapper<LsyOrderEntity>()
.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<LsyOrderEntity> stale = orderMapper.selectList(
new LambdaQueryWrapper<LsyOrderEntity>()
// 用常量而不是字面量 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: 运行测试确认全绿
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: 跑本模块全部测试
cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest='Lsy*Test,Leshouying*Test'
[ ] Step 7: 确认工作区状态(不提交)
git status --short
Files:
java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingOrderController.javajava/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingRefundController.javajava/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingMerchantController.javajava/src/test/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingAdminWiringTest.javaInterfaces:
LsyOrderMapper, LsyRefundMapper, LsyNotifyLogMapper, LsyMerchantService本任务不做 test-first。 三个控制器是纯 CRUD 适配层——分页条件拼装 + 转发给 Service/Mapper, 没有分支逻辑,给它写 mock 测试只能测出「我调了我自己」,是负价值。 业务逻辑的测试在 Task 11(
LsyMerchantServiceTest)已经覆盖。 这里唯一值得守的是一个安全边界:管理端路径绝不能混进 ApiKey 白名单, 否则任何持 API Key 的租户都能读到全平台订单。所以下面的测试守的是这条线。
LeshouyingMerchantController.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<PageResult<LsyMerchantEntity>> 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<LsyMerchantEntity> save(@RequestBody LsyMerchantEntity entity) {
return Result.ok(merchantService.save(entity));
}
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable Long id) {
merchantService.delete(id);
return Result.ok();
}
}
LeshouyingOrderController.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<PageResult<LsyOrderEntity>> 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<LsyOrderEntity> page = new Page<>(pageNo, pageSize);
LambdaQueryWrapper<LsyOrderEntity> 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<LsyOrderEntity> r = orderMapper.selectPage(page, w);
return Result.ok(PageResult.of(pageNo, pageSize, r.getTotal(), r.getRecords()));
}
/** 详情含回调审计轨迹,便于对账排查 */
@GetMapping("/{outTradeNo}")
public Result<LsyOrderEntity> 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<List<LsyNotifyLogEntity>> notifyLogs(@PathVariable String outTradeNo) {
LsyOrderEntity order = requireOrder(outTradeNo);
LambdaQueryWrapper<LsyNotifyLogEntity> 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<LsyOrderEntity>()
.eq(LsyOrderEntity::getOutTradeNo, outTradeNo).last("LIMIT 1"));
if (o == null) {
throw new BusinessException(404, "订单不存在");
}
return o;
}
}
LeshouyingRefundController.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<PageResult<LsyRefundEntity>> page(
@RequestParam(defaultValue = "1") int pageNo,
@RequestParam(defaultValue = "20") int pageSize,
@RequestParam(required = false) String status,
@RequestParam(required = false) String keyword) {
Page<LsyRefundEntity> page = new Page<>(pageNo, pageSize);
LambdaQueryWrapper<LsyRefundEntity> 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<LsyRefundEntity> r = refundMapper.selectPage(page, w);
return Result.ok(PageResult.of(pageNo, pageSize, r.getTotal(), r.getRecords()));
}
@GetMapping("/{outRefundNo}")
public Result<LsyRefundEntity> detail(@PathVariable String outRefundNo) {
return Result.ok(refundMapper.selectOne(new LambdaQueryWrapper<LsyRefundEntity>()
.eq(LsyRefundEntity::getOutRefundNo, outRefundNo).last("LIMIT 1")));
}
}
三个控制器的 Result 工厂必须用 Result.ok(...)(成功码 0)。
Result 类没有 success() / error() 方法 —— 写错会直接编译失败。
[ ] Step 2: 写安全边界守卫测试
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: 运行测试确认全绿
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: 编译全量确认控制器无语法问题
cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test-compile
[ ] Step 5: 确认工作区状态(不提交)
git status --short
Files:
frontend/src/api/module_payment/leshouying.tsfrontend/src/views/module_payment/leshouying/order/index.vuefrontend/src/views/module_payment/leshouying/refund/index.vuefrontend/src/views/module_payment/leshouying/merchant/index.vuejava/src/main/resources/db/lsy-menu.sql(不执行)Interfaces:
Produces: 三个页面 + 菜单数据
[ ] Step 1: 写 API 封装
frontend/src/api/module_payment/leshouying.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<string, unknown>) {
return request({ url: '/payment/leshouying/merchant', method: 'post', data })
}
export function deleteLsyMerchant(id: number) {
return request({ url: `/payment/leshouying/merchant/${id}`, method: 'delete' })
}
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。
<template>
<div class="app-container">
<el-card shadow="never">
<el-form :inline="true" :model="query">
<el-form-item label="订单号">
<el-input v-model="query.keyword" placeholder="下游/上游订单号" clearable
style="width: 240px" @keyup.enter="handleSearch" />
</el-form-item>
<el-form-item label="场景">
<el-select v-model="query.payScene" placeholder="全部" clearable style="width: 140px">
<el-option label="被扫" value="BARCODE" />
<el-option label="主扫" value="QRCODE" />
<el-option label="H5" value="H5" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="query.status" placeholder="全部" clearable style="width: 160px">
<el-option v-for="s in STATUS_OPTIONS" :key="s.value"
:label="s.label" :value="s.value" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card shadow="never" style="margin-top: 12px">
<el-table v-loading="loading" :data="list" border stripe>
<el-table-column prop="outTradeNo" label="下游订单号" min-width="200" show-overflow-tooltip />
<el-table-column label="场景" width="90">
<template #default="{ row }">{{ sceneLabel(row.payScene) }}</template>
</el-table-column>
<el-table-column label="金额(元)" width="110" align="right">
<template #default="{ row }">{{ fen2yuan(row.totalAmount) }}</template>
</el-table-column>
<el-table-column label="已退(元)" width="110" align="right">
<template #default="{ row }">{{ fen2yuan(row.refundedAmount) }}</template>
</el-table-column>
<el-table-column label="状态" width="130">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="tradeNo" label="乐收赢订单号" min-width="200" show-overflow-tooltip />
<el-table-column label="回调" width="100">
<template #default="{ row }">
<el-tag v-if="row.notifyStatus === 'SUCCESS'" type="success" size="small">已送达</el-tag>
<el-tag v-else-if="row.notifyStatus === 'FAIL'" type="danger" size="small">
失败({{ row.notifyCount }})
</el-tag>
<el-tag v-else type="info" size="small">待推送</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdTime" label="创建时间" width="180" />
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="query.pageNo"
v-model:page-size="query.pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
style="margin-top: 12px; justify-content: flex-end"
@change="loadData"
/>
</el-card>
<el-drawer v-model="detailVisible" title="订单详情" size="720px">
<el-descriptions v-if="detail" :column="1" border>
<el-descriptions-item label="下游订单号">{{ detail.outTradeNo }}</el-descriptions-item>
<el-descriptions-item label="乐收赢商户号">{{ detail.mchNo }}</el-descriptions-item>
<el-descriptions-item label="支付流水号">{{ detail.payTraceNo }}</el-descriptions-item>
<el-descriptions-item label="乐收赢订单号">{{ detail.tradeNo || '-' }}</el-descriptions-item>
<el-descriptions-item label="渠道订单号">{{ detail.tdTradeNo || '-' }}</el-descriptions-item>
<el-descriptions-item label="金额">
{{ fen2yuan(detail.totalAmount) }} 元 / 实收 {{ fen2yuan(detail.receiptAmount) }} 元
</el-descriptions-item>
<el-descriptions-item label="上游结果">
{{ detail.upstreamResultCode }} {{ detail.upstreamResultMsg }}
</el-descriptions-item>
<el-descriptions-item v-if="detail.codeUrl" label="支付链接">
<el-link :href="detail.codeUrl" target="_blank" type="primary">{{ detail.codeUrl }}</el-link>
</el-descriptions-item>
<el-descriptions-item label="备注">{{ detail.description || '-' }}</el-descriptions-item>
</el-descriptions>
<h4 style="margin-top: 20px">回调轨迹</h4>
<el-table :data="notifyLogs" border size="small">
<el-table-column prop="createdTime" label="时间" width="180" />
<el-table-column prop="tradeType" label="类型" width="70">
<template #default="{ row }">{{ tradeTypeLabel(row.tradeType) }}</template>
</el-table-column>
<el-table-column prop="verifyStatus" label="验签" width="80">
<template #default="{ row }">
<el-tag :type="row.verifyStatus === 'SUCCESS' ? 'success' : 'danger'" size="small">
{{ row.verifyStatus }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="processStatus" label="处理" width="130" />
<el-table-column label="原文" min-width="200">
<template #default="{ row }">
<el-popover placement="left" :width="600" trigger="click">
<template #reference><el-button link type="primary">查看</el-button></template>
<pre style="max-height: 400px; overflow: auto">{{ row.rawBody }}</pre>
</el-popover>
</template>
</el-table-column>
</el-table>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import {
getLsyOrderPage, getLsyOrderDetail, getLsyNotifyLogs
} from '@/api/module_payment/leshouying'
const STATUS_OPTIONS = [
{ label: '已建单', value: 'CREATED' },
{ label: '支付中', value: 'PAYING' },
{ label: '支付成功', value: 'SUCCESS' },
{ label: '支付失败', value: 'FAIL' },
{ label: '已撤销', value: 'CANCELED' },
{ label: '已关单', value: 'CLOSED' },
{ label: '已过期', value: 'EXPIRED' },
{ label: '部分退款', value: 'PARTIAL_REFUNDED' },
{ label: '已全额退款', value: 'REFUNDED' }
]
const SCENE_LABEL: Record<string, string> = {
BARCODE: '被扫', QRCODE: '主扫', H5: 'H5'
}
const query = reactive({
pageNo: 1, pageSize: 20, status: '', payScene: '', keyword: ''
})
const list = ref<any[]>([])
const total = ref(0)
const loading = ref(false)
const detailVisible = ref(false)
const detail = ref<any>(null)
const notifyLogs = ref<any[]>([])
function sceneLabel(v: string) { return SCENE_LABEL[v] || v }
function fen2yuan(fen?: number | null) {
return fen == null ? '-' : (fen / 100).toFixed(2)
}
function statusLabel(v: string) {
return STATUS_OPTIONS.find(s => s.value === v)?.label || v
}
function statusTagType(v: string) {
if (v === 'SUCCESS') return 'success'
if (v === 'FAIL') return 'danger'
if (v === 'PAYING' || v === 'CREATED') return 'warning'
if (v === 'REFUNDED' || v === 'PARTIAL_REFUNDED') return 'info'
return 'info'
}
function tradeTypeLabel(v: string) {
return ({ '0': '退款', '1': '交易', '2': '云订单关单' } as Record<string, string>)[v] || v || '-'
}
async function loadData() {
loading.value = true
try {
const res: any = await getLsyOrderPage(query)
// res 是 AxiosResponse(request.ts 的响应拦截器 return response 不拆包)
// → res.data = {code,msg,data} → res.data.data = PageResult
// PageResult 的分页字段名是 list(不是 records/items)
list.value = res.data.data?.list || []
total.value = res.data.data?.total || 0
} finally {
loading.value = false
}
}
function handleSearch() { query.pageNo = 1; loadData() }
function handleReset() {
query.keyword = ''; query.status = ''; query.payScene = ''; query.pageNo = 1; loadData()
}
async function openDetail(row: any) {
const res: any = await getLsyOrderDetail(row.outTradeNo)
// ★ 两层 data:`request.ts` 的响应拦截器 `return response`,**不做解包**,
// 所以 res.data 是整个 `{code,msg,data}` 信封,业务数据在 res.data.data。
// 写成 res.data 会把信封塞给 el-descriptions,字段全部渲染成空 —— 页面不报错,
// 只是"什么都没显示",是那种查半天查不出来的假成功。
// (本仓库既有写法即两层:全库 `.data.data` 出现 264 处。)
detail.value = res.data.data
const logs: any = await getLsyNotifyLogs(row.outTradeNo)
notifyLogs.value = logs.data.data || []
detailVisible.value = true
}
onMounted(loadData)
</script>
frontend/src/views/module_payment/leshouying/refund/index.vue —— 结构与订单页同构,字段换成退款单:
<template>
<div class="app-container">
<el-card shadow="never">
<el-form :inline="true" :model="query">
<el-form-item label="退款单号">
<el-input v-model="query.keyword" placeholder="下游/上游退款单号" clearable
style="width: 240px" @keyup.enter="handleSearch" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="query.status" placeholder="全部" clearable style="width: 140px">
<el-option label="已建单" value="CREATED" />
<el-option label="退款中" value="REFUNDING" />
<el-option label="退款成功" value="SUCCESS" />
<el-option label="退款失败" value="FAIL" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card shadow="never" style="margin-top: 12px">
<el-table v-loading="loading" :data="list" border stripe>
<el-table-column prop="outRefundNo" label="下游退款单号" min-width="200" show-overflow-tooltip />
<el-table-column prop="outTradeNo" label="原订单号" min-width="200" show-overflow-tooltip />
<el-table-column label="退款金额(元)" width="130" align="right">
<template #default="{ row }">{{ fen2yuan(row.refundFee) }}</template>
</el-table-column>
<el-table-column label="实退(元)" width="110" align="right">
<template #default="{ row }">{{ fen2yuan(row.refundSettle) }}</template>
</el-table-column>
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-tag :type="row.status === 'SUCCESS' ? 'success'
: row.status === 'FAIL' ? 'danger' : 'warning'">
{{ statusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="refundNo" label="乐收赢退款单号" min-width="200" show-overflow-tooltip />
<el-table-column prop="createdTime" label="创建时间" width="180" />
</el-table>
<el-pagination
v-model:current-page="query.pageNo"
v-model:page-size="query.pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
style="margin-top: 12px; justify-content: flex-end"
@change="loadData"
/>
</el-card>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { getLsyRefundPage } from '@/api/module_payment/leshouying'
const STATUS_LABEL: Record<string, string> = {
CREATED: '已建单', REFUNDING: '退款中', SUCCESS: '退款成功', FAIL: '退款失败'
}
const query = reactive({ pageNo: 1, pageSize: 20, status: '', keyword: '' })
const list = ref<any[]>([])
const total = ref(0)
const loading = ref(false)
function fen2yuan(fen?: number | null) {
return fen == null ? '-' : (fen / 100).toFixed(2)
}
function statusLabel(v: string) { return STATUS_LABEL[v] || v }
async function loadData() {
loading.value = true
try {
const res: any = await getLsyRefundPage(query)
list.value = res.data.data?.list || []
total.value = res.data.data?.total || 0
} finally {
loading.value = false
}
}
function handleSearch() { query.pageNo = 1; loadData() }
function handleReset() { query.keyword = ''; query.status = ''; query.pageNo = 1; loadData() }
onMounted(loadData)
</script>
frontend/src/views/module_payment/leshouying/merchant/index.vue:
<template>
<div class="app-container">
<el-alert type="info" :closable="false" style="margin-bottom: 12px"
title="A 阶段手工维护:企业的乐收赢商户号。不配置则无法为该企业下单。进件功能上线后将自动写入。" />
<el-card shadow="never">
<el-form :inline="true" :model="query">
<el-form-item label="关键字">
<el-input v-model="query.keyword" placeholder="商户号 / 商户名 / 企业ID" clearable
style="width: 260px" @keyup.enter="handleSearch" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button type="success" @click="openForm(null)">新增</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card shadow="never" style="margin-top: 12px">
<el-table v-loading="loading" :data="list" border stripe>
<el-table-column prop="enterpriseId" label="企业 ID" min-width="180" show-overflow-tooltip />
<el-table-column prop="mchNo" label="乐收赢商户号" min-width="160" />
<el-table-column prop="shopNo" label="门店号" min-width="140" />
<el-table-column prop="merchantName" label="商户名称" min-width="180" />
<el-table-column prop="reviewStatus" label="审核状态" width="120" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 'ENABLED' ? 'success' : 'info'">
{{ row.status === 'ENABLED' ? '启用' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdTime" label="创建时间" width="180" />
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openForm(row)">编辑</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="query.pageNo"
v-model:page-size="query.pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
style="margin-top: 12px; justify-content: flex-end"
@change="loadData"
/>
</el-card>
<el-dialog v-model="formVisible" :title="form.id ? '编辑商户映射' : '新增商户映射'" width="560px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<el-form-item label="企业 ID" prop="enterpriseId">
<el-input v-model="form.enterpriseId" placeholder="我方 pay_enterprise.enterprise_id" />
</el-form-item>
<el-form-item label="乐收赢商户号" prop="mchNo">
<el-input v-model="form.mchNo" placeholder="如 Y52211090004" />
</el-form-item>
<el-form-item label="门店号">
<el-input v-model="form.shopNo" placeholder="可空" />
</el-form-item>
<el-form-item label="商户名称">
<el-input v-model="form.merchantName" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.description" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="formVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getLsyMerchantPage, saveLsyMerchant, deleteLsyMerchant } from '@/api/module_payment/leshouying'
const query = reactive({ pageNo: 1, pageSize: 20, keyword: '' })
const list = ref<any[]>([])
const total = ref(0)
const loading = ref(false)
const formVisible = ref(false)
const formRef = ref()
const form = reactive<any>({
id: null, enterpriseId: '', mchNo: '', shopNo: '', merchantName: '', description: ''
})
const rules = {
enterpriseId: [{ required: true, message: '企业 ID 必填', trigger: 'blur' }],
mchNo: [{ required: true, message: '乐收赢商户号必填', trigger: 'blur' }]
}
async function loadData() {
loading.value = true
try {
const res: any = await getLsyMerchantPage(query)
list.value = res.data.data?.list || []
total.value = res.data.data?.total || 0
} finally {
loading.value = false
}
}
function handleSearch() { query.pageNo = 1; loadData() }
function openForm(row: any) {
Object.assign(form, row || {
id: null, enterpriseId: '', mchNo: '', shopNo: '', merchantName: '', description: ''
})
formVisible.value = true
}
async function handleSubmit() {
await formRef.value?.validate()
await saveLsyMerchant(form)
ElMessage.success('保存成功')
formVisible.value = false
loadData()
}
async function handleDelete(row: any) {
await ElMessageBox.confirm(
`确认删除商户映射「${row.mchNo}」?删除后该企业将无法下单。`, '提示', { type: 'warning' })
await deleteLsyMerchant(row.id)
ElMessage.success('已删除')
loadData()
}
onMounted(loadData)
</script>
java/src/main/resources/db/lsy-menu.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: 前端类型检查与构建
cd frontend && npx vue-tsc --noEmit --skipLibCheck && npx vite build
Expected: 无类型错误,构建成功。
[ ] Step 7: 确认工作区状态(不提交)
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 仍停在 dbe364cgit status --short 显示所有新增文件为 ??,无 M 之外的非预期改动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 -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)它照样看不见。
误报的检查比没有检查更坏:它会被忽略,而被忽略的检查会制造「已经有人守着了」的假信心 ——
我上一条就正是这么把自己骗过去的。所以这条已删除,只保留「靠测试守」这一条路。"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()))。这 7 项不影响实现,但会影响线上正确性,需与乐收赢确认:
EXPIRED,上游链接仍有效)h5pay 的轮询规则(当前靠回调 + 5 分钟兜底)v3/qrclose 的返回语义措辞与关单场景不符auth_code。 注意:这不改变任何代码 —— Task 12 已按「不假设上游不回显」
实现(脱敏放在写入口,不放在「上游应该不会」的假设上)。问它是为了知道这道防线实际有没有被用到:
若上游确实回显,脱敏就是每天都在生效的必需控制;若不回显,它是针对将来的防御。两种结论都不导致改代码。/v3/* 端点返回通用成功码 result_code: "SUCCESS"(而非 "PAY_SUCCESS")。
这条阻塞 Task 5/6 的状态机,且无法从仓库内验证。见下节。
顺带会解答一个次生问题:退款接口(Task 10)若回未知码,退款会停在 REFUNDING 不再前进
—— 不是「误报失败」那么严重,但会积压。知道取值域才能判断这是否真会发生。
注意 Task 10 的在途互斥恰好依赖它不会永久停在 REFUNDING:真停住了,这张单就再也退不了款,
得人工改状态。所以这条不只是"积压",对退款侧是阻塞性的。callbackService.notifyOrder,
而 createRefund 同步成功时是直接返回给调用方(管理端界面能看到),不触发下游 webhook。
如果租户的系统靠 webhook 感知退款,这条就得补;如果退款只走管理端查询,现状即可。
属于产品语义问题,不是缺陷 —— 所以没动代码,列在这里等答案。★ 共享 RestTemplate 没有任何超时。 来源见 Task 4 的 Minor #3(逐任务评审查出,我复核确认)。
WebMvcConfig.java:24-27 定义的 RestTemplate 是无参 new RestTemplate(),
计划全文中没有任何一处设置 connectTimeout / readTimeout;Task 4 的
LeshouyingClient 直接注入这个共享 bean(注入第二个 bean 会 NoUniqueBeanDefinitionException)。HttpURLConnection
读超时为 0 = 永不超时)。Task 16 的调度器线程同样会被卡住 —— 那不是「这一笔慢」,
是对账/兜底轮询整体停摆。TenantApiKeyAuthFilter.java:92-96 / SecurityConfig.java:38-70 / TenantInnerInterceptor.java:53-76,
不含 WebMvcConfig。而这个 bean 同时被既有的 OpenapiService 使用 ——
给它加超时会一并改变既有下游回调路径的行为,属于本计划范围之外,
该独立成一笔带自己审查的变更,不能夹在乐收赢通道里顺手做。改法:WebMvcConfig 里换成显式工厂,例如
SimpleClientHttpRequestFactory f = new SimpleClientHttpRequestFactory();
f.setConnectTimeout(5_000); // 5s
f.setReadTimeout(30_000); // 30s —— 乐收赢下单是同步调用,不宜过长
return new RestTemplate(f);
或 ClientHttpRequestFactorySettings 的 builder 形式。超时值要和乐收赢确认
(付款码支付是同步扣款,超时给太短会在用户已付款时误报失败)。
启动 Spring 上下文,打开 MyBatis SQL 日志,然后:
/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 的幂等查找。curl 一次 /payment/leshouying/notify/pay,
必须返回 {"return_code":"FAIL","return_msg":"验签失败"},且不产生任何业务状态变更。pay_lsy_order 无二次 UPDATE,pay_lsy_notify_log 有两条记录)。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 位,故不受影响;若发现被遮,说明值层阈值或报文形态与预期不符,需要复核)。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/* 响应码表)。
拿到答复前:
!isPaySuccess(...) 来判失败 —— 那会把「成功」判成「失败」落库;result_code,
因此 LsyNotifyService 的两处比较都必须是三分支,并由
payNotify_unknownResultCode_* / refundNotify_unknownResultCode_* 两条测试钉住。
这两条不是锦上添花 —— 它们是这条规则在回调路径上的唯一执行点(grep 看不见 x ? A : B)。