Преглед изворни кода

fix(退款): finish_time 只在终态流转时写 + R41 第 9 处订正 + 首次入库的 3 个模块文件

1) applyNotifyResult 的 finish_time 只在**终态**流转时写。
   该列语义是「完成时间(终态)」(V1.8:161),而台账 §2.1 的预插入 CREATED
   会让本方法第一次执行非终态流转(CREATED → REFUNDING)—— 照旧无条件写,
   会让一笔**还在途**的退款带上完成时间,事后按 `finish_time IS NULL`
   捞未完结退款就会漏掉它。判据用 NOT LsyRefundStatus.PENDING,
   不新增第二份「终态」白名单跟着漂移。
   测试:非终态不写(新行为)+ 终态仍然写(阳性对照,防"整个删掉也算绿")。
   LsyRefundServiceTest 31 绿。

2) LsyQueryDetailAssembler 里引用 refund_no 列注释那句 —— R41 清理的第 9 处。
   它引用的假括注早已改掉,但末尾「台账 §一已列进待订正清单」还停在旧状态。

3) 顺带把此前一直未跟踪的 3 个文件首次入库:
   LsyRefundService / LsyRefundServiceTest / LsyQueryDetailAssembler。
   ⚠ leshouying 模块的**其余文件仍未入库**,见后续提交。

另:本提交还扫进了工作区里此前已 `git add` 的 7 个文件
(TenantInnerInterceptor / application.yml / SecurityConfig / TenantApiKeyAuthFilter /
 WebMvcConfig / GlobalExceptionHandler / 前端 apikey/index.vue)——
它们属于同一批乐收赢改动,之前就已在暂存区,`git commit` 一并带上了。
alphaH пре 3 недеља
родитељ
комит
31dfbd8745

+ 31 - 2
frontend/src/views/module_payment/apikey/index.vue

@@ -440,6 +440,23 @@
                   </li>
                 </ul>
               </div>
+              <!--
+                条目由 collectionDoc.ts 从 md 文档切出来 —— 侧边栏、卡片标题、正文
+                三处同源。标题在 md 里被改字则对应条目自动消失(不是留下一个空白页)。
+              -->
+              <div class="sidebar-section">
+                <h3>收付款接口</h3>
+                <ul>
+                  <li
+                    v-for="(section, key) in collectionSections"
+                    :key="key"
+                    @click="activeSection = key"
+                    :class="{ active: activeSection === key }"
+                  >
+                    {{ section.title }}
+                  </li>
+                </ul>
+              </div>
               <div class="sidebar-section">
                 <h3>其他</h3>
                 <ul>
@@ -453,7 +470,6 @@
                     PHP示例代码
                   </li>
                 </ul>
-                identity_type
               </div>
             </el-scrollbar>
           </div>
@@ -1326,6 +1342,15 @@ $isValid = SignatureGenerator::verifySignature($apiSecret, $requestData, $signat
 echo "签名验证结果: " . ($isValid ? '有效' : '无效') . "\n";
 ?&gt;</code></pre>
               </div>
+
+              <!--
+                收付款接口 —— 正文由 MarkdownRenderer 渲染 collectionDoc.ts 切出的 md 片段。
+                这里刻意不写任何接口细节:页面上的每个字段名都来自那份 md,
+                而 md 的签名样例与字段名由 Java 侧契约测试钉住。
+              -->
+              <div v-else-if="collectionSections[activeSection]" class="section-content">
+                <MarkdownRenderer :content="collectionSections[activeSection]?.content || ''" />
+              </div>
             </el-card>
           </div>
         </div>
@@ -1342,6 +1367,7 @@ import ApiKeyAPI, {
   ApiKeyResponse,
 } from "@/api/module_payment/apikey";
 import OpenAPI from "@/api/module_payment/openapi";
+import { collectionSections } from "./collectionDoc";
 import CrudToolbarLeft from "@/components/CURD/CrudToolbarLeft.vue";
 import CrudToolbarRight from "@/components/CURD/CrudToolbarRight.vue";
 import PageSearch from "@/components/CURD/PageSearch.vue";
@@ -1453,7 +1479,10 @@ function toggleExpand(section: string) {
 }
 
 function getSectionTitle() {
-  const titles = {
+  // 收付款的标题与侧边栏同源(collectionDoc.ts),不在这里再抄一份
+  const collection = collectionSections[activeSection.value];
+  if (collection) return collection.title;
+  const titles: Record<string, string> = {
     auth: "认证方式",
     signature: "签名验证",
     notes: "注意事项",

+ 27 - 0
java/src/main/java/com/payment/platform/common/config/WebMvcConfig.java

@@ -4,6 +4,7 @@ import com.payment.platform.core.log.OperationLogInterceptor;
 import lombok.RequiredArgsConstructor;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Primary;
 import org.springframework.web.client.RestTemplate;
 import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
 import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@@ -21,7 +22,33 @@ public class WebMvcConfig implements WebMvcConfigurer {
                 .excludePathPatterns("/v3/api-docs/**", "/swagger-ui/**", "/doc.html", "/webjars/**", "/static/**");
     }
 
+    /**
+     * 共享 RestTemplate:**不配超时**(Spring 默认 -1 ⇒ HttpURLConnection 用 JDK 默认 0 ⇒ 永不超时)。
+     *
+     * <p><b>本 bean 的行为不得改动:</b>它的既有调用方({@code OpenapiService} 的推送路径)
+     * 在乐收赢通道之前就在用它,给它加超时 = 顺手改了那些调用方的行为。
+     * 需要有限超时的新调用方另建专用 bean(见 {@code LeshouyingHttpConfig.lsyRestTemplate})。
+     *
+     * <p>★ {@code @Primary} 是**明示意图 + 第二道保险**,**不是**当前解析路径上的必需品 ——
+     * 这一点是实测的,不是推断。容器里现在有第二个 {@code RestTemplate}(乐收赢专用那个),
+     * 而 {@code OpenapiService} 的依赖是构造器形参 {@code restTemplate}
+     * (本模块编译带 {@code -parameters},pom 里显式开了)—— 形参名与 bean 名同名,
+     * 于是多候选时 Spring 会**回落到按名匹配**。用真实的两个配置类 + 真实的
+     * {@code OpenapiService}(其余依赖 mock)起最小上下文,实测三种布局:
+     * <ul>
+     *   <li>今天({@code @Primary} 在本 bean 上)⇒ 注入**本 bean**(无超时);</li>
+     *   <li>**删掉** {@code @Primary} ⇒ 仍注入本 bean(按名回落),
+     *       **不会**抛 {@code NoUniqueBeanDefinitionException};</li>
+     *   <li>把 {@code @Primary} **挪到**乐收赢那个 bean 上 ⇒ {@code OpenapiService}
+     *       **静默**拿到**带超时**的 bean({@code @Primary} 的优先级高于按名回落)。</li>
+     * </ul>
+     * ⇒ 本注解的实际作用有两条:① 把「按类型注入的既有调用方仍然拿这个 bean」写成**显式声明**,
+     * 而不是依赖「形参名恰好同名 + 编译带了 {@code -parameters}」这条更脆的链路;
+     * ② 谁想给新 bean 加 {@code @Primary},两处注解会同时出现,是个显眼的信号。
+     * 删掉今天不会坏事(实测),但它不是装饰 —— 上面第三种布局正是要防的那个改动。
+     */
     @Bean
+    @Primary
     public RestTemplate restTemplate() {
         return new RestTemplate();
     }

+ 20 - 0
java/src/main/java/com/payment/platform/common/exception/GlobalExceptionHandler.java

@@ -12,6 +12,7 @@ import org.springframework.web.bind.MethodArgumentNotValidException;
 import org.springframework.web.bind.annotation.ExceptionHandler;
 import org.springframework.web.bind.annotation.ResponseStatus;
 import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.servlet.resource.NoResourceFoundException;
 
 import java.util.stream.Collectors;
 
@@ -50,6 +51,25 @@ public class GlobalExceptionHandler {
         return Result.of(ErrorCode.BAD_REQUEST.getCode(), msg);
     }
 
+    /**
+     * 路由不存在。NoResourceFoundException 是 Spring 6.1 起「没有 handler 匹配、
+     * 转而按静态资源找、也没找到」时抛的。它落进下面那个兜底 Exception 就是
+     * **HTTP 500「服务器内部错误」**,而它表达的分明是 404。
+     *
+     * <p>联调实测:{@code POST /payment/openapi/pay/no/such/path}(以及把退款路径多写一段
+     * {@code /refund/create})都返回 HTTP 500。对接方在联调期打错路径是常态,收到
+     * 「服务器内部错误」会先去排查我们的服务,而不是检查自己的 URL。
+     *
+     * <p>这条对全平台生效 —— 依据是「缺失路由按定义就是 404」这条 HTTP 语义本身,
+     * 不依赖任何单模块的约定。
+     */
+    @ExceptionHandler(NoResourceFoundException.class)
+    @ResponseStatus(HttpStatus.NOT_FOUND)
+    public Result<?> handleNoResourceFound(NoResourceFoundException e, HttpServletRequest request) {
+        log.warn("路径不存在 [{}]", request.getRequestURI());
+        return Result.of(ErrorCode.NOT_FOUND.getCode(), "接口不存在");
+    }
+
     @ExceptionHandler(Exception.class)
     @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
     public Result<?> handleException(Exception e, HttpServletRequest request) {

+ 7 - 0
java/src/main/java/com/payment/platform/core/security/SecurityConfig.java

@@ -40,6 +40,11 @@ public class SecurityConfig {
             // 此处放行授权层,避免 filter 漏跑时返回 Spring Security 默认空 403。
             // filter 认证失败时无 request attribute,Controller 会抛 401 JSON 响应。
             "/payment/openapi/account/**",
+            // 乐收赢租户 API 与 /payment/openapi/account/** 同形:由 TenantApiKeyAuthFilter
+            // 独立认证(API Key + 签名),此处放行授权层作为纵深防御 ——
+            // 万一 filter 因路径或装配问题漏跑,请求会走到 Controller 拿明确响应,
+            // 而不是被 AuthorizationFilter 丢一个空 403。
+            "/payment/openapi/pay/**",
             "/system/auth/login",
             "/system/auth/login/mini",
             "/system/auth/login/sms",
@@ -60,6 +65,8 @@ public class SecurityConfig {
             // 支付宝授权回调白名单地址(当面付 app_auth_code + openid auth_code 共用,按参数分流;
             // 无登录态,租户隔离由 TenantInnerInterceptor 条件表放行)
             "/payment/aplipay/auth",
+            // 乐收赢回调入口(免登录;身份由 MD5 验签保证,不通过则不写任何业务状态)
+            "/payment/collection/notify/**",
             "/v3/api-docs/**",
             "/swagger-ui/**",
             "/swagger-ui.html",

+ 45 - 8
java/src/main/java/com/payment/platform/core/security/TenantApiKeyAuthFilter.java

@@ -79,8 +79,8 @@ import java.util.UUID;
  *            Java: 将 tenantId / apiKey 写入 request attribute, 由 Controller 读取
  *
  * ———————————————————————————————————————
- * 作用范围 (与 Python 一致):
- *   仅拦截 /payment/openapi/account/** 开放接口
+ * 作用范围:
+ *   拦截 /payment/openapi/account/** 与 /payment/openapi/pay/**(前缀判定, 见 OPENAPI_PREFIXES)
  *   /payment/openapi/conf 等管理接口走 JWT 认证, 不经过此 filter
  */
 @Slf4j
@@ -88,11 +88,43 @@ import java.util.UUID;
 @RequiredArgsConstructor
 public class TenantApiKeyAuthFilter extends OncePerRequestFilter {
 
-    /** 仅对 OpenAPI 账户接口生效 (对应 Python OpenapiRouter 中使用 TenantApiKeyAuth 的路由) */
+    /**
+     * 本通道对外暴露的 OpenAPI 端点**清单**(供文档与测试核对)。
+     * ★ 认证判定**不读本数组** —— 见下面的 OPENAPI_PREFIXES。
+     */
     private static final String[] OPENAPI_ACCOUNT_PATHS = {
             "/payment/openapi/account/transfer",
             "/payment/openapi/account/transfer/query",
             "/payment/openapi/account/balance/query",
+            // 乐收赢支付通道的 7 条端点。逐条列出便于核对;
+            // 认证判定按前缀(见 OPENAPI_PREFIXES),漏列**不会**导致失去认证。
+            "/payment/openapi/pay/barcode/pay",
+            "/payment/openapi/pay/qrcode/create",
+            "/payment/openapi/pay/h5/create",
+            "/payment/openapi/pay/order/query",
+            "/payment/openapi/pay/order/close",
+            "/payment/openapi/pay/refund",
+            "/payment/openapi/pay/refund/query",
+    };
+
+    /**
+     * 需要 API Key 认证的路径**前缀**(不含尾斜杠)—— 与 SecurityConfig.WHITE_LIST 里的 `/**` 通配一一对应。
+     *
+     * ★ 为什么按前缀而不是按上面那份逐条清单:白名单用 `/**` 整体 permitAll,
+     *   授权侧按通配放行;若这里按精确清单判定,**清单外的新增映射**就变成
+     *   「被放行 + 没有任何 filter 认证」= 完全无鉴权,而且不会有任何测试变红。
+     *   按前缀判定后,新增同前缀映射自动要求认证(fail-closed)。
+     *
+     * ★ 为什么必须连 **bare 前缀本身**(`/payment/openapi/account`)一起覆盖(实测):
+     *   AntPathMatcher.match("/payment/openapi/account/**", "/payment/openapi/account") = true
+     *   —— `**` 可匹配零段,故白名单**放行** bare 前缀。若这里只判 startsWith(prefix + "/"),
+     *   bare 前缀就又是「被放行 + 无人认证」= 完全无鉴权。
+     *   故取值不带尾斜杠,判定写作 `equals(prefix) || startsWith(prefix + "/")`,
+     *   这样既覆盖 bare 前缀,又不会误伤 `/payment/openapi/accounting`。
+     */
+    private static final String[] OPENAPI_PREFIXES = {
+            "/payment/openapi/account",
+            "/payment/openapi/pay",
     };
 
     private static final String AUTH_PREFIX = "ApiKey ";
@@ -105,13 +137,18 @@ public class TenantApiKeyAuthFilter extends OncePerRequestFilter {
 
     @Override
     protected boolean shouldNotFilter(HttpServletRequest request) {
-        // 仅拦截 OpenAPI 账户端点 (Python: OpenapiRouter 中使用了 TenantApiKeyAuth 的路由)
+        // 按**前缀**判定哪些路径需要 API Key 认证。
+        // ★ 不要改回"逐条精确匹配 OPENAPI_ACCOUNT_PATHS":白名单用 `/**` 放行,
+        //   逐条匹配会让清单外的新增路径「被 permitAll 放行 + 无人认证」= 完全无鉴权。
+        // ★ bare 前缀本身也必须认证:白名单 `/payment/openapi/account/**` 经 AntPathMatcher 实测
+        //   匹配 `/payment/openapi/account`(`**` 可匹配零段),所以 `equals` 与 `startsWith(p + "/")`
+        //   两者都要判;只写 startsWith(p + "/") 会漏掉 bare 前缀。
         // 注意: 必须用 getServletPath()(不含 context-path)而非 getRequestURI()。
-        // 后端 context-path=/api/v1,getRequestURI() 返回 /api/v1/payment/openapi/...
-        // 与 OPENAPI_ACCOUNT_PATHS 精确匹配永远不命中,导致 API Key 认证被整体跳过。
+        //   后端 context-path=/api/v1,getRequestURI() 返回 /api/v1/payment/openapi/...
+        //   与任何前缀都不匹配,会导致 API Key 认证被整体跳过。
         String path = request.getServletPath();
-        for (String p : OPENAPI_ACCOUNT_PATHS) {
-            if (path.equals(p)) {
+        for (String prefix : OPENAPI_PREFIXES) {
+            if (path.equals(prefix) || path.startsWith(prefix + "/")) {
                 return false; // 需要过滤
             }
         }

+ 87 - 2
java/src/main/java/com/payment/platform/core/tenant/TenantInnerInterceptor.java

@@ -23,7 +23,8 @@ public class TenantInnerInterceptor extends TenantLineInnerInterceptor {
 
     /**
      * 始终跳过的表 — 无 tenant_id 列,或系统级共享表。
-     * 支付业务表已移出(由 getTenantId() 返回 null 处理无认证场景)。
+     * 有 tenant_id 列、但会被无认证场景(回调 / 定时任务)访问的表**不在这里**,
+     * 在下面的 CONDITIONAL_TABLES —— 漏登记会被追加 `AND tenant_id = 0`,无认证查询恒为空。
      */
     private static final Set<String> IGNORE_TABLES = Set.of(
             "sys_tenant",         // 租户表自身
@@ -69,10 +70,94 @@ public class TenantInnerInterceptor extends TenantLineInnerInterceptor {
                                      //   API Key 全局唯一,须跨租户查询;管理端登录后仍按租户隔离)
             "open_transfer",         // 开放转账映射(支付宝通知回调 notifyTransferResult 无认证上下文)
             "open_conf",             // 开放配置(回调通知 resolveReturnUrl 无认证上下文)
-            "pay_batch_authorize"    // 制单授权(授权签约通知 + OAuth openid 回调均无认证上下文;
+            "pay_batch_authorize",   // 制单授权(授权签约通知 + OAuth openid 回调均无认证上下文;
                                      //   无认证时按 out_biz_no(雪花ID全局唯一)反查,无跨租户风险;
                                      //   此前不在条件表导致通知回写查询恒带 tenant_id=0 而失效,
                                      //   实际靠前端 queryAuthorize 兜底 — 加入后通知回写真正生效)
+            // ★ 乐收赢支付通道四表(2026-09-21 新增)。本模块的**非 Controller 路径**
+            //   (上游支付/退款回调、下游回调重试 retryFailed()、被扫轮询
+            //   LsyOrderPollScheduler)**设计上都在无认证上下文里跑**;漏登记 ⇒ ignoreTable
+            //   落到末尾 return false ⇒ 追加 AND tenant_id = 0 ⇒ 回调按 pay_trace_no / refund_no
+            //   定位**恒为 null**。
+            //   同类失效的实证见上面 pay_batch_authorize 那条注释。
+            //   无认证时跳过过滤 ⇒ 查询**不带租户条件**,故跨租户安全完全依赖业务键的唯一性。
+            //   ★★ 通则(**本清单里所有表都适用**):**未认证**的查询**只允许**用**有全局唯一
+            //      约束**的列定位。其余业务键都只有普通索引,用之前**必须先补唯一索引** ——
+            //      唯一性是无认证查询不串租户的**前提**,不是优化。(论证同上面
+            //      pay_batch_authorize 那条。)
+            //      【下面清单由 V1.8 **机械提取**(grep '^CREATE.*INDEX'),不是凭记忆枚举 ——
+            //        人工枚举已漏过列(漏过 refund_trace_no),这正是必须立通则而非继续枚举的理由。
+            //        新增索引后请同步重跑该 grep,别手改。】
+            //      ✓ 全局唯一 ⇒ 可安全用于**未认证**定位(V1.8 索引名):
+            //          pay_lsy_order.pay_trace_no     uk_lsy_order_pay_trace_no
+            //          pay_lsy_refund.refund_trace_no uk_lsy_refund_trace_no
+            //          pay_lsy_refund.refund_no       uk_lsy_refund_refund_no
+            //        (2026-09-21:原先列在这里的 out_trade_no / out_refund_no 已**移入**下面的 ⚠ ——
+            //          它们从不属于本清单的问题域,且其索引收窄成 (tenant_id, X) 后不再全局唯一。)
+            //      ✗ 非唯一 ⇒ 未认证查询**不得**用来定位:
+            //          pay_lsy_order.trade_no        idx_lsy_order_trade_no
+            //          pay_lsy_merchant.mch_no       idx_lsy_merchant_mch_no
+            //          pay_lsy_notify_log.trade_no   idx_lsy_notify_trade_no
+            //                                        (trade_no, trade_type)
+            //          pay_lsy_refund.order_id       idx_lsy_refund_order
+            //        (本节**只列候选定位键** —— 「由未认证输入提供、会被用来**点查**某一行」的业务键,
+            //          **不是 V1.8 里全部的非唯一索引**。V1.8 机械提取(在仓库根目录下
+            //          `grep -c '^CREATE.*INDEX' java/src/main/resources/db/migration/V1.8__create_pay_lsy_tables.sql`
+            //          ⇒ 15 条 = 7 条唯一 + 8 条非唯一;上面 ✗ 列了 4 条)**未列的另有四条**,
+            //          都不在本清单的问题域:
+            //            idx_lsy_order_poll       (status, pay_scene, next_query_time) 轮询任务的范围扫描
+            //            idx_lsy_order_tenant     (tenant_id, created_time DESC)      管理端租户维度列表
+            //            idx_lsy_notify_pay_trace_no (tenant_id, pay_trace_no, created_time DESC)
+            //                                     管理端按支付流水号取审计行(已认证;覆盖过滤+排序)
+            //            idx_lsy_notify_refund_no (pay_lsy_notify_log.refund_no)      审计表按退款号的事后关联
+            //          四条都服务于「扫一批 / 聚一组」,不是「按外来键定位一行」。第三张表那句
+            //          「**没有读**」**只截至 2026-09-21**(当时全仓只有 insert 调用点)——
+            //          **Task 17 的管理端 `/{outTradeNo}/notify-logs` 会按 pay_trace_no 读它**
+            //          (即下面那条实例里说的「已认证的管理端查询」;那条走 JWT ⇒ 受租户过滤,
+            //          与「未认证读不得用 selectOne」那条义务不相干 —— 后者只管**未认证**读,
+            //          无论这张表有没有读都永远成立)⇒ 别把「今天没有读」读成「永远没有读」。
+            //          ⇒ 别把三节合起来当成 grep '^CREATE.*INDEX' 的全量输出。)
+            //      ⚠ **看似唯一、实则不能作跨租户保障**的陷阱(前两处是人工枚举两次都漏掉的):
+            //          uk_lsy_merchant_tenant_ent (tenant_id, enterprise_id)
+            //            —— 复合唯一且**含 tenant_id**:未认证查询不带租户条件 ⇒ 该索引提供**零**
+            //               保护 ⇒ enterprise_id **不是**安全定位键;
+            //          uk_lsy_refund_one_pending (order_id)
+            //              WHERE status IN ('CREATED','REFUNDING')
+            //            —— **部分**唯一:只约束在途行,order_id 在**全表并不唯一**
+            //               (同一单可先后有多笔退款)⇒ order_id 也不是安全定位键。
+            //          uk_lsy_order_out_trade_no (tenant_id, out_trade_no)       ← 2026-09-21 收窄
+            //          uk_lsy_refund_out_refund_no (tenant_id, out_refund_no)    ← 2026-09-21 收窄
+            //            —— 同 uk_lsy_merchant_tenant_ent(复合唯一且**含 tenant_id**):未认证
+            //               查询不带租户条件 ⇒ 该索引提供**零**保护 ⇒ out_trade_no / out_refund_no
+            //               **不是**安全定位键,两个不同租户可以合法持有同一个单号。
+            //               **为什么收窄**:这两列是**租户自己提供**的业务单号,原先的全局唯一
+            //               会让租户 B 用租户 A 用过的单号时撞索引 ⇒ DuplicateKeyException ⇒
+            //               永久 500(论证见 V1.8 这两条索引上方新增的注释)。
+            //               **⇒ 由此产生的义务**:任何**未认证**路径都**不得**按 out_trade_no /
+            //               out_refund_no 定位。当前**没有**这样的路径(LeshouyingOpenController
+            //               的 findExisting / requireOrder 与 LsyRefundService.requireByOutRefundNo
+            //               都显式带 tenant_id,且都在已认证路径上)——
+            //               故本条是**约定 + 记录**,不是既有缺陷,不改变当前行为。
+            //               反过来说:将来若有人要给这两列写未认证查询,**能用的唯一性已经不在了**;
+            //               但**不可**走「把这两列的全局唯一补回来」这条路 —— 那等于**恢复本任务
+            //               刚修掉的「租户撞号永久 500」**(这两列由**租户自己提供**,全局唯一正是
+            //               缺陷本身,不是防线)。只能改用 pay_trace_no / refund_no 这类**上游给的**
+            //               全局唯一键,或另设一个**我们自己生成**的定位键。
+            //   —— 以下两条是上面通则的**实例**,保留在此以便就近阅读:
+            //     pay_lsy_notify_log —— 审计表,**无认证上下文里只写、不读**(已认证的管理端
+            //                           查询会按 pay_trace_no 读它,但那条走 JWT ⇒ 受租户过滤)。
+            //       **义务**:任何**未认证**的读都**不得**用 selectOne —— 无认证查询不带租户条件,
+            //       LIMIT 1 会返回**任意租户**的审计行(raw_body 含上游报文原文);
+            //       要按业务键做未认证读,必须**先补唯一约束**(trade_no / pay_trace_no 均无)。
+            //   ★ pay_lsy_merchant 的 mch_no **没有唯一索引**(见 LsyMerchantService.requireByMchNo
+            //     的 javadoc)。当前**没有任何**未认证路径查询它(两条回调实测按 pay_trace_no /
+            //     refund_no 定位),所以本次加入是**惰性**的,不改变现有行为。
+            //     **义务**:将来一旦有人写按 mch_no 的**未认证**查询,必须先把 uk_lsy_merchant_mch_no
+            //     补上 —— 唯一性是无认证查询不串租户的前提(通则的实例)。
+            "pay_lsy_order",
+            "pay_lsy_refund",
+            "pay_lsy_merchant",
+            "pay_lsy_notify_log"
     );
 
     public TenantInnerInterceptor() {

+ 131 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyQueryDetailAssembler.java

@@ -0,0 +1,131 @@
+package com.payment.platform.module.payment.leshouying.service;
+
+import com.payment.platform.module.payment.leshouying.client.LeshouyingResponseParser;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * 把上游查单/查退款响应**按白名单**投影成下游可见的 `data.detail`。
+ *
+ * <p>【为什么需要它】原实现是把上游响应报文整个透传给租户(`data.put("detail", upstream)`),
+ * 于是租户能拿到 `mch_no`、`qd_trade_no`、`td_trade_no`、`user_id`、`pay_trace_no`、
+ * `inst_no`、`refund_settle` 这些**上游的标识与字段名**。把 key 从 `upstream` 改名叫 `detail`
+ * 并不解决这件事 —— 换的只是标签。
+ *
+ * <p>【白名单怎么定的】只放**我们自己的话**能解释的字段,语义以 V1.8 建表语句的列注释为准:
+ * <ul>
+ *   <li>金额三兄弟 `total_amount` / `receipt_amount` / `discount_amount` —— 列注释「(分,上游回填)」;</li>
+ *   <li>`pay_time` —— 列注释「上游支付时间(yyyyMMddHHmmss)」,格式与我们发出去的入参一致;</li>
+ *   <li>`code_url` / `code_url_expire_time` —— 聚合码内容与过期时间;</li>
+ *   <li>`refund_no` —— 列注释「上游退款单号(退款回调的定位键)」,是**对外叫「退款单号」的那个值**。
+ *       ⚠ 该列注释里的括注**是假的**(实测:退款回调报文不带任何退款标识,故它不是定位键)。
+ *       但**白名单结论不变** —— 它仍是上游同步响应里那笔退款自己的单号,
+ *       也就是我们要对外叫「退款单号」的值;真假只在「谁按它查」这一点上。
+ *       ✅ 2026-09-22:库里的 `COMMENT ON` 与建表脚本 `V1.8`(含上面引用的那句)**均已改掉**,
+ *       改后文案「上游退款单号(同步响应回传,可空)」。本行括注保留原样只为给这次订正留痕,
+ *       `V1.8` 里已无此说法。(这是全仓第 9 处 —— 台账 §一记的是 8 处,它只统计**假话**,
+ *       这类**引用假话**的交叉引用没进统计。)</li>
+ * </ul>
+ * 明确**不**放的(列注释写着「本侧不读」或属上游身份):
+ * `qd_trade_no`、`td_trade_no`、`qd_refund_no`、`refund_settle`、`user_id`、
+ * `mch_no`、`shop_no`、`inst_no`、`pay_trace_no`、`refund_trace_no`,以及
+ * `return_code` / `result_code` / `return_msg` 三个上游协议字段。
+ *
+ * <p>【金额为什么转 Long】上游回来的是字符串(`"100"`)。其它接口的金额都是 JSON 数字,
+ * 这里要是原样透字符串,租户得为同一个概念写两套解析。转不出来就返回 null ——
+ * 不把没法解释的值编一个数字送出去。金额的权威口径始终是订单/退款单本身,`detail` 是核对用的旁证。
+ *
+ * <p>【结果码为什么归一】`result_code` 是上游协议字段,不该出现在对外报文里;
+ * 但它的**信息**有用,所以映射成我们的说法 `SUCCESS` / `PROCESSING` / `FAILED`,
+ * 映射不上一律 `UNKNOWN`。**不猜**:上游文档正文把成功写成 `PAY_SUCEESS`(少一个 S),
+ * 我们无法判定那是笔误还是真实返回值,猜成成功会让一个可疑码进入正常路径。
+ * 判定复用 {@link LeshouyingResponseParser} 的三个谓词,与关单/对账路径同一个口径。
+ */
+@Component
+@RequiredArgsConstructor
+public class LsyQueryDetailAssembler {
+
+    /** 我们的说法,不是上游的 result_code。 */
+    static final String SUCCESS = "SUCCESS";
+    static final String PROCESSING = "PROCESSING";
+    static final String FAILED = "FAILED";
+    /** 三个谓词全不命中、或根本没有 result_code 时的取值。含义是「不知道」,不是「失败」。 */
+    static final String UNKNOWN = "UNKNOWN";
+
+    private final LeshouyingResponseParser parser;
+
+    /** 订单查询的 `data.detail`。**键恒为这几个**,没有的值为 null。 */
+    public Map<String, Object> forOrder(Map<String, Object> upstream) {
+        Map<String, Object> detail = new LinkedHashMap<>();
+        detail.put("pay_result", resultOf(upstream));
+        detail.put("total_amount", toLong(get(upstream, "total_amount")));
+        detail.put("receipt_amount", toLong(get(upstream, "receipt_amount")));
+        detail.put("discount_amount", toLong(get(upstream, "discount_amount")));
+        detail.put("pay_time", toStr(get(upstream, "pay_time")));
+        detail.put("code_url", toStr(get(upstream, "code_url")));
+        detail.put("code_url_expire_time", toStr(get(upstream, "code_url_expire_time")));
+        return detail;
+    }
+
+    /**
+     * 退款查询的 `data.detail`。
+     *
+     * <p>`refund_no` 放在这里而不是响应顶层,是因为它是**上游给的**值;
+     * 我们自己的退款单号 `out_refund_no` 在发起退款时就已经给过租户,
+     * 且可由 `LsyRefundEntity` 直接读,不需要从上游报文里取。
+     */
+    public Map<String, Object> forRefund(Map<String, Object> upstream) {
+        Map<String, Object> detail = new LinkedHashMap<>();
+        detail.put("refund_result", resultOf(upstream));
+        detail.put("refund_no", toStr(get(upstream, "refund_no")));
+        detail.put("refund_fee", toLong(get(upstream, "refund_fee")));
+        return detail;
+    }
+
+    /**
+     * 归一到我们的说法。
+     *
+     * <p>顺序不能换:三个谓词互斥(各自比对一个字面量),但先判成功更贴合「已知码」的语义。
+     * null 报文走第一条分支返回 UNKNOWN —— 调用方(查单接口)**不得**因为
+     * 上游这一跳失败就把整个接口抛崩,租户要的是本地状态。
+     */
+    private String resultOf(Map<String, Object> upstream) {
+        if (upstream == null) {
+            return UNKNOWN;
+        }
+        if (parser.isPaySuccess(upstream)) {
+            return SUCCESS;
+        }
+        if (parser.isPayInProcess(upstream)) {
+            return PROCESSING;
+        }
+        if (parser.isPayFail(upstream)) {
+            return FAILED;
+        }
+        return UNKNOWN;
+    }
+
+    /** 白名单取值的唯一入口:所有键都从这里取,杜绝顺手多透一个字段。 */
+    private static Object get(Map<String, Object> upstream, String key) {
+        return upstream == null ? null : upstream.get(key);
+    }
+
+    /** 上游金额是字符串。转不出来返回 null —— 不编数字。 */
+    private static Long toLong(Object value) {
+        if (value == null) {
+            return null;
+        }
+        try {
+            return Long.parseLong(String.valueOf(value).trim());
+        } catch (NumberFormatException e) {
+            return null;
+        }
+    }
+
+    private static String toStr(Object value) {
+        return value == null ? null : String.valueOf(value);
+    }
+}

+ 504 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyRefundService.java

@@ -0,0 +1,504 @@
+package com.payment.platform.module.payment.leshouying.service;
+
+import cn.hutool.core.util.IdUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+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.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.LsyOrderMapper;
+import com.payment.platform.module.payment.leshouying.mapper.LsyRefundMapper;
+import com.payment.platform.module.payment.leshouying.util.LsyLogSanitizer;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDateTime;
+import java.time.OffsetDateTime;
+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;
+    /**
+     * 两个用途,都是**顺 order_id 回订单表**,没有第三个:
+     *   1. {@link #queryRefund} 补 `mch_no` —— `pay_lsy_refund` 没有这一列(V1.8 逐列核对过),
+     *      而上游 `/v3/queryrefund` 把它标为必填;
+     *   2. {@link #settleFromQuery} 退款成功后收敛订单侧。
+     * 无循环依赖:LsyOrderMapper 是 mapper,不认识任何 Service。
+     */
+    private final LsyOrderMapper orderMapper;
+    /**
+     * 只为退款成功后的订单侧收敛({@link #settleOrderAfterRefund})而依赖。
+     * 无循环依赖:LsyOrderService 不认识 LsyRefundService。
+     */
+    private final LsyOrderService orderService;
+    /**
+     * 退款到达终态后推下游退款回调(见 {@link #applyNotifyResult})。
+     * 无循环依赖:LsyCallbackService 只依赖 mapper / RestTemplate / 配置,不认识任何 Service。
+     */
+    private final LsyCallbackService callbackService;
+
+    /**
+     * 申请退款。支持部分退款。
+     * @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 虚高 → 一笔在途 100 元的单还能再退 100 元。
+        //   下面的金额校验挡不住这一点,因为从它的输入看余额确实够。
+        //   用 selectList 而不是 selectCount:后者返 Long,Mockito 默认 null,
+        //   测试里 unboxing 直接 NPE;selectList 默认空 List,没有这个坑。
+        //
+        // 一次查询取回本单全部退款行,同时服务于「在途」与「已退累计」两个判定。
+        // ★ 为什么「已退」必须来自退款表而不是 order.getRefundedAmount():
+        //   入参 order 是调用方读到的快照,可能已经过期。快照过期 + 在途闸门放行
+        //   (前一笔已 SUCCESS、不再在途)⇒ 余额校验通过 ⇒ 同一张单退两次。
+        //   退款表的 SUCCESS 行才是权威:applyNotifyResult 把它推进 SUCCESS 是一次独立 UPDATE,
+        //   早于订单侧收敛,所以以它为准时那个窗口不存在。
+        //   方向性:不取单一边,而是取台账与快照的 max(见下面 :101 起的注释)——
+        //   单信台账会被「订单侧已累加、行却是 FAIL」绕过,单信快照会被陈旧快照绕过。
+        List<LsyRefundEntity> rows = refundMapper.selectList(new LambdaQueryWrapper<LsyRefundEntity>()
+                .eq(LsyRefundEntity::getOrderId, order.getId()));
+        // ★ 在途判定在 Java 侧完成(按 `LsyRefundStatus.PENDING` 判成员资格),
+        //   **不要**把行过滤挪回 Wrapper 的 `.in(status, ...)`:
+        //   1. 上面已经把本单全部退款行取回来了(要算「已退累计」就不能滤掉终态行),
+        //      任何行级过滤都只能落在 Java 侧;
+        //   2. 单测里 refundMapper 是 mock、Wrapper 根本不会被执行,只有 Java 侧判定可被观测 ——
+        //      `refund_pendingGuard_passesWhenPreviousRefundIsTerminal` 钉的就是它
+        //      (终态行不得被当成在途而误抛)。
+        //   它必须与 V1.8 的 `uk_lsy_refund_one_pending` 索引谓词(`status IN ('CREATED','REFUNDING')`)
+        //   用**同一个** `LsyRefundStatus.PENDING` 常量 —— 改一处必须改两处,
+        //   `LsyEntityMappingTest.refundPendingSetMatchesPartialUniqueIndexPredicate` 钉住这份一致性。
+        //   (status 列是 NOT NULL,故不存在 contains(null) 的路径。)
+        boolean inFlight = rows.stream()
+                .anyMatch(r -> LsyRefundStatus.PENDING.contains(r.getStatus()));
+        if (inFlight) {
+            throw new IllegalStateException(
+                    "该订单有退款正在处理中,请等结果返回后再发起: orderId=" + order.getId());
+        }
+
+        // 已退累计取退款表的 SUCCESS 行(权威账),不取 order 快照的 refundedAmount。
+        // 用字面量 "SUCCESS":LsyRefundStatus 里的是**枚举常量**、不是 String 常量,比不了字符串。
+        long settled = rows.stream()
+                .filter(r -> "SUCCESS".equals(r.getStatus()))
+                .mapToLong(r -> r.getRefundFee() == null ? 0L : r.getRefundFee())
+                .sum();
+        // ★ 取「台账」与「订单快照」的**较大者**,不是二选一。两边各有各的漏算方向:
+        //   1. 快照可能**陈旧偏低**(调用方读到的是旧值)⇒ 只信快照会放行重复退款(R12 的原始缺陷);
+        //   2. 台账可能**少算**(订单侧已累加、退款行却是 FAIL)⇒ 只信台账同样会放行。
+        //      该结构见本类同步成功分支:`if (!applyNotifyResult(...))` 与
+        //      `if (!settleOrderAfterRefund(...))` 是**并列**两条,后者不在前者的 else 里
+        //      —— 按内容定位,别按行号(交付时在 :188-193)。
+        //   max 对两者的下界都成立:remaining 只会比「只信任一边」更小或相等,**方向恒为更严**。
+        //   实测:测试夹具 `order()` 本身即把 refundedAmount 置 0,个别用例再显式重申一次;
+        //   故 max(0, 台账) == 台账 —— 本改动对既有用例行为中性,已由「18 条既有用例全绿」实证。
+        //   新增那条才是唯一的行为差异点。
+        long snapshotSettled = order.getRefundedAmount() == null ? 0L : order.getRefundedAmount();
+        // total 仍取快照:总额不随时间变,没有陈旧问题。
+        long remaining = order.getTotalAmount() - Math.max(settled, snapshotSettled);
+        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 存了原始码可追溯。
+        //   没有「钱已退、单记失败」这种最坏方向 —— 这正是下面未知码分支**只记日志、不落终态**
+        //   的理由(支付侧要拦住是因为它有落 FAIL 的最坏方向,退款侧没有,所以停住就够)。
+        //   ★ 但「没有最坏方向」不等于「可以不记日志」:停在 REFUNDING 的行同时占着
+        //   uk_lsy_refund_one_pending 与在途闸门 ⇒ 这张单在它被收敛之前发不起退款。
+        //   ★ 「没有对账会来捞它」这句在 2026-09-22 之后**不成立**:
+        //   LsyReconcileScheduler.reconcilePendingRefunds 会捞非终态行问上游
+        //   (前提:已过 props.reconcileDelaySeconds)。所以「停住」现在是有界等待而非永久卡死 ——
+        //   但那只是兜底、且要等一轮;**它不构成把未知码当场归类的许可**。
+        //   所以未知码必须走下面的 log.error 分支被人看见(R21 补上,此前是静默的):
+        //   兜底要等,人能立刻来看。
+        //
+        // ★ 一律先落非终态,同步成功也不能直接 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 并**在该行被收敛前挡住这张单的后续退款**
+            // (在途校验 + 部分唯一索引都认它)。
+            // ★ 这条日志的措辞在 2026-09-22 改过:旧文案说「再无兜底」,而现在
+            // LsyReconcileScheduler.reconcilePendingRefunds 会捞非终态行问上游 ⇒ 有兜底,
+            // 只是要等一轮(前提:已过 props.reconcileDelaySeconds)。
+            // **仍然必须 log.error、不能降级成 info**:兜底是「等 + 依赖上游查得到」,
+            // 而这里是一次本地写库失败 —— 前者不保证能修好后者。
+            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);
+            }
+        } else if (parser.isPayFail(resp)) {
+            // ★ PAY_FAIL 是**已知码**(计划:`/v3/*` 的 result_code ∈ {PAY_SUCCESS, PAY_IN_PROCESS, PAY_FAIL}),
+            //   所以这里绝不能静默 —— 但**不能**把这个要求的出处记成 parser javadoc:
+            //   `LeshouyingResponseParser.isPayFail` 里「三个谓词全为 false 时,调用方必须走未知码分支:
+            //   记日志 + 待人工核对」那句,针对的是**三个都不命中**的情形,对应的是下面的未知码分支;
+            //   PAY_FAIL 恰恰是其中一个谓词命中,不在那条要求的适用范围里。
+            //   本分支不得静默的理由就是紧接下面的那条(永久占闸门 + 运营看不到)。
+            //   不记日志的后果:本行永久停在 REFUNDING,既占着 `uk_lsy_refund_one_pending`
+            //   又占着上面的在途闸门 ⇒ **这张单再也发不起退款**,而运营一个字节都看不到。
+            //
+            // ★ 只记日志,**不写任何状态**(不调 applyNotifyResult、不调 settleOrderAfterRefund)。
+            //   特别是**不要**自作主张把它落成 FAIL —— 落 FAIL 会**释放互斥窗口**:
+            //   若上游这个 PAY_FAIL 是偶发误报(钱其实出去了),第二笔退款就真的放行了,
+            //   方向正好是资金最坏的那一侧。停住 + 让人看见 > 替上游猜一个方向放手。
+            //   收敛手段是人工/对账,不是这里下结论。
+            //
+            // 【两个上游实参过 LsyLogSanitizer.flatten】它们来自 HTTP 响应体、不经过任何 DTO 校验
+            //   (入口控制覆盖不到),必须在汇点压平(CWE-117)。
+            //   outRefundNo / refundTraceNo 不套:都是我们自己的值(outRefundNo 由入口 @Pattern 保证,
+            //   refundTraceNo 是本方法上面的 IdUtil.fastSimpleUUID())。
+            log.error("退款返回 PAY_FAIL,状态不落终态、留在 REFUNDING(占用在途闸门, 该单暂时无法再退款),"
+                            + "需人工核对上游退款结果: outRefundNo={}, refundTraceNo={}, "
+                            + "result_code={}, return_msg={}",
+                    outRefundNo, refundTraceNo,
+                    LsyLogSanitizer.flatten(resp.get("result_code")),
+                    LsyLogSanitizer.flatten(resp.get("return_msg")));
+        } else if (!parser.isPayInProcess(resp)) {
+            // ★ R11 那条契约的另一半:`LeshouyingResponseParser.isPayFail` 的 javadoc 要求
+            //   「三个谓词全为 false 时,调用方必须走未知码分支:记日志 + 待人工核对,不得静默归类」。
+            //   上面只堵了 PAY_FAIL,三个谓词全 false 的路径此前一个字节都不打。
+            //
+            // ★ 为什么用 `!isPayInProcess` 而不是裸 `else`(同 LsyOrderService:420 的形状):
+            //   PAY_IN_PROCESS 是退款的**正常**路径(异步结果未到),不是异常。
+            //   让它落进这条 error 会让正常业务刷屏,把真正的未知码淹掉。
+            //   走到这里时前两个谓词已判过 false,故本条恰好覆盖「非成功、非失败、非在途」。
+            //
+            // ★ 只记日志,不写任何状态 —— 与上面 PAY_FAIL 分支同理,且这里更明显:
+            //   我们连上游想表达什么都没识别出来,落任何终态都是替上游猜方向。
+            //   落 FAIL 会释放 `uk_lsy_refund_one_pending` 与在途闸门;落 SUCCESS 更不用说。
+            //   停住 + 让人看见,收敛手段是人工/对账。
+            //   不记日志的后果与 PAY_FAIL 完全相同:本行永久停在 REFUNDING,
+            //   既占着部分唯一索引又占着在途闸门 ⇒ 这张单再也发不起退款,而运营什么都看不到。
+            //   (同模块先例:LsyOrderService:260 / :340 / :436,形状一致。)
+            //
+            // 【三个上游实参全部过 LsyLogSanitizer.flatten】它们来自 HTTP 响应体、不经过任何
+            //   DTO 校验(入口控制覆盖不到),必须在汇点压平(CWE-117)。
+            //   同一行日志只堵一个字段等于没堵 —— 换个字段就能绕过。
+            //   outRefundNo / refundTraceNo 不套:都是我们自己的值(outRefundNo 由入口 @Pattern 保证,
+            //   refundTraceNo 是本方法上面的 IdUtil.fastSimpleUUID())。
+            log.error("退款返回未知 result_code,不落状态、留在 REFUNDING(占用在途闸门, 该单暂时无法再退款),"
+                            + "需人工核对上游退款结果: outRefundNo={}, refundTraceNo={}, "
+                            + "return_code={}, result_code={}, return_msg={}",
+                    outRefundNo, refundTraceNo,
+                    LsyLogSanitizer.flatten(resp.get("return_code")),
+                    LsyLogSanitizer.flatten(resp.get("result_code")),
+                    LsyLogSanitizer.flatten(resp.get("return_msg")));
+        }
+        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<>();
+        // ★ mch_no 是**必填**(上游文档「退款查询」入参表),而 pay_lsy_refund 没有这一列
+        //   ⇒ 只能顺着 order_id 回订单表取。判据不是「文档这么写」,是**结构孪生方法**:
+        //   LsyQueryService.queryOrder 形状完全平行(同样的 inst_no/mch_no/query_trace_no/
+        //   二选一定位键)且送了 mch_no、联调实测查单走通;createRefund 也送。缺它的只有本方法。
+        //   取不到就**不送这个键**(而不是送 null):LeshouyingClient.post 会过滤副本里的 null 值,
+        //   所以两者在上游看来一样,但「送了个 null 键」会让钉 keySet 的用例失去覆盖
+        //   ⇒ 反查失败这条路永远测不到。宁可退化为改动前的行为,也不静默留个空键。
+        LsyOrderEntity order = refund.getOrderId() == null ? null
+                : orderMapper.selectById(refund.getOrderId());
+        if (order != null && order.getMchNo() != null) {
+            p.put("mch_no", order.getMchNo());
+        }
+        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);
+    }
+
+    /**
+     * 退款查询结果收敛 —— 退款对账腿({@code LsyReconcileScheduler.reconcilePendingRefunds})的解释器。
+     *
+     * <p>【为什么解释放在这里而不是调度器】调度器 javadoc 明令「本调度器自己不解析任何上游报文」。
+     * 形状与 {@link LsyOrderService#settleFromQuery} 同构,唯一区别是退款要写**两行**
+     * (退款行 + 订单行),所以订单侧收敛也留在这里 —— 两行必须成对,拆到调度器就会出现
+     * 「写了一半」的中间态,而 {@link #settleOrderAfterRefund} 的 javadoc 正是为反对这种
+     * 拆法写的。
+     *
+     * <p>【上游词表】退款**查询**回 `PAY_SUCCESS` / `PAY_IN_PROCESS` / `PAY_FAIL`,与退款
+     * **回调**那套 `TRADE_REFUND*` 是两套词表 ⇒ 复用支付侧那三个谓词是对的。
+     *
+     * <p>【为什么这里敢落 FAIL,而 {@link #createRefund} 的同步 PAY_FAIL 分支不敢】
+     * 同步分支手上没有权威答案(它自己就是第一次问),停住等人工;对账腿拿到的正是上游对
+     * **这一笔退款**的直接回答,而上游文档明说「`PAY_IN_PROCESS` 需调用退款查询接口确认退款结果」
+     * —— 查询就是那个被指定的权威确认通道。另外:这条腿若只收敛成功、收敛不了失败,
+     * 就等于没解决「停在 REFUNDING 的行没有出口」这件事。
+     *
+     * @return {@code "SUCCESS"} / {@code "FAIL"}(本次真的流转了);{@code null} = 没写库
+     *         (在途、已被回调抢先写成终态、或未知码)
+     */
+    public String settleFromQuery(LsyRefundEntity refund, Map<String, Object> resp) {
+        if (parser.isPaySuccess(resp)) {
+            if (!applyNotifyResult(refund, str(resp.get("result_code")),
+                    str(resp.get("return_msg")), "SUCCESS")) {
+                // ★ 与 createRefund 同步成功分支同一条理由:命中 0 行只可能是
+                //   ① 回调/另一轮对账抢先写了终态 —— 那它已经收敛过订单侧,这里不能再收敛一次;
+                //   ② 本地写库失败 —— 该行卡在 REFUNDING,占着索引与在途闸门。
+                //   两种情形都不能往下走,applyNotifyResult 里已经 log 过。
+                return null;
+            }
+            LsyOrderEntity order = refund.getOrderId() == null ? null
+                    : orderMapper.selectById(refund.getOrderId());
+            if (order != null) {
+                long fee = refund.getRefundFee() == null ? 0L : refund.getRefundFee();
+                if (!settleOrderAfterRefund(order, fee)) {
+                    // 与 createRefund 同步成功分支同形、同理:上游的钱确实已经退了,
+                    // 这里只记账,不抛异常(抛出去等于对账腿谎报失败)。
+                    log.error("对账确认退款成功但订单侧收敛未命中(状态不符或并发), 需人工核对: "
+                                    + "orderId={}, outRefundNo={}, refundFee={}",
+                            refund.getOrderId(), refund.getOutRefundNo(), fee);
+                } else {
+                    callbackService.notifyOrder(order);
+                }
+            }
+            return "SUCCESS";
+        }
+        if (parser.isPayFail(resp)) {
+            return applyNotifyResult(refund, str(resp.get("result_code")),
+                    str(resp.get("return_msg")), "FAIL") ? "FAIL" : null;
+        }
+        if (!parser.isPayInProcess(resp)) {
+            // ★ 与 LsyOrderService.settleFromQuery 的未知码分支同纪律:**什么都不写**。
+            //   落 FAIL 会释放 uk_lsy_refund_one_pending 与在途闸门 ⇒ 可能重复出金;
+            //   落 SUCCESS 更不用说。停在 PENDING 是有界的:下一轮对账拿同一个问题再问一次。
+            //   把原始码打出来 —— 未知码第一次出现时这行日志是唯一的追溯线索(CWE-117:全部压平)。
+            log.error("退款查询返回未知 result_code,不落状态、留在 PENDING 待下一轮对账: "
+                            + "outRefundNo={}, return_code={}, result_code={}, return_msg={}",
+                    refund.getOutRefundNo(),
+                    LsyLogSanitizer.flatten(resp.get("return_code")),
+                    LsyLogSanitizer.flatten(resp.get("result_code")),
+                    LsyLogSanitizer.flatten(resp.get("return_msg")));
+        }
+        return null;
+    }
+
+    /**
+     * 退款结果回写 —— **退款行的唯一状态写入口**,条件更新,受影响行数 = 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);
+        // ★ finish_time 只在**终态**流转时写。列注释是「完成时间(终态)」(V1.8:161)。
+        //   本方法自 2026-09-22 起会执行非终态流转(createRefund 的 CREATED → REFUNDING,
+        //   见台账 §2.1):照旧无条件写,会让一笔**还在途**的退款带上完成时间,
+        //   事后按 `finish_time IS NULL` 捞未完结退款就会漏掉它。
+        //   判据用 NOT PENDING(而不是新增一份终态白名单),是为了不给「非终态」再添第二份副本 ——
+        //   `LsyRefundStatus.PENDING` 已经是它的唯一定义,改一处即可,不必再多一个集合跟着漂移。
+        //   实际传进来的终态只有 SUCCESS / FAIL(三个调用点:同步成功、回调、对账兜底),
+        //   两条路径都有测试钉住。
+        if (!LsyRefundStatus.PENDING.contains(newStatus)) {
+            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);
+        pushRefundCallbackIfTerminal(refund);
+        return true;
+    }
+
+    /**
+     * 退款到达终态时推下游退款回调。
+     *
+     * <p>【为什么挂在这里】本方法是退款行**唯一**的状态写入口 —— 同步成功
+     * ({@link #createRefund})与异步回调(`LsyNotifyService.handleRefundNotify`)两条路径
+     * 都走它。挂在两个调用点上会形成两份副本,而本模块已经反复吃过"两份副本迟早漂移"的亏
+     * ({@code LsyRefundStatus.PENDING} 与 V1.8 索引谓词那份就是这样被测试钉住的)。
+     *
+     * <p>【为什么必须有】此前**没有任何地方**调用 {@code callbackService.notifyRefund}:
+     * 退款行 insert 时写 {@code notify_status='PENDING'},而 {@code retryFailed()} 的退款腿
+     * 只捞 {@code 'FAIL'},能把行写成 FAIL 的 {@code recordRefundNotifyResult} 又只被
+     * {@code notifyRefund} 调 ⇒ 退款行永远停在 PENDING,下游一个退款回调都收不到。
+     * 门是自锁的,钥匙插在锁里面。
+     *
+     * <p>【为什么吞异常】推送失败**不能**让本方法抛出去:调用方
+     * {@code createRefund} 把异常冒到 OpenAPI 控制器,租户收到的是「退款失败」,
+     * 而钱其实已经退出去了 —— 那是最坏的一类谎报。推送失败由
+     * {@code notify_status='FAIL'} + {@code retryFailed()} 兜,不由「把成功的退款报成失败」兜。
+     * ({@code notifyRefund} 内部已自行捕获投递异常并记 FAIL,此处这道 try 挡的是
+     * {@code resolveUrl} 的查库异常等更早的失败点。)
+     *
+     * <p>【时序】异步路径下本方法先于 {@code settleOrderAfterRefund} 执行,
+     * 故退款回调可能**早于**订单回调到达。两者各自独立且都可重入,租户不应依赖先后顺序。
+     */
+    private void pushRefundCallbackIfTerminal(LsyRefundEntity refund) {
+        if (LsyRefundStatus.PENDING.contains(refund.getStatus())) {
+            return;
+        }
+        try {
+            callbackService.notifyRefund(refund);
+        } catch (Exception e) {
+            log.error("退款回调推送异常(状态回写已生效,退款结果不受影响),需人工核对: outRefundNo={}",
+                    refund.getOutRefundNo(), e);
+        }
+    }
+
+    /** 下游 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;
+    }
+
+    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;
+        }
+    }
+}

+ 18 - 0
java/src/main/resources/application.yml

@@ -143,3 +143,21 @@ logging:
   level:
     com.payment.platform: info
     org.springframework.security: info
+
+# 乐收赢支付通道
+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/collection/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

+ 683 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyRefundServiceTest.java

@@ -0,0 +1,683 @@
+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.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.List;
+import java.util.Map;
+import java.util.Set;
+
+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 LsyOrderMapper orderMapper;
+    @Mock private LsyOrderService orderService;
+    @Mock private LsyCallbackService callbackService;
+    private LsyRefundService service;
+
+    @BeforeEach
+    void setUp() {
+        service = new LsyRefundService(client, new LeshouyingResponseParser(), refundMapper,
+                orderMapper, orderService, callbackService);
+    }
+
+    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() {
+        // ★ 夹具随 R12 调整:「已退」的取值源已从 order 快照改为**退款表**。
+        //   旧夹具只把 80 设进 order.setRefundedAmount(...),新实现根本不读那个字段,
+        //   于是 selectList 返回空 → settled=0 → 30 会被放行,用例必红。
+        //   现在把 80 放进退款表的 SUCCESS 行,而**快照刻意留 0**
+        //   —— 这样这条用例顺带证明「余额取自退款表、不取自快照」。
+        LsyOrderEntity o = order();
+        o.setRefundedAmount(0L);            // 快照说一分没退(陈旧/误导)
+        LsyRefundEntity prior = new LsyRefundEntity();
+        prior.setId(9L);
+        prior.setStatus("SUCCESS");
+        prior.setRefundFee(80L);            // 权威账说已退 80 → 剩余 20
+        when(refundMapper.selectList(any())).thenReturn(List.of(prior));
+
+        assertThrows(IllegalArgumentException.class,
+                () -> service.createRefund(o, "RF001", 30L));
+        verify(client, never()).post(anyString(), anyMap());
+    }
+
+    @Test
+    void refund_rejectsWhenLedgerAlreadyRefunded_evenIfOrderSnapshotStale() {
+        // ★ R12 的核心回归:快照陈旧 + 在途闸门放行 ⇒ 同一张单退两次。
+        //   快照说"一分没退"(可退 10000),但退款表里已有一笔退满的 SUCCESS 行。
+        //   那笔已 SUCCESS、不在途,所以在途闸门会放行;旧实现信快照 → 余额校验也放行
+        //   → 一路走到 /v3/refundorder,钱真扣两次。
+        LsyOrderEntity o = order();
+        o.setTotalAmount(10000L);
+        o.setRefundedAmount(0L);            // ★ 陈旧快照:看起来可退 10000
+        o.setStatus("SUCCESS");
+
+        LsyRefundEntity alreadyRefunded = new LsyRefundEntity();
+        alreadyRefunded.setId(9L);
+        alreadyRefunded.setStatus("SUCCESS");
+        alreadyRefunded.setRefundFee(10000L);
+        when(refundMapper.selectList(any())).thenReturn(List.of(alreadyRefunded));
+
+        assertThrows(IllegalArgumentException.class,
+                () -> service.createRefund(o, "RF002", 10000L));
+        // 非空转的关键:旧实现在这个夹具下会**走到上游调用**,新实现必须在上游之前拒掉。
+        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 = 原支付时间
+        //
+        // ★ 形状断言(与 LsyCancelServiceTest 的房规一致):只断言非 null 挡不住
+        //   "哪天被写成 LocalDateTime.toString()" —— 那会得到 2026-09-21T10:00 这种非 null
+        //   但不是 14 位的值,测试照样绿,而失败会推迟到上游回「参数不合法」,根因不可见。
+        Object payTime = sent.get("pay_time");
+        assertNotNull(payTime);
+        assertTrue(payTime instanceof String && ((String) payTime).matches("\\d{14}"),
+                "pay_time 必须是 14 位 yyyyMMddHHmmss 形状(退款请求时间),实际=" + payTime);
+
+        Object payTraceTime = sent.get("pay_trace_time");
+        assertEquals("20260921120000", payTraceTime,
+                "pay_trace_time 必须传原支付时间,传反会导致上游找不到原单");
+        assertTrue(payTraceTime instanceof String && ((String) payTraceTime).matches("\\d{14}"),
+                "pay_trace_time 必须是 14 位 yyyyMMddHHmmss 形状,实际=" + payTraceTime);
+    }
+
+    @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 的谓词之外,并发第二笔退款撞不上约束。
+        // 这个断言就是在钉住"互斥窗口必须真的存在"。
+        //
+        // ★ 必须用 doAnswer 在**调用那一刻**断言,不能用 ArgumentCaptor。
+        //   captor 存的是**引用**,而 createRefund 返回前会在同一个实例上把状态翻成 SUCCESS
+        //   (applyNotifyResult 是退款行唯一的状态写入口,同步路径也照样走它),
+        //   于是 captor.getValue().getStatus() 在返回之后求值永远得到 SUCCESS ——
+        //   那条断言恒假,与实现有没有先落 REFUNDING 无关。
+        //   这里观测的是 INSERT 那一刻的实参状态,才是"先落非终态"的可验证含义。
+        when(client.post(eq("/v3/refundorder"), anyMap())).thenReturn(resp("PAY_SUCCESS"));
+        doAnswer(inv -> {
+            assertEquals("REFUNDING", ((LsyRefundEntity) inv.getArgument(0)).getStatus(),
+                    "INSERT 那一刻必须是非终态,不能直接落终态");
+            return 1;
+        }).when(refundMapper).insert(any());
+        when(refundMapper.update(any(), any())).thenReturn(1);
+        when(orderService.applyRefundResult(any(), anyLong(), any())).thenReturn(true);
+
+        service.createRefund(order(), "RF001", 100L);
+    }
+
+    @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_payFail_leavesStatusRefundingAndNeverSettlesOrder() {
+        // ★ R11:PAY_FAIL 是已知码,必须留信号,但**绝不能**落终态 FAIL。
+        //   落 FAIL 会释放 uk_lsy_refund_one_pending 的互斥窗口与在途闸门:
+        //   若上游这个 PAY_FAIL 是偶发误报(钱其实出去了),第二笔退款就真放行了。
+        //   停在 REFUNDING + 记日志让人看见,是唯一方向安全的选项。
+        Map<String, Object> fail = new LinkedHashMap<>();
+        fail.put("return_code", "SUCCESS");
+        fail.put("result_code", "PAY_FAIL");
+        fail.put("return_msg", "退款失败");
+        when(client.post(eq("/v3/refundorder"), anyMap())).thenReturn(fail);
+        when(refundMapper.insert(any())).thenReturn(1);
+
+        LsyRefundEntity r = service.createRefund(order(), "RF001", 100L);
+
+        assertEquals("REFUNDING", r.getStatus());
+        assertNotEquals("FAIL", r.getStatus(), "PAY_FAIL 不得被落成终态 FAIL(会释放互斥窗口)");
+        // 下面两条是让本用例**非空转**的关键:只断言 status 的话,
+        // 一个「把 FAIL 写成 REFUNDING」的实现也会绿。
+        verify(orderService, never()).applyRefundResult(any(), anyLong(), any());
+        verify(refundMapper, never()).update(any(), any());
+    }
+
+    @Test
+    void refund_unknownResultCode_leavesStatusRefundingAndNeverSettlesOrder() {
+        // ★ R21:R11 那条契约的另一半。`LeshouyingResponseParser.isPayFail` 的 javadoc 要求
+        //   「三个谓词全为 false 时,调用方必须走未知码分支:记日志 + 待人工核对,不得静默归类」。
+        //   R11 只堵了 PAY_FAIL,三个谓词全 false 的路径此前一个字节都不打。
+        //
+        // ★ 夹具刻意用通用成功码 "SUCCESS" —— 正是 `LeshouyingResponseParser` 那句 javadoc
+        //   点名的场景(「某端点回的是通用成功码 "SUCCESS" 而非 "PAY_SUCCESS"」)。
+        //   本用例**非空转是结构保证的**:isPaySuccess / isPayFail / isPayInProcess 比的都是
+        //   PAY_* 字面量,对 "SUCCESS" 全为 false ⇒ 必然走进新分支,
+        //   不存在"被前面某个分支吃掉所以其实没走到"的可能。
+        //
+        // ★ 已知待确认项(不是遗漏):若联调发现 /v3/refundorder 确实回裸 "SUCCESS" 而非 "PAY_SUCCESS",
+        //   那么新分支与这条用例**必须一起改**,否则每一笔正常退款都会被记成"未知码"。
+        Map<String, Object> unknown = new LinkedHashMap<>();
+        unknown.put("return_code", "SUCCESS");
+        unknown.put("result_code", "SUCCESS");   // 通用成功码,不是 PAY_SUCCESS
+        unknown.put("return_msg", "成功");
+        when(client.post(eq("/v3/refundorder"), anyMap())).thenReturn(unknown);
+        when(refundMapper.insert(any())).thenReturn(1);
+
+        LsyRefundEntity r = service.createRefund(order(), "RF001", 100L);
+
+        assertEquals("REFUNDING", r.getStatus());
+        // 与 R11 那条同形:只断言 status 的话,一个「把未知码落成终态」的实现也会绿。
+        verify(orderService, never()).applyRefundResult(any(), anyLong(), any());
+        verify(refundMapper, never()).update(any(), any());
+    }
+
+    @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());
+    }
+
+    // ---- queryRefund:只有开放 API 的退款查询端点调它(不是死代码) ----
+
+    @Test
+    void queryRefund_withoutRefundNo_sendsRefundTraceNoAndRequestTime() {
+        // queryRefund 踩的是与 createRefund 同源的那个字段陷阱:
+        // 上游退款查询接口的 refund_time 传的是**退款请求时间**(refundRequestTime),
+        // 传成原支付时间上游就找不到这笔退款。
+        // ★ 调用者有两个:开放 API 的退款查询端点
+        //   (LeshouyingOpenController.refundQuery,@PostMapping("/refund/query")),
+        //   以及 **LsyReconcileScheduler.reconcilePendingRefunds**(退款对账腿,2026-09-22 补)。
+        //   ⚠ 后者的存在使本方法的**入参正确性**变成真钱路径上的事:它的 mch_no 之前一个字节
+        //   都没送,而这条腿现在真的会被调度器调用(见下面 mch_no 那段)。
+        //   (旧注释写「没有任何定时任务调它」,那是补腿之前的事实。)
+        //
+        // 本条只覆盖「没有上游退款单号」那一支:有 refund_no 时走 refund_no,
+        // 二选一互斥(两组同时上送时上游取哪一组不可预期)。
+        LsyRefundEntity refund = refund("REFUNDING");
+        refund.setOrderId(1L);                          // ★ mch_no 由订单反查
+        refund.setRefundNo(null);                       // ★ 走 trace_no 分支
+        refund.setRefundTraceNo("RT001");
+        refund.setRefundRequestTime("20260921120500");
+        when(orderMapper.selectById(1L)).thenReturn(order());
+
+        when(client.post(eq("/v3/queryrefund"), anyMap())).thenReturn(resp("PAY_IN_PROCESS"));
+
+        service.queryRefund(refund);
+
+        var captor = org.mockito.ArgumentCaptor.forClass(Map.class);
+        verify(client).post(eq("/v3/queryrefund"), captor.capture());
+        @SuppressWarnings("unchecked")
+        Map<String, Object> sent = captor.getValue();
+
+        // keySet 恰好相等(房规:不用 containsKey —— 那种形状对「值写错了」「多塞了一个键」零保护)。
+        // 四个键 = 无条件写入的 mch_no / query_trace_no + 无 refund_no 分支写入的
+        // refund_trace_no / refund_time。refund_no **不得**出现(本条正是它的反面)。
+        //
+        // ★ mch_no 是 2026-09-22 补的:上游文档 §15 把 refund 查询的 mch_no 标为**必填**
+        //   (`D:\project2\leshouying-api-docs.md` :596),而本方法此前一个字节都没送。
+        //   判据不是「文档这么写」,是**结构孪生方法**:`LsyQueryService.queryOrder`
+        //   形状完全平行(同样的 inst_no/mch_no/query_trace_no/二选一定位键)、它送了 mch_no
+        //   且联调实测查单走通;同类的 `createRefund` 也送。缺它的只有本方法一处。
+        assertEquals(Set.of("mch_no", "query_trace_no", "refund_trace_no", "refund_time"),
+                sent.keySet(), "queryrefund 入参集合必须恰好是这四个");
+        // keySet 相等**不能**证明值非 null(`LeshouyingClient.post` 的 null 过滤作用在副本上,
+        // 而且一个值为 null 的键照样在 keySet 里),所以下面这条是真覆盖,不是重复。
+        assertNotNull(sent.get("query_trace_no"), "query_trace_no 必填,且必须真的填了值");
+        assertEquals("Y52211090004", sent.get("mch_no"), "mch_no 必填,必须取自订单而不是留空");
+        assertEquals("RT001", sent.get("refund_trace_no"));
+        // 值断言用字面量而非 refund.getRefundRequestTime():让期望值独立于 fixture 的取值 ——
+        // 将来有人改 refund.setRefundRequestTime(...) 时,期望值不会跟着静默漂移。
+        Object refundTime = sent.get("refund_time");
+        assertEquals("20260921120500", refundTime);
+        assertTrue(refundTime instanceof String && ((String) refundTime).matches("\\d{14}"),
+                "refund_time 必须是 14 位 yyyyMMddHHmmss 形状(退款请求时间),实际=" + refundTime);
+    }
+
+    @Test
+    void queryRefund_withRefundNo_sendsRefundNoOnly() {
+        // 与上一条互为镜像:手上有上游退款单号时走 refund_no 一支。
+        // 两支都要覆盖 —— 走哪一支取决于库里那一行有没有 refund_no,不是「哪一支更常用」。
+        //
+        // ★ 三样都设上(refundNo / refundTraceNo / refundRequestTime),走错分支才可被观测到:
+        //   只设 refundNo 的话,实现就算误走 trace_no 分支,入参里也会多出 refund_trace_no 与
+        //   refund_time,「keySet 恰好相等」这条断言才拦得住 —— 那是本用例真正的判据。
+        LsyRefundEntity refund = refund("REFUNDING");
+        refund.setOrderId(1L);
+        refund.setRefundNo("RF999");
+        refund.setRefundTraceNo("RT001");               // ★ 故意设上:走错分支就会多出来
+        refund.setRefundRequestTime("20260921120500");  // ★ 同上
+        when(orderMapper.selectById(1L)).thenReturn(order());
+
+        when(client.post(eq("/v3/queryrefund"), anyMap())).thenReturn(resp("PAY_IN_PROCESS"));
+
+        service.queryRefund(refund);
+
+        var captor = org.mockito.ArgumentCaptor.forClass(Map.class);
+        verify(client).post(eq("/v3/queryrefund"), captor.capture());
+        @SuppressWarnings("unchecked")
+        Map<String, Object> sent = captor.getValue();
+
+        // 恰好相等:有 refund_no 时**不得**再上送 refund_trace_no / refund_time ——
+        // 两组同时上送时上游取哪一组不可预期(二选一互斥)。
+        assertEquals(Set.of("mch_no", "query_trace_no", "refund_no"), sent.keySet(),
+                "queryrefund 有 refund_no 时入参集合必须恰好是这三个");
+        assertEquals("RF999", sent.get("refund_no"));
+        // keySet 相等不能证明值非 null(值为 null 的键照样在 keySet 里),故这条是真覆盖。
+        assertNotNull(sent.get("query_trace_no"), "query_trace_no 必填,且必须真的填了值");
+    }
+
+    /**
+     * 订单查不到时**不送** `mch_no` —— 退化为改动前的行为,而不是送一个 null 键。
+     *
+     * <p>【为什么单独立一条】`LeshouyingClient.post` 会过滤 null 值,所以「送 null」与「不送」
+     * 在上游看到的是同一件事;但只有后者能被 keySet 断言区分。若实现写成
+     * `p.put("mch_no", null)`,上面两条「keySet 恰好相等」的用例**照样全绿**,
+     * 于是「反查失败时怎么办」这条路径永远不被覆盖。
+     */
+    @Test
+    void queryRefund_订单查不到_不送mchNo() {
+        LsyRefundEntity refund = refund("REFUNDING");
+        refund.setOrderId(404L);
+        refund.setRefundNo("RF999");
+        when(orderMapper.selectById(404L)).thenReturn(null);
+        when(client.post(eq("/v3/queryrefund"), anyMap())).thenReturn(resp("PAY_IN_PROCESS"));
+
+        service.queryRefund(refund);
+
+        var captor = org.mockito.ArgumentCaptor.forClass(Map.class);
+        verify(client).post(eq("/v3/queryrefund"), captor.capture());
+        @SuppressWarnings("unchecked")
+        Map<String, Object> sent = captor.getValue();
+        assertFalse(sent.containsKey("mch_no"),
+                "查不到订单时不得送 mch_no(送 null 键会让上两条 keySet 用例失去覆盖),实测="
+                        + sent.keySet());
+    }
+
+    // ---- settleFromQuery:退款对账腿的解释点(Job 2 第五条腿,2026-09-22) ----
+    //
+    // 【为什么它必须存在】设计 §6.6 写了「PAY_IN_PROCESS → REFUNDING,由 Job 2 查
+    // v3/queryrefund 推进」,实现时掉了:LsyReconcileScheduler 四条腿全是订单
+    // ⇒ 停在 REFUNDING 的行永久卡住(台账 §2.3)。
+    //
+    // 【为什么解释放在 Service 而不是调度器】调度器 javadoc 明令
+    // 「本调度器自己不解析任何上游报文」—— 与 LsyOrderService.settleFromQuery 同一条纪律。
+    //
+    // 【上游词表依据】`D:\project2\leshouying-api-docs.md` §15 :603 —— 退款**查询**回的是
+    // PAY_SUCCESS / PAY_IN_PROCESS / PAY_FAIL,而退款**回调**回的是 TRADE_REFUND /
+    // TRADE_REFUND_FAIL(§8 :441)。两套词表,所以解析器复用支付侧那三个谓词是对的。
+
+    /**
+     * 正控:查询确认退款成功 ⇒ 落 SUCCESS + 收敛订单侧 + 推订单回调。
+     *
+     * <p>【为什么正控不可省】Mockito 对未 stub 的 `update` 默认返回 **0**,
+     * 没有这一条,一个「永远不写、永远不收敛」的坏实现能让所有负路径用例全绿。
+     */
+    @Test
+    void settleFromQuery_退款成功_落SUCCESS并收敛订单侧并推订单回调() {
+        LsyRefundEntity refund = refund("REFUNDING");
+        refund.setOrderId(1L);
+        LsyOrderEntity o = order();
+        when(refundMapper.update(any(), any())).thenReturn(1);
+        when(orderMapper.selectById(1L)).thenReturn(o);
+        when(orderService.applyRefundResult(eq(o), eq(30L), anyString())).thenReturn(true);
+
+        assertEquals("SUCCESS", service.settleFromQuery(refund, resp("PAY_SUCCESS")));
+        assertEquals("SUCCESS", refund.getStatus());
+        verify(callbackService).notifyOrder(o);
+    }
+
+    /**
+     * `PAY_FAIL` ⇒ 落 FAIL 终态。
+     *
+     * <p>【它与 `createRefund` 同步分支守闸**不矛盾**】同步分支手上没有权威答案,
+     * 所以停住;对账腿拿到的是上游对**这笔退款**的直接回答,而上游文档 :485 明说
+     * 「`PAY_IN_PROCESS` 需调用**退款查询接口**确认退款结果」—— 查询就是权威确认通道。
+     * 另外:若这里也不落终态,这条腿就只收敛成功、收敛不了失败,等于没解决 §2.3。
+     */
+    @Test
+    void settleFromQuery_退款失败_落FAIL() {
+        LsyRefundEntity refund = refund("REFUNDING");
+        refund.setOrderId(1L);
+        when(refundMapper.update(any(), any())).thenReturn(1);
+
+        assertEquals("FAIL", service.settleFromQuery(refund, resp("PAY_FAIL")));
+        assertEquals("FAIL", refund.getStatus());
+        verify(orderService, never()).applyRefundResult(any(), anyLong(), anyString());
+        verify(callbackService, never()).notifyOrder(any());
+    }
+
+    @Test
+    void settleFromQuery_退款申请中_不落状态返回null() {
+        LsyRefundEntity refund = refund("REFUNDING");
+
+        assertNull(service.settleFromQuery(refund, resp("PAY_IN_PROCESS")));
+        assertEquals("REFUNDING", refund.getStatus());
+        verify(refundMapper, never()).update(any(), any());
+    }
+
+    /**
+     * 未知码 ⇒ **什么都不写**,留在 PENDING 等下一轮。
+     *
+     * <p>与 `LsyOrderService.settleFromQuery` 的未知码分支同纪律:落任何终态都是替上游猜方向,
+     * 而落 FAIL 会释放 `uk_lsy_refund_one_pending` 与在途闸门 ⇒ 可能重复出金。
+     * 停在 PENDING 有界:下一轮对账会拿同一个问题再问上游一次。
+     */
+    @Test
+    void settleFromQuery_未知码_不落任何状态() {
+        LsyRefundEntity refund = refund("REFUNDING");
+
+        assertNull(service.settleFromQuery(refund, resp("SOME_UNKNOWN_CODE")));
+        assertEquals("REFUNDING", refund.getStatus());
+        verify(refundMapper, never()).update(any(), any());
+    }
+
+    /**
+     * 回调先到、对账后到:`applyNotifyResult` 命中 0 行 ⇒ 不得重复收敛订单侧。
+     *
+     * <p>这是 §2.3 与回调路径的必然交叉面 —— 对账腿存在的意义之一就是与回调赛跑,
+     * 所以「赛输的那一次不能重复累加 refunded_amount」是它的核心不变量。
+     */
+    @Test
+    void settleFromQuery_已被回调写成终态_返回null且不重复收敛订单() {
+        LsyRefundEntity refund = refund("REFUNDING");
+        refund.setOrderId(1L);
+        when(refundMapper.update(any(), any())).thenReturn(0);   // 已被回调抢先写终态
+
+        assertNull(service.settleFromQuery(refund, resp("PAY_SUCCESS")));
+        verify(orderService, never()).applyRefundResult(any(), anyLong(), anyString());
+        verify(callbackService, never()).notifyOrder(any());
+    }
+
+    // ---- 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_非终态流转_不写finishTime() {
+        // finish_time 的列注释是「完成时间(终态)」(V1.8:161)。
+        // §2.1 预插入之后本方法多了一个非终态流转(CREATED → REFUNDING),
+        // 无条件写会让**还在途**的退款带上完成时间 —— 事后按 finish_time IS NULL
+        // 捞未完结退款就会漏掉它。MP 的 NOT_NULL 策略下「patch 里为 null」= 该列不进 SET。
+        LsyRefundEntity r = new LsyRefundEntity();
+        r.setId(9L);
+        r.setStatus("CREATED");
+        when(refundMapper.update(any(), any())).thenReturn(1);
+
+        assertTrue(service.applyNotifyResult(r, "PAY_IN_PROCESS", "处理中", "REFUNDING"));
+
+        var captor = org.mockito.ArgumentCaptor.forClass(LsyRefundEntity.class);
+        verify(refundMapper).update(captor.capture(), any());
+        assertNull(captor.getValue().getFinishTime(),
+                "非终态流转不得写 finish_time(该列语义是「完成时间(终态)」)");
+    }
+
+    @Test
+    void applyNotifyResult_终态流转_仍然写finishTime() {
+        // ★ 阳性对照:证明上面那条不是因为「finish_time 被整个删掉了」才绿的。
+        //   两向都要有读数,否则「不写」与「根本不会写」在结果上无法区分。
+        LsyRefundEntity r = new LsyRefundEntity();
+        r.setId(9L);
+        r.setStatus("REFUNDING");
+        when(refundMapper.update(any(), any())).thenReturn(1);
+
+        assertTrue(service.applyNotifyResult(r, "PAY_SUCCESS", "成功", "SUCCESS"));
+
+        var captor = org.mockito.ArgumentCaptor.forClass(LsyRefundEntity.class);
+        verify(refundMapper).update(captor.capture(), any());
+        assertNotNull(captor.getValue().getFinishTime(),
+                "终态必须写 finish_time —— 否则这列永远不会被写");
+    }
+
+    @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");
+    }
+
+    /**
+     * ★ 退款到达终态必须推下游回调。
+     *
+     * <p>【这条钉的是一个真实缺陷】原实现里没有任何地方调用 `callbackService.notifyRefund`:
+     * 退款行 insert 时写 `notify_status='PENDING'`,而 `LsyCallbackService.retryFailed()`
+     * 的退款腿只捞 `notify_status='FAIL'`,能把行写成 FAIL 的 `recordRefundNotifyResult`
+     * 又只被 `notifyRefund` 调 ⇒ **退款行永远停在 PENDING,下游一个退款回调都收不到**。
+     * `applyNotifyResult` 是退款行**唯一**的状态写入口(同步成功与异步回调两条路径都走它),
+     * 所以推送挂在这里,两条路径一起覆盖。
+     */
+    @Test
+    void applyNotifyResult_terminalStatus_pushesDownstreamRefundCallback() {
+        LsyRefundEntity refund = refund("REFUNDING");
+        when(refundMapper.update(any(), any())).thenReturn(1);
+
+        assertTrue(service.applyNotifyResult(refund, "PAY_SUCCESS", "成功", "SUCCESS"));
+        verify(callbackService).notifyRefund(refund);
+    }
+
+    /** 退款失败也是终态,同样要推 —— 下游需要知道这笔退款没成。 */
+    @Test
+    void applyNotifyResult_failedStatus_alsoPushesDownstream() {
+        LsyRefundEntity refund = refund("REFUNDING");
+        when(refundMapper.update(any(), any())).thenReturn(1);
+
+        assertTrue(service.applyNotifyResult(refund, "PAY_FAIL", "失败", "FAIL"));
+        verify(callbackService).notifyRefund(refund);
+    }
+
+    /**
+     * 重复回调(条件更新命中 0 行)**不得**再推一次。
+     *
+     * <p>推送挂在 `applyNotifyResult` 成功分支之后,就是为了让这条闸门同时管住推送 ——
+     * 上游重推回调时下游不该收到第二条相同的退款通知。
+     */
+    @Test
+    void applyNotifyResult_duplicateCallback_doesNotPushAgain() {
+        LsyRefundEntity refund = refund("SUCCESS");
+        when(refundMapper.update(any(), any())).thenReturn(0);
+
+        assertFalse(service.applyNotifyResult(refund, "PAY_SUCCESS", "成功", "SUCCESS"));
+        verify(callbackService, never()).notifyRefund(any());
+    }
+
+    /**
+     * 推送本身抛异常**不得**改变返回值。
+     *
+     * <p>钱已经退出去了。此时让异常冒到 `createRefund` 的调用方(OpenAPI 控制器),
+     * 租户收到的是「退款失败」—— 而实际上退款成功了,那是最坏的一类谎报。
+     * 推送失败该由 `notify_status='FAIL'` + 重试腿兜,
+     * 不该由「把成功的退款报成失败」兜。
+     */
+    @Test
+    void applyNotifyResult_pushThrows_stillReturnsTrue() {
+        LsyRefundEntity refund = refund("REFUNDING");
+        when(refundMapper.update(any(), any())).thenReturn(1);
+        doThrow(new RuntimeException("推送通道炸了")).when(callbackService).notifyRefund(any());
+
+        assertTrue(service.applyNotifyResult(refund, "PAY_SUCCESS", "成功", "SUCCESS"),
+                "推送失败不得让调用方以为退款失败");
+        assertEquals("SUCCESS", refund.getStatus(), "本地状态回写不受推送影响");
+    }
+
+    @Test
+    void refund_rejectsWhenOrderSnapshotExceedsLedger() {
+        // ★ 本用例封的是「订单侧已累加、退款行却是 FAIL」这个状态。
+        //   该状态由 LsyRefundService 同步成功分支的设计产生:`if (!applyNotifyResult(...))` 与
+        //   `if (!settleOrderAfterRefund(...))` 是**并列**两条,后者不在前者的 else 里,
+        //   故 applyNotifyResult 返回 false 时订单侧照样累加,于是快照 > 台账
+        //   —— 按内容定位,别按行号(交付时在 LsyRefundService.java:188-193)。
+        //   Task 10 自己写不出 FAIL 行(它只写 REFUNDING/SUCCESS),所以本任务内不可达 ——
+        //   但 Task 12 的回调路径会写,所以闸门必须现在就收紧。
+        //
+        //   断言方向:**必须拒**。旧实现(只取台账)在这里会算出 remaining=100 而放行,
+        //   也就是「钱已经出去了还能再退一次」—— 本用例在改动前必红。
+        LsyOrderEntity o = order();
+        o.setTotalAmount(100L);
+        o.setRefundedAmount(60L);           // ★ 订单侧已累加 60
+        o.setStatus("SUCCESS");
+
+        LsyRefundEntity failed = new LsyRefundEntity();
+        failed.setId(9L);
+        failed.setStatus("FAIL");           // ★ 台账里这一笔不是 SUCCESS ⇒ Σ(SUCCESS)=0
+        failed.setRefundFee(60L);
+        when(refundMapper.selectList(any())).thenReturn(List.of(failed));
+
+        assertThrows(IllegalArgumentException.class,
+                () -> service.createRefund(o, "RF003", 50L));
+        // 非空转的关键:旧实现会走到上游调用,改动后必须在上游之前拒掉。
+        verify(client, never()).post(anyString(), anyMap());
+    }
+
+    private LsyRefundEntity refund(String status) {
+        LsyRefundEntity r = new LsyRefundEntity();
+        r.setId(9L);
+        r.setOutRefundNo("RF001");
+        r.setRefundFee(30L);
+        r.setStatus(status);
+        return r;
+    }
+}