浏览代码

feat(收款): 扫码收款后端三个端点 —— 柜台一个金额框就能收钱

给「收银台」这条最简路径配后端。操作员只填一个金额(备注选填),
单号与商户号全部由服务端决定 —— 这是本批次的交互目标:屏幕上从头到尾
只有一个输入框。

  POST /payment/collection/cashier/qrcode              下单,拿二维码
  GET  /payment/collection/cashier/{outTradeNo}        轮询;撤销失败后也用它复查
  POST /payment/collection/cashier/{outTradeNo}/close  撤销(关单)

复用 LsyMerchantService.requireByTenant 找本租户默认商户,复用 LsyOrderService
下单/关单,**不抄** LeshouyingOpenController 那套落库装配 —— 那份带 enterpriseId
分流,收银台没有「哪家企业」这个概念。

三处刻意的选择,都是为了让柜台那台电脑上的二维码不消失、不多打上游:

1. **不设 notify_url**(pull,不是 push)。pay_lsy_order.notify_url 一列两义:
   既是「回调往哪打」,也是「这一单要不要回调」。收银台设上它,等于让上游把回调
   打进我们自己的收银端点 —— 自环。轮询 3 秒足够,柜台不需要推送。

2. **终态短路**。已到终态的单不再问上游,否则一笔已成功的单会被永远轮询下去。

3. **上游查单失败降级成「按本地状态返回」**,不抛错。一次网络抖动不该把二维码
   从操作员屏幕上抹掉;前端因此也不会因为一次失败就把码收走。

租户闸:三个端点只从认证上下文取租户,查询带**显式** tenant_id 谓词。
带 outTradeNo 的端点第一行就是 requireOrder(outTradeNo, tenantId),
不存在「先按单号查出来、再在 Java 里比租户」这条路 —— 那已经把别租户的单据读进内存了。

两向变异 7 组(读数存 logs/_t4-mutate-report.txt,不进仓):
  丢租户谓词 / 关单不加守卫 / 设上 notify_url / 去掉查单节流 / 去掉失败降级 /
  去掉终态短路 —— 六组各自**只**打红它该打红的那一条;
  外加一组**无害**改动(局部变量改名)实测仍全绿。
  每组跑完都按字节还原并复核基线 sha256
  738e01fb17b43bf69cf04664b075730ecff55cdfd1713556fc5a9cb97dce083e。

登记完备性(LeshouyingAdminWiringTest):原测试只保证「注解里的权限串都在菜单契约里」,
漏的是反方向 —— 菜单里有页面行、却没有任何控制器声明它。补腿⑤后,
反注册本控制器 ⇒ 变红并报出 unbacked = [module_payment:leshouying:cashier:list];
无害地把 MENU_SQLS 换个顺序 ⇒ 仍绿。MENU_SQL 随之扩成 MENU_SQLS 数组,
因为契约现在跨两个脚本文件。

测试:LeshouyingCashierControllerTest 20 条;LeshouyingAdminWiringTest 6 条;
全量 688 绿 / 0 失败 / 0 错误,BUILD SUCCESS。

菜单 SQL(db/lsy-cashier-menu.sql)**由用户执行**,本提交只落文件。
route_name 与 permission 取自活库只读实测与源码现取,不是照邻页推的
(照邻页推会错:邻页源码里写的名字与活库对不上)。
★ 该文件注释里有一处前向引用(lsy-merchant-menu-rename.sql)—— 那个文件在本批次的
  后一个提交里;单独 checkout 本提交会看到一条指向未来的引用。
alphaH 3 周之前
父节点
当前提交
35f2d8e96f

+ 482 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingCashierController.java

@@ -0,0 +1,482 @@
+package com.payment.platform.module.payment.leshouying.controller;
+
+import cn.hutool.core.util.IdUtil;
+import cn.hutool.core.util.StrUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
+import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException;
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.common.response.Result;
+import com.payment.platform.core.security.LoginUser;
+import com.payment.platform.module.payment.leshouying.client.LeshouyingApiException;
+import com.payment.platform.module.payment.leshouying.dto.LsyCashierCreateDTO;
+import com.payment.platform.module.payment.leshouying.dto.LsyCashierOrderVO;
+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.enums.LsyOrderStatus;
+import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper;
+import com.payment.platform.module.payment.leshouying.service.LsyCancelService;
+import com.payment.platform.module.payment.leshouying.service.LsyMerchantService;
+import com.payment.platform.module.payment.leshouying.service.LsyOrderService;
+import com.payment.platform.module.payment.leshouying.service.LsyQueryService;
+import com.payment.platform.module.payment.leshouying.util.LsyLogSanitizer;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+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.ResponseStatus;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.time.OffsetDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.Map;
+
+/**
+ * 扫码收款(管理端收银台)—— 出二维码 / 看状态 / 撤销,三个端点。
+ *
+ * <p><b>【本类存在的理由:在此之前平台自己收一笔钱必须去调开放 API】</b>
+ * 管理端原有的乐收赢入口全是**看**的(订单、退款、商户映射)或者**进件**(建号、提交入网),
+ * 没有任何一处能主动发起一笔收款。要收钱只能走 {@code /payment/openapi/pay/**} ——
+ * 而那要 ApiKey、要签名、要下游自己拼报文。这个控制器就是那块收银台:
+ * 一个金额输入框,按一下,出二维码。
+ *
+ * <h2>★★ 为什么收银单**不设** {@code notify_url}</h2>
+ * 「顾客付完,页面马上变绿」这件事有两条路:等上游回调推过来(push),或者我们自己问
+ * (pull)。本控制器走的是 <b>pull</b>,{@link #status} 每次都主动查一次上游。
+ * 理由是 push 那条路在这里**会绕成一个环**:
+ * <ul>
+ *   <li>{@code pay_lsy_order.notify_url} 这一列在库里只有一个位置,却被两种语义共用 ——
+ *       上送给上游时它是「上游回调我们」的地址({@code LsyOrderService.createQrOrder} 里
+ *       {@code p.put("notify_url", order.getNotifyUrl())}),而
+ *       {@code LsyCallbackService.resolveUrl} 把**同一个字段**当成「我们推给下游」的目标,
+ *       且优先级最高。</li>
+ *   <li>于是:若把收银单的 {@code notify_url} 填成我们自己的回调入口,上游推回来之后
+ *       {@code LsyNotifyService} 落库成功 → {@code callbackService.notifyOrder(order)} →
+ *       {@code resolveUrl} 取订单快照 ⇒ <b>我们把自己的回调推给自己</b>。那个端点吃
+ *       {@code @RequestBody Map},而我们推出去的是 form-encoded ⇒ 415 ⇒
+ *       {@code notify_status=FAIL},再被对账腿重试最多 8 次。</li>
+ *   <li>干净地挡掉这个环需要改 {@code LsyCallbackService}(已评审、线上运行中的代码),
+ *       而收银台本来就不需要 push:屏幕就在操作员面前,3 秒问一次足够了。</li>
+ * </ul>
+ * <p>所以 {@link #newOrder} 里 {@code notifyUrl} <b>有意留空</b>,并有测试钉着这一条
+ * ({@code create_assemblesTheOrder_rowShapeAndServerDecidedFields})。
+ * 这是本控制器与 {@code LeshouyingOpenController} 那条腿**唯一一处刻意的行为差异**。
+ *
+ * <h2>与 {@code LeshouyingOpenController.newOrder} 的关系:孪生,**有意不合并**</h2>
+ * 本类的 {@link #newOrder} 与开放 API 那三个 {@code /create} 端点里的落库装配是同一件事的
+ * 两份实现。抽成一处公共方法会迫使那三个端点的既有测试全部改构造方式 ——
+ * 而那条腿**线上正在跑**,本批次不动它。代价是两份装配会各自漂移,所以:
+ * <ul>
+ *   <li>两侧各自有一条「装配不变量」测试(本类是
+ *       {@code create_assemblesTheOrder_rowShapeAndServerDecidedFields});</li>
+ *   <li>注释里互相指名:本方法 javadoc 末尾列了**逐字段的差异表**,改任一侧先看那张表。</li>
+ * </ul>
+ *
+ * <h2>★★ 租户归属闸:只来自认证上下文,且顺序固定</h2>
+ * <b>三个端点的第一行都是 {@link #requireTenantId()}。</b>
+ * 「收款」那条腿的顺序固定为
+ * 「取认证上下文 → {@link LsyMerchantService#requireByTenant}(本地闸)→ 组装 → 落库 → 上游下单」,
+ * <b>本地闸必须排在任何一个上游调用之前</b> —— 反了不是「同样安全」:
+ * 那等于拿一个还没证明属于本租户的商户号去发起一次上游调用。
+ *
+ * <p>查单 / 关单两个端点的行级定位**自带** {@code tenant_id} 谓词(见 {@link #requireOrder})
+ * 而不是只靠 {@code TenantInnerInterceptor}:拦截器对超管主体不追加谓词
+ * (同 {@code LeshouyingOnboardingController} 的实测),而这里「别人的单号」只是一个字符串。
+ *
+ * <h2>端点清单(菜单 SQL 与前端 API 层按它对齐)</h2>
+ * <table border="1">
+ *   <caption>三个权限串 → 三个端点</caption>
+ *   <tr><th>方法</th><th>完整路径</th><th>权限串</th></tr>
+ *   <tr><td>POST</td><td>{@code /payment/collection/cashier/qrcode}</td>
+ *       <td>{@value #PERM_CASHIER_CREATE}</td></tr>
+ *   <tr><td>GET</td><td>{@code /payment/collection/cashier/{outTradeNo}}</td>
+ *       <td>{@value #PERM_CASHIER_LIST}</td></tr>
+ *   <tr><td>POST</td><td>{@code /payment/collection/cashier/{outTradeNo}/close}</td>
+ *       <td>{@value #PERM_CASHIER_CLOSE}</td></tr>
+ * </table>
+ *
+ * <p>★ 权限串在注解里写成**字面量**(不是常量拼接):全仓
+ * {@code grep 'module_payment:leshouying:cashier:create'} 必须能命中 {@code @PreAuthorize}
+ * 那一行,菜单 SQL 要按字面量逐字对齐。注解↔菜单 SQL 的一致性由
+ * {@code LeshouyingAdminWiringTest} 对撞(本类已登记进它的 {@code ADMIN_CONTROLLERS})。
+ */
+@Slf4j
+@RestController
+@RequestMapping("/payment/collection/cashier")
+@RequiredArgsConstructor
+public class LeshouyingCashierController {
+
+    /** 权限串的**字符串常量**只用于本类 javadoc 的 {@code @value} 与测试比对;注解里必须写字面量。 */
+    static final String PERM_CASHIER_CREATE = "module_payment:leshouying:cashier:create";
+    static final String PERM_CASHIER_LIST = "module_payment:leshouying:cashier:list";
+    static final String PERM_CASHIER_CLOSE = "module_payment:leshouying:cashier:close";
+
+    /** 与 {@code LsyCancelService} / {@code LeshouyingOpenController} 同一份交易时间格式。 */
+    private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
+
+    /**
+     * 单号前缀。与开放 API 那条腿的单号**不共用**前缀 —— 收银台的单在库里一眼可辨,
+     * 排查时不必再去 join 一次 {@code api_key_id}。
+     */
+    private static final String OTN_PREFIX = "SK";
+
+    /**
+     * 两次上游查询之间的最小间隔(秒),与前端页面的轮询间隔**同值**。
+     *
+     * <p>节流的目的是「页面开着不动时不要按人眨眼的频率打上游」,而不是「省一次调用」:
+     * 3 秒一次正好是「顾客付完,屏幕上最迟 3 秒变绿」这条体验承诺的上界。
+     * 取比 3 更大没有收益(更慢),取更小则一轮页面轮询可能触发两次上游查询。
+     */
+    private static final long POLL_INTERVAL_SECONDS = 3L;
+
+    /**
+     * 撤销失败时给操作员的一句话。
+     *
+     * <p>★ 「可重试」这个方向是**量过的**,不是照抄:{@code LeshouyingOnboardingController}
+     * 对写端点用的是防重试文案(那边的重试会多建一个上游商户),而关单重复执行已被实测为
+     * 无害 —— {@code LeshouyingOpenController.orderClose} 的注释里记着「重复关一笔已 CLOSED
+     * 的单,上游回 FAIL『该聚合码链接不存在或已过期!』」。重试不可逆的那一侧才需要防。
+     */
+    private static final String MSG_CLOSE_FAILED = "撤销未成功,请稍后重试";
+
+    private final LsyOrderService orderService;
+    private final LsyQueryService queryService;
+    private final LsyCancelService cancelService;
+    private final LsyMerchantService merchantService;
+    private final LsyOrderMapper orderMapper;
+
+    // ──────────────────────────── 1. 出二维码 ────────────────────────────
+
+    /**
+     * 收一笔钱:入参只有金额(和一行可选备注),返回二维码。
+     *
+     * <p>落库与下单是**两步**且顺序不可换:{@code createQrOrder} 往这一行上写 {@code code_url}
+     * 与状态,用的是 {@code order.getId()} —— 而 id 由 insert 生成。
+     */
+    @PreAuthorize("@perm.hasAny('module_payment:leshouying:cashier:create')")
+    @PostMapping("/qrcode")
+    public Result<LsyCashierOrderVO> createQr(@Valid @RequestBody LsyCashierCreateDTO dto) {
+        Long tenantId = requireTenantId();
+
+        // ★ 本地闸在**任何上游调用之前**。没有商户号 ⇒ 404,一行都不落、一次上游都不发。
+        LsyMerchantEntity merchant = merchantService.requireByTenant(tenantId);
+
+        LsyOrderEntity order = newOrder(tenantId, merchant, dto);
+        orderMapper.insert(order);
+
+        try {
+            orderService.createQrOrder(order, null);
+        } catch (LeshouyingApiException e) {
+            // 上游这一跳失败:本地这一行会留在 CREATED 且没有 code_url。
+            // 它**不是死行** —— LsyReconcileScheduler.expireStaleOrders 扫的是整个
+            // LsyOrderStatus.PENDING(含 CREATED),超过 absoluteExpireHours 会被强制收敛为
+            // EXPIRED。所以不在这里额外清理(那会引入一条与开放 API 那条腿不同的行为)。
+            //
+            // ★ 文案不给「重试」也不给「防重试」:下单失败的重试会生成**新的单号**,
+            //   旧行既不占资金也不再被使用,重试完全安全 —— 但说「请重试」又暗示
+            //   「这一单还在」,所以只描述事实。
+            log.warn("收银台下单未成功: outTradeNo={}, 原因={}", order.getOutTradeNo(), e.getMessage());
+            return Result.of(500, "二维码生成失败,请重新发起收款");
+        }
+        return Result.ok(toVo(order, "请顾客扫码支付"));
+    }
+
+    // ──────────────────────────── 2. 看状态(页面轮询) ────────────────────────────
+
+    /**
+     * 页面每 3 秒问一次:这一单付了没有。
+     *
+     * <p>★ <b>这是「顾客付完马上变绿」的全部实现</b>(见类 javadoc 里为什么不用回调)。
+     * 顺序是「先查上游、再落库、最后读内存对象」—— {@code settleFromQuery} 会把终态同步回
+     * 传进去的那个实体,所以返回值里的状态是**查完之后**的。
+     */
+    @PreAuthorize("@perm.hasAny('module_payment:leshouying:cashier:list')")
+    @GetMapping("/{outTradeNo}")
+    public Result<LsyCashierOrderVO> status(@PathVariable String outTradeNo) {
+        Long tenantId = requireTenantId();
+        LsyOrderEntity order = requireOrder(tenantId, outTradeNo);
+        refreshFromUpstream(order);
+        return Result.ok(toVo(order, null));
+    }
+
+    // ──────────────────────────── 3. 撤销 ────────────────────────────
+
+    /**
+     * 撤销 —— 顾客没付、这一单不要了。
+     *
+     * <p>出口形状照 {@code LeshouyingOpenController.orderClose}(同一次服务调用),
+     * 但两处文案按**操作员能做什么**改了:
+     * <ul>
+     *   <li>上游文档 §5.5 要求「收到失败先查订单状态,不要盲目重试」——而这个页面
+     *       每 3 秒自己就在查,所以 {@link #MSG_CLOSE_FAILED} 让操作员原地等一下即可,
+     *       不需要教他去别处查。抹成「服务器内部错误」等于把那条指引删掉。</li>
+     *   <li>开放 API 在「已支付」那一支让租户「请调用退款接口」;收银台这版**没有退款入口**
+     *       (本批次不做),照抄等于指一条不存在的路。</li>
+     * </ul>
+     */
+    @PreAuthorize("@perm.hasAny('module_payment:leshouying:cashier:close')")
+    @PostMapping("/{outTradeNo}/close")
+    public Result<LsyCashierOrderVO> close(@PathVariable String outTradeNo) {
+        Long tenantId = requireTenantId();
+        LsyOrderEntity order = requireOrder(tenantId, outTradeNo);
+
+        String target;
+        try {
+            target = cancelService.closeOrder(order);
+        } catch (LeshouyingApiException e) {
+            log.warn("收银台撤销未成功: outTradeNo={}, 原因={}", order.getOutTradeNo(), e.getMessage());
+            return Result.of(500, MSG_CLOSE_FAILED);
+        }
+        if (target == null) {
+            // 上游拒绝(或撤销前查询显示支付中 / 未知码而主动不撤)⇒ 本次不改状态,留给下次。
+            return Result.of(500, MSG_CLOSE_FAILED);
+        }
+        if ("SUCCESS".equals(target)) {
+            // ★ 这个 "SUCCESS" 的含义是**「顾客已经付过了,撤销来不及了」**,不是「关单成功」。
+            //   报成「已撤销」的后果是一笔真实收款在操作员眼里消失。详见 LsyCancelService
+            //   的类 javadoc「查到已支付 → 不调 cancelorder」。
+            log.info("收银台撤销时发现订单已支付: outTradeNo={}", order.getOutTradeNo());
+            return Result.of(500, "顾客已经付款了,不能撤销");
+        }
+
+        // ★ 条件更新:守卫**只能**写在 WHERE 里(返回值不是守卫)。窗口不窄 ——
+        //   requireOrder 取快照与这次落库之间隔着 closeOrder 的**一次完整上游 HTTP 往返**,
+        //   期间支付成功回调可能已经把这一行写成 SUCCESS。写法与
+        //   LsyOrderService.applyPayResult / LsyReconcileScheduler.markExpired 同形 ——
+        //   本模块只允许这一种条件更新风格。
+        LsyOrderEntity patch = new LsyOrderEntity();
+        patch.setStatus(target);
+        patch.setUpdatedTime(OffsetDateTime.now());
+        UpdateWrapper<LsyOrderEntity> w = new UpdateWrapper<>();
+        w.eq("id", order.getId())
+         .in("status", LsyOrderStatus.PENDING);
+        if (orderMapper.update(patch, w) == 0) {
+            log.info("收银台撤销结果未命中(订单已非非终态),不覆盖状态: outTradeNo={}, target={}",
+                    order.getOutTradeNo(), target);
+            return Result.of(500, "撤销未生效:这一单的状态刚刚变了(可能顾客已经付款),请以页面刷新后的状态为准");
+        }
+        order.setStatus(target);
+        return Result.ok(toVo(order, "已撤销"));
+    }
+
+    // ──────────────────────────── 落库装配(开放 API 那条腿的孪生) ────────────────────────────
+
+    /**
+     * 组装收银单。
+     *
+     * <p><b>逐字段的差异表(对照 {@code LeshouyingOpenController.newOrder} 与三个 {@code /create} 端点):</b>
+     * <table border="1">
+     *   <caption>同名字段为什么取值不同</caption>
+     *   <tr><th>字段</th><th>开放 API</th><th>收银台</th><th>差异的理由</th></tr>
+     *   <tr><td>{@code outTradeNo}</td><td>调用方传入({@code @Pattern} 校验)</td>
+     *       <td>服务端生成 {@code "SK"+雪花}</td><td>收银员不输入单号</td></tr>
+     *   <tr><td>{@code apiKeyId}</td><td>认证上下文里的 ApiKey</td><td>null</td>
+     *       <td>收银台走 JWT,不走 ApiKey 认证面</td></tr>
+     *   <tr><td>{@code enterpriseId}</td><td>可选(企业级映射分流)</td><td>不设(null)</td>
+     *       <td>{@code requireByTenant} 找的就是 {@code enterprise_id IS NULL} 那一行</td></tr>
+     *   <tr><td>{@code notifyUrl}</td><td>调用方传入</td><td><b>有意不设</b></td>
+     *       <td>见类 javadoc「为什么收银单不设 notify_url」</td></tr>
+     *   <tr><td>{@code orderBody}</td><td>{@code dto.getOrderBody()}</td><td>{@code dto.getRemark()}</td>
+     *       <td>同一列,两个端点各自的名字</td></tr>
+     *   <tr><td>{@code attach}</td><td>{@code dto.getAttach()}</td><td>不设(null)</td>
+     *       <td>收银台没有「回传字段」这个概念</td></tr>
+     * </table>
+     *
+     * <p>★ <b>{@code insert} 必须发生在 {@code mchNo}/{@code shopNo} 装配之后</b>:
+     * {@code pay_lsy_order.mch_no} 是 NOT NULL 且无默认值。
+     */
+    private LsyOrderEntity newOrder(Long tenantId, LsyMerchantEntity merchant, LsyCashierCreateDTO dto) {
+        OffsetDateTime now = OffsetDateTime.now();
+        LsyOrderEntity o = new LsyOrderEntity();
+        o.setTenantId(tenantId);
+
+        o.setOutTradeNo(OTN_PREFIX + IdUtil.getSnowflakeNextIdStr());
+        o.setMchNo(merchant.getMchNo());
+        o.setShopNo(merchant.getShopNo());
+
+        o.setPayScene("QRCODE");
+        // 聚合码不传 pay_type —— 上游按用户扫出来的码自动识别支付宝/微信(同 LsyQrCreateDTO 那条腿)。
+        o.setPayType(null);
+        o.setPayTraceNo(IdUtil.fastSimpleUUID());
+        o.setPayTime(LocalDateTime.now().format(FMT));
+
+        // 落库这一刻还没下单 ⇒ 只能是 CREATED。createQrOrder 之后会按上游应答改成 PAYING/FAIL。
+        o.setStatus("CREATED");
+        o.setTotalAmount(dto.getAmount());
+        o.setRefundedAmount(0L);
+        o.setQueryCount(0);
+        o.setOrderBody(dto.getRemark());
+
+        o.setNotifyStatus("PENDING");
+        o.setNotifyCount(0);
+        // ★ notifyUrl 有意留空 —— 见类 javadoc。
+
+        o.setCreatedTime(now);
+        o.setUpdatedTime(now);
+        return o;
+    }
+
+    // ──────────────────────────── 查单与节流 ────────────────────────────
+
+    /**
+     * 定位本租户的一单。
+     *
+     * <p>★ {@code tenant_id} 谓词是**显式写上**的,不是「反正拦截器会加」:
+     * {@code TenantInnerInterceptor.ignoreTable} 对超管主体直接返回 true(不追加谓词),
+     * 而这里「别人的单号」只是一个字符串 —— 少了这条谓词,任何人拿到一个单号就能读到
+     * 别家租户的收款单。查不到一律 404,且**不去打扰上游**(否则连单号存不存在都能被探出来)。
+     */
+    private LsyOrderEntity requireOrder(Long tenantId, String outTradeNo) {
+        if (StrUtil.isBlank(outTradeNo)) {
+            throw new BusinessException(400, "单号不能为空");
+        }
+        LsyOrderEntity order = orderMapper.selectOne(
+                new LambdaQueryWrapper<LsyOrderEntity>()
+                        .eq(LsyOrderEntity::getTenantId, tenantId)
+                        .eq(LsyOrderEntity::getOutTradeNo, outTradeNo)
+                        .last("LIMIT 1"));
+        if (order == null) {
+            throw new BusinessException(404, "收款单不存在");
+        }
+        return order;
+    }
+
+    /**
+     * 还挂着的话,问一次上游并落库。
+     *
+     * <p>三条短路,一个都不能少:
+     * <ol>
+     *   <li><b>终态不问</b> —— 否则一页开着不动会把上游配额烧在这张已经结束的单上;</li>
+     *   <li><b>未到点不问</b>({@link #POLL_INTERVAL_SECONDS})—— 页面 3 秒一轮,
+     *       没有节流就是「每一轮打一次上游」;</li>
+     *   <li><b>失败降级</b> —— 上游这一跳失败**不得**变成操作员屏幕上的一屏报错。
+     *       照库里的状态照常返回,页面继续轮询;</li>
+     * </ol>
+     * 只接 {@link LeshouyingApiException}(上游那一跳的失败类型),不接 {@code Exception}:
+     * 把编程缺陷也吞掉会让它们只在生产上表现为「状态一直不变」。
+     */
+    private void refreshFromUpstream(LsyOrderEntity order) {
+        if (!LsyOrderStatus.PENDING.contains(order.getStatus())) {
+            return;
+        }
+        OffsetDateTime next = order.getNextQueryTime();
+        if (next != null && next.isAfter(OffsetDateTime.now())) {
+            return;
+        }
+        Map<String, Object> resp;
+        try {
+            resp = queryService.queryOrder(order);
+        } catch (LeshouyingApiException e) {
+            log.warn("收银台查单未成功,按本地状态返回: outTradeNo={}, 原因={}",
+                    order.getOutTradeNo(), e.getMessage());
+            return;
+        }
+        orderService.settleFromQuery(order, resp);
+        markNextQuery(order);
+    }
+
+    /**
+     * 记一次查询并推下次可查时间。
+     *
+     * <p>★ 这是 {@code LsyOrderService.markNextQuery} 的**孪生**,不能复用:那个方法是
+     * <b>包内可见</b>({@code service} 包专用),本类在 {@code controller} 包。
+     * 放开它成 {@code public} 会把「谁都能推这个游标」变成公开接口。
+     * 落库写法与 {@code LsyOrderPollScheduler.pollOne} 逐字同形({@code updateById} +
+     * 三个时间/计数列)。
+     *
+     * <p>★ 用 {@code updateById} 而不是条件更新是**对的**:这不是状态流转,是轮询记账
+     * —— 它不携带任何关于「钱」的断言,写在不该写的行上也不会造成错误状态
+     * (同一个选择在 {@code LsyOrderPollScheduler} 里已经跑了很久)。
+     */
+    private void markNextQuery(LsyOrderEntity order) {
+        OffsetDateTime now = OffsetDateTime.now();
+        LsyOrderEntity patch = new LsyOrderEntity();
+        patch.setId(order.getId());
+        patch.setQueryCount((order.getQueryCount() == null ? 0 : order.getQueryCount()) + 1);
+        patch.setLastQueryTime(now);
+        patch.setNextQueryTime(now.plusSeconds(POLL_INTERVAL_SECONDS));
+        patch.setUpdatedTime(now);
+        orderMapper.updateById(patch);
+
+        order.setQueryCount(patch.getQueryCount());
+        order.setLastQueryTime(now);
+        order.setNextQueryTime(patch.getNextQueryTime());
+    }
+
+    // ──────────────────────────── 视图与出口 ────────────────────────────
+
+    private static LsyCashierOrderVO toVo(LsyOrderEntity o, String message) {
+        LsyCashierOrderVO vo = new LsyCashierOrderVO();
+        vo.setOutTradeNo(o.getOutTradeNo());
+        vo.setStatus(o.getStatus());
+        vo.setTotalAmount(o.getTotalAmount());
+        vo.setReceiptAmount(o.getReceiptAmount());
+        vo.setCodeUrl(o.getCodeUrl());
+        vo.setExpireSeconds(remainingSeconds(o.getCodeUrlExpireTime()));
+        vo.setMessage(message);
+        return vo;
+    }
+
+    /** 剩余秒数;没有过期时间 ⇒ null(页面据此不显示倒计时)。已过期 ⇒ 0,不返回负数。 */
+    private static Long remainingSeconds(OffsetDateTime expireAt) {
+        if (expireAt == null) {
+            return null;
+        }
+        return Math.max(Duration.between(OffsetDateTime.now(), expireAt).getSeconds(), 0L);
+    }
+
+    /**
+     * 取当前主体的租户;取不到就**显式拒绝**。
+     *
+     * <p>不带着 {@code null} 继续往下走:{@code eq(列, null)} 在 SQL 里恒不成立、
+     * 于是表现为一个查不到数据的空页面 —— 那种「看起来正常的空」比一句 403 难查得多。
+     * 文案不含任何上游词汇(Global Constraint 1),且它经 {@code GlobalExceptionHandler}
+     * 原样回给调用方。
+     */
+    private Long requireTenantId() {
+        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
+        LoginUser user = (auth != null && auth.getPrincipal() instanceof LoginUser u) ? u : null;
+        Long tenantId = user == null ? null : user.getTenantId();
+        if (tenantId == null) {
+            throw new BusinessException(403, "当前登录身份没有归属租户,无法执行收款操作,请联系管理员");
+        }
+        return tenantId;
+    }
+
+    /**
+     * 请求体里有不认识的字段 ⇒ 400 + 一句人话,**不是** HTTP 500「服务器内部错误」。
+     *
+     * <p>照 {@code LeshouyingOpenController.handleUnreadableBody} 的先例(本仓
+     * {@code FAIL_ON_UNKNOWN_PROPERTIES} 是 true,未知键会一路冒到
+     * {@code GlobalExceptionHandler} 的兜底)。这里挂在自己身上而不是加进全局出口,
+     * 理由同那个控制器:{@code ExceptionHandlerExceptionResolver} 先查 handler 自身、
+     * 再查 advice,顺序确定。
+     *
+     * <p>★ 只取 Jackson 的 {@code propertyName} 自己拼消息,**不回显它的原文** ——
+     * 原文形如 {@code Unrecognized field "x" (class com.payment.platform...)},
+     * 把类名交给调用方等于送出一份内部结构图。
+     */
+    @ExceptionHandler(HttpMessageNotReadableException.class)
+    @ResponseStatus(HttpStatus.BAD_REQUEST)
+    public Result<?> handleUnreadableBody(HttpMessageNotReadableException e) {
+        Throwable cause = e.getCause();
+        String detail = cause instanceof UnrecognizedPropertyException upe
+                ? "未知字段 \"" + LsyLogSanitizer.flatten(upe.getPropertyName())
+                        + "\",报文里的字段名一律用下划线式,请核对"
+                : "请求体不是合法 JSON";
+        log.warn("收银台请求体解析失败: {}", detail);
+        return Result.of(400, detail);
+    }
+}

+ 49 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/dto/LsyCashierCreateDTO.java

@@ -0,0 +1,49 @@
+package com.payment.platform.module.payment.leshouying.dto;
+
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Positive;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+/**
+ * 收银台「收款」的请求体。
+ *
+ * <p>★ <b>只有两个字段,且两个都不是服务端决定的值</b> —— 单号、商户号、门店号、交易时间
+ * 全部由服务端生成(见 {@code LeshouyingCashierController.newOrder})。
+ * 收银员面对的屏幕上只有一个金额输入框和一行备注,这个 DTO 就是那块屏幕的形状。
+ *
+ * <p>★ 字段名一律**下划线式**(本仓 {@code JacksonConfig} 是 SNAKE_CASE):
+ * 报文里写 {@code amount} / {@code remark} 即此处两个字段,没有第二个别名。
+ */
+@Data
+public class LsyCashierCreateDTO {
+
+    /**
+     * 收款金额,单位**分**。
+     *
+     * <p>{@code @Positive} 不是洁癖:金额是操作员唯一的输入,也是唯一会带着错误值
+     * 走到上游去的字段。0 与负数在收银场景里没有含义,而静默接受它们等于让一笔
+     * 「0 元收款」以成功的姿态出现在页面上。
+     *
+     * <p>★ <b>没有上界</b> —— 上游对单笔金额的上限我们**没有实测过**,编一个数字出来
+     * 会变成一条我们自己也解释不清的规则。联调时实测出真实上限后再补。
+     *
+     * <p>★ <b>防手滑(多打几个 0)今天靠什么</b>:靠**页面上那一眼** —— 二维码生成后,
+     * 应收金额以 34px 大字显示在二维码**旁边**(`cashier/index.vue` 的 `.wait-amount`),
+     * 顾客扫码付款之前操作员就能核对。**没有**二次确认弹窗,**也没有**金额上限校验
+     * —— 那两条都会给每一笔正常收款加一步,与「简单快捷」相抵。
+     * ★ 如实写:这个做法**不拦**手滑,只是让它在付款**之前**可见。若日后要真拦,
+     * 该加的是「超过某阈值才确认」,而那个阈值是个业务决定,不由本类拍。
+     */
+    @NotNull
+    @Positive
+    private Long amount;
+
+    /**
+     * 备注(选填)。落到 {@code pay_lsy_order.order_body} —— 也就是**顾客账单上看到的那行字**。
+     *
+     * <p>长度上界 128 与列定义对齐(同 {@code LsyQrCreateDTO.orderBody})。
+     */
+    @Size(max = 128, message = "备注不能超过 128 位")
+    private String remark;
+}

+ 51 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/dto/LsyCashierOrderVO.java

@@ -0,0 +1,51 @@
+package com.payment.platform.module.payment.leshouying.dto;
+
+import lombok.Data;
+
+/**
+ * 收银台收款单的对外视图 —— 「收款」「查状态」「撤销」三个端点共用同一个形状。
+ *
+ * <p>★ <b>本 VO 有意比 {@link LsyPayResultVO} 少一个字段:没有 {@code tradeNo}。</b>
+ * 开放 API 那条腿必须给 {@code trade_no}(租户要靠它发起退款 / 查询),
+ * 而收银员从头到尾不需要它 —— 屏幕上没有一处能用到。少给一个上游标识就少一处泄漏面,
+ * 所以这里不给,而不是「顺手也带上」。
+ *
+ * <p>★ 报文键名一律**下划线式**({@code out_trade_no} / {@code receipt_amount} /
+ * {@code expire_seconds}),由全局 {@code JacksonConfig} 的 SNAKE_CASE 决定,
+ * 前端 {@code collection.ts} 的注释按同一份约定写。
+ */
+@Data
+public class LsyCashierOrderVO {
+
+    /** 本单的号。收银员不需要理解它,但它是「刷新后还能找回这一单」的凭据。 */
+    private String outTradeNo;
+
+    /**
+     * 订单状态,原样透给页面判色:
+     * {@code CREATED} / {@code PAYING} = 等待付款;{@code SUCCESS} = 收款成功;
+     * {@code CANCELED} / {@code CLOSED} / {@code EXPIRED} = 已撤销 / 已过期;
+     * {@code FAIL} = 未成功;另有两个退款态 {@code PARTIAL_REFUNDED} / {@code REFUNDED}。
+     *
+     * <p>★ 值与含义见 {@link com.payment.platform.module.payment.leshouying.enums.LsyOrderStatus},
+     * 此处不另立一套「简单状态」—— 多一层映射就是多一处会与库里不一致的地方。
+     */
+    private String status;
+
+    /** 应收金额(分) */
+    private Long totalAmount;
+
+    /** 实收金额(分)。用户付款前上游不给这个数 ⇒ 此刻为 null(不替它编 0) */
+    private Long receiptAmount;
+
+    /** 收款二维码链接;下单未成功时为空 */
+    private String codeUrl;
+
+    /**
+     * 二维码剩余有效秒数 —— 页面靠它倒计时并自己切到「已过期」。
+     * 没有过期时间时为 null(页面据此不显示倒计时)。
+     */
+    private Long expireSeconds;
+
+    /** 给操作员看的一句话。成功时是「请顾客扫码支付 / 已撤销」这类,失败在 {@code Result.msg} 里 */
+    private String message;
+}

+ 183 - 0
java/src/main/resources/db/lsy-cashier-menu.sql

@@ -0,0 +1,183 @@
+-- 扫码收款(收银台)—— 菜单与权限(Task 4 / Step 4)
+--
+-- 【本脚本尚未执行】执行由用户做。执行前请先读下面「★ 授权给谁」一节。
+--
+-- ── 本脚本干什么 ──────────────────────────────────────────────────────
+-- 在**已存在**的「收付款」目录下,新增:
+--   · 1 行页面(type=2)「扫码收款」 —— 收银台路由
+--   · 2 行按钮(type=3)「发起收款」「撤销收款」 —— 承载另两个写权限点
+-- 并把这三行授权给 role_id = 1(管理员角色)**与 role_id = 2(租户角色)**。
+-- ★★ 与 `lsy-onboarding-menu.sql` **故意不同**:那份**只授 role 1**,这份**两个都授**。
+--    理由不是「这份更松」,而是那一份挡的那条攻击链在**这里根本不存在** —— 见下「★ 授权给谁」。
+--    别为了「保持一致」把 role 2 从这里删掉,也别照这份把 role 2 加到进件那份上。
+--
+-- ── ★ 与 lsy-onboarding-menu.sql 的形态差异(照抄它的写法会出错的地方)──
+-- 两者的骨架相同(`dir` 是**查**目录、不是插目录),列清单同样是 **20 列**。差异只有权限串、
+-- 名字、路径,以及授权范围。★ 那份脚本头部关于 uuid / id / component_path / type 语义的
+-- 全部警告**在这里同样适用**,不重复粘贴 —— 需要时去读它(`lsy-onboarding-menu.sql:46-90`)。
+--
+-- ── ★★ route_name:`Collection*` 是**查活库查出来的**,不是照邻页源码推的 ────────
+-- **活库只读实测(2026-09-23,scripts 见 logs/_t4_menu_probe.py)**:
+--   收付款目录(id=**268**,type=1)下四个 type=2 子行的 route_name 依次是
+--     id=269「支付订单」 CollectionOrder
+--     id=270「退款记录」 CollectionRefund
+--     id=271「商户映射」 CollectionMerchant
+--     id=272「商户进件」 CollectionOnboarding
+--   ⇒ 活库的命名空间是 **`Collection<名词>`**。
+--   ★ 上面四个名字是**那一刻**的读数。其中 id=271 随后在本批次改名
+--     「商户映射」→「手工登记商户号」(`lsy-merchant-menu-rename.sql`,同一批次交付);
+--     其余三个名字未动。`route_name` 四行都没变。
+--
+-- ★★ 但 `frontend/src/views/module_payment/collection/` 下**三个页面的源码**里写的是
+--    `LeshouyingOrder` / `LeshouyingRefund` / `LeshouyingMerchant` —— **与活库对不上**。
+--    只有 onboarding 那页写的 `CollectionOnboarding` 是对的。
+--    ⇒ **照「紧邻的三个页面」取名会取错**(本文件初稿就是这么错的,靠查库拦下)。
+--    ⇒ 那三页的 `keep_alive=true` **今天静默失效**(`<keep-alive :include>` 匹配**组件名**,
+--      而 `cachedViews` 里放的是**路由名**;对不上时 vue 不报错,只是不缓存)。
+--      这是**既有缺陷、不在本批次范围**,**未顺手改**(用户令:不相关的不动)。
+--    ⇒ 本页两头都用 `CollectionCashier`,先保证自己自洽。
+--    ★ 若哪天有人去修那三页,修法是**把页面里的 defineOptions 改成活库那个名字**,
+--      而不是改库(改库会让已发布的前端路由名漂移)。
+--
+-- ── ★ permission 必须与 @PreAuthorize 逐字相同 ──────────────────────────
+-- 差一个字符就是 403,而且**超管测不出来**(`PermissionCheck` 对 `ROLE_SUPERUSER` 直接放行)。
+-- 本脚本三个 permission 逐字取自 **2026-09-23 从源码现取**(未抄任何文档):
+--   LeshouyingCashierController.java:168  @PreAuthorize("@perm.hasAny('module_payment:leshouying:cashier:create')")  —— POST /qrcode
+--   LeshouyingCashierController.java:205  @PreAuthorize("@perm.hasAny('module_payment:leshouying:cashier:list')")    —— GET  /{outTradeNo}
+--   LeshouyingCashierController.java:229  @PreAuthorize("@perm.hasAny('module_payment:leshouying:cashier:close')")   —— POST /{outTradeNo}/close
+-- 类级映射 `@RequestMapping("/payment/collection/cashier")`(同文件 :117)。
+-- ★ 取值口径是「现取」,不是「抄一次就完」—— 控制器改了这里必须重取。
+-- ★ 那三行注释里同时还有 `static final String PERM_CASHIER_*` 三个常量;注解里写的是**字面量**
+--   (为的是 `grep 'module_payment:leshouying:cashier:create'` 能命中 @PreAuthorize 那一行),
+--   两者的相等由 `LeshouyingAdminWiringTest` 与 `LeshouyingCashierControllerTest` 双向钉住。
+--
+-- ── component_path ───────────────────────────────────────────────────
+-- **相对 `src/views/` 的路径**:不带 `views/` 前缀、不带 `.vue` 后缀。
+-- 本脚本的值与页面文件对齐:
+--   frontend/src/views/module_payment/collection/cashier/index.vue
+-- 前缀/后缀写错会拼成查不到的模块名 ⇒ **静默落到 error/404.vue**(不是报错)。
+--
+-- ── ★★ 授权给谁:role 1 **和** role 2 ─────────────────────────────────
+-- **活库只读实测(2026-09-23)**:收付款子行的现有授权是
+--   支付订单 → role 1,2 / 退款记录 → role 1,2 / 商户映射(今「手工登记商户号」) → role 1,2
+--   / **商户进件 → 只有 role 1**。
+--   ⇒ 本页按**多数形态**(两个角色都授)。
+--
+-- ★ `lsy-onboarding-menu.sql` 只授 role 1,它挡的是这条链(每一跳它都实测过):
+--   `onboarding:commit` 的租户归属闸证明的是「**本租户内存在一行 mch_no 等于该值**」,
+--   而映射表**调用方自己就能写**(`merchant:list` 一个读权限守着写和删、mch_no 由调用方给)
+--   ⇒ 于是能拿别家**可枚举**的 `mch_no`(形如 `Y52211090004`)去发起入网提交。
+--
+-- ★★ 这条链**搬不到收银台**,因为收银台的商户号**不由调用方给**:
+--   `LeshouyingCashierController.createQr` 里那一行是
+--       `LsyMerchantEntity merchant = merchantService.requireByTenant(tenantId);`
+--   —— 唯一入参是**认证上下文里取出的 tenantId**(`requireTenantId()`),
+--      报文里**没有** mchNo / shopNo / tenant_id 任何一个字段(`LsyCashierCreateDTO` 只有
+--      `amount` 与 `remark`;多给一个未知字段会被局部 handler 拒成 400)。
+--   ⇒ 调用方**无法**让这一单落到别人家的商户上;最坏情形是「租户给自己的商户号收钱」,
+--      而那**正是这个页面的用途**。
+--   ★ 并且落库与查询两侧都带显式 tenant_id 谓词(`requireOrder`),
+--     所以也读不到别家的单(那两条谓词各有专门的用例钉着)。
+--
+-- ★ 如实写代价:授了 role 2 ⇒ 17 个租户用户都能在管理端点「收款」,即**真的能产生资金流水的单**。
+--   这是本功能的目的(用户令:流程要完整、要傻瓜式),但它确实是一条**新的**资金入口,
+--   与进件那种「上游侧写」不是一个量级 —— 所以这里把判据留全,供复核者独立复核。
+--   ★ 若判定「租户不得自助收款」,改法是把下面 `SELECT 1, x.id` 换成 `SELECT 1, x.id ... UNION ALL
+--     SELECT 2, ...`(即回到只授 role 1),不用重跑本脚本 —— 见「放开/收回」。
+--
+-- ── 幂等(与 lsy-onboarding-menu.sql 同形,两种相反情形都不报错)──────────
+--   1) 页面行已存在 ⇒ `page` 为空 ⇒ **两个按钮与全部授权行都不会插**,且不报错。
+--   2) 收付款目录不存在 ⇒ `dir` 为空 ⇒ 三行都不插,且不报错。执行前先跑第 1 条只读核对。
+--
+-- ── "order" ──────────────────────────────────────────────────────────
+-- 用 `MAX("order") + 1` **现算**,不写死数字。实测(2026-09-23)现值为 1/2/3/4 ⇒ 本行会拿到 **5**。
+--   ★ 写成字面量 5 会在「有人手工调过序」或「本脚本重跑」时撞车,而 `sys_menu` 对
+--     (parent_id, "order") **没有唯一约束** ⇒ 撞了也不报错,只是两行同序。
+--   ★ 全表行数实测 240(`lsy-onboarding-menu.sql` 写作时是 237,其后 +3 正是它自己插的
+--     1 页面 + 2 按钮)—— 这个差额同时是「那份脚本真的执行过」的一条旁证。
+--
+-- ── 执行后若要把 role 2 收回 ──────────────────────────────────────────
+--   DELETE FROM sys_role_menus
+--    WHERE role_id = 2
+--      AND menu_id IN (SELECT id FROM sys_menu
+--                       WHERE name IN ('扫码收款', '发起收款', '撤销收款') AND status = '0');
+--   ★ **不要**用「重跑本脚本」来收回:页面行已存在 ⇒ 授权行也不会插(见幂等 1),
+--     但同样**也不会删**,跑了等于什么都没做。
+
+WITH dir AS (
+    SELECT id FROM sys_menu
+     WHERE name = '收付款' AND type = 1 AND status = '0' AND parent_id IS NULL
+),
+page AS (
+    INSERT INTO sys_menu (name, type, "order", permission, icon, route_name, route_path, component_path,
+                          redirect, hidden, keep_alive, always_show, affix, title, params,
+                          uuid, parent_id, status, created_time, updated_time)
+    SELECT '扫码收款', 2,
+           (SELECT COALESCE(MAX("order"), 0) + 1 FROM sys_menu WHERE parent_id = dir.id),
+           'module_payment:leshouying:cashier:list', NULL,
+           'CollectionCashier', '/payment/collection/cashier',
+           'module_payment/collection/cashier/index',
+           NULL, false, true, false, false, '扫码收款', NULL,
+           gen_random_uuid(), dir.id, '0', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
+    FROM dir
+    WHERE NOT EXISTS (SELECT 1 FROM sys_menu
+                       WHERE name = '扫码收款' AND parent_id = dir.id AND status = '0')
+    RETURNING id
+),
+buttons AS (
+    INSERT INTO sys_menu (name, type, "order", permission, icon, route_name, route_path, component_path,
+                          redirect, hidden, keep_alive, always_show, affix, title, params,
+                          uuid, parent_id, status, created_time, updated_time)
+    SELECT v.name, 3, v.ord, v.perm, NULL, NULL, NULL, NULL,
+           NULL, false, false, false, false, v.name, NULL,
+           gen_random_uuid(), page.id, '0', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
+    FROM page,
+         (VALUES
+            ('发起收款', 1, 'module_payment:leshouying:cashier:create'),
+            ('撤销收款', 2, 'module_payment:leshouying:cashier:close')
+         ) AS v(name, ord, perm)
+    RETURNING id
+)
+-- 页面行与两个按钮行**都要**授权:菜单树按 sys_role_menus 过滤,只授页面会让按钮权限缺席 ⇒ 403。
+-- 两个角色都授(理由见上「★★ 授权给谁」—— 与进件那份的差异是**故意的**)。
+INSERT INTO sys_role_menus (role_id, menu_id)
+SELECT r.role_id, x.id
+  FROM (SELECT id FROM page UNION ALL SELECT id FROM buttons) AS x
+ CROSS JOIN (VALUES (1), (2)) AS r(role_id);
+
+
+-- ── 只读核对(执行后手工跑一遍;第 1 条**执行前**也先跑)────────────────
+--
+-- 1) 收付款目录在不在(**为空则本脚本什么都不会插,且不报错**)
+--   SELECT id, name, type, route_path FROM sys_menu
+--    WHERE name = '收付款' AND type = 1 AND status = '0' AND parent_id IS NULL;
+--   期望:**1 行**(2026-09-23 实测 id=268)。0 行 ⇒ 先建目录再回来。
+--
+-- 2) 页面行
+--   SELECT id, name, type, "order", permission, route_name, route_path, component_path,
+--          keep_alive, uuid
+--     FROM sys_menu WHERE name = '扫码收款' AND status = '0';
+--   期望:**1 行**,type=2,permission='module_payment:leshouying:cashier:list',
+--         route_name='CollectionCashier',route_path='/payment/collection/cashier',
+--         component_path='module_payment/collection/cashier/index',
+--         keep_alive=true,uuid 非空(36 字符)。★ uuid 为 NULL 说明有人改了本脚本(见文件头)。
+--
+-- 3) 两个按钮行(**漏了 ⇒ 非超管点发起/撤销必 403,而超管测不出来**)
+--   SELECT m.id, m.name, m.type, m."order", m.permission
+--     FROM sys_menu m JOIN sys_menu p ON m.parent_id = p.id
+--    WHERE p.name = '扫码收款' AND m.type = 3 AND m.status = '0'
+--    ORDER BY m."order";
+--   期望:**2 行**,permission 逐字为
+--         'module_payment:leshouying:cashier:create' 与 'module_payment:leshouying:cashier:close'。
+--
+-- 4) 授权行(**6 行**:3 个菜单 × 2 个角色)
+--   SELECT rm.role_id, m.name, m.permission
+--     FROM sys_role_menus rm JOIN sys_menu m ON m.id = rm.menu_id
+--    WHERE m.name IN ('扫码收款', '发起收款', '撤销收款') AND m.status = '0'
+--    ORDER BY rm.role_id, m.name;
+--   期望:**6 行**,role_id 是 1 和 2 各 3 行。若只有 3 行(全是 1)⇒ role 2 被收回了,见文件头。
+--
+-- 5) 端到端(**唯一能证明权限串没写错的一条**):用一个**非超管**的租户账号登录,
+--    确认三个 `module_payment:leshouying:cashier:*` 都出现在
+--    `MenuMapper.selectPermissionsByUserId` 的返回里。
+--    ★ 超管账号**证明不了任何事** —— 它对 ROLE_SUPERUSER 直接放行。

+ 76 - 21
java/src/test/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingAdminWiringTest.java

@@ -77,16 +77,41 @@ class LeshouyingAdminWiringTest {
             "/payment/collection/refund/{outRefundNo}",
             "/payment/collection/merchant/page",
             "/payment/collection/merchant",
+            "/payment/collection/cashier/qrcode",
+            "/payment/collection/cashier/{outTradeNo}",
+            "/payment/collection/cashier/{outTradeNo}/close",
     };
 
-    /** 被守的三个管理端控制器(走 JWT + `@PreAuthorize`)。见 `everyAdminMappedEndpoint_…` 的边界说明。 */
+    /**
+     * 被守的管理端控制器(走 JWT + `@PreAuthorize`)。见 `everyAdminMappedEndpoint_…` 的边界说明。
+     *
+     * <p>★ 这是**登记制**,不是扫描制:类没写进这里就**不受任何守卫**,而本类不会因此变红。
+     * 新增管理端控制器时**必须**同时登记它、把它的端点加进 {@link #ADMIN_PATHS}、
+     * 并把它的权限串所在的菜单脚本加进 {@link #MENU_SQLS} —— 三件事少一件都会造出
+     * 一个「测试全绿但没被守」的端点。
+     * ★ 已知**未登记**的:{@code LeshouyingOnboardingController}
+     * (`/payment/collection/onboarding`,权限串在 `db/lsy-onboarding-menu.sql`)。
+     * 它是上一批次加的,按当时的边界说明「由评审时的类清单核对兜底」——**本批次没有顺手收编**,
+     * 因为那会把本类的注意力扩散到不在本批次范围内的端点上。**如实记在这里,别当成已覆盖。**
+     */
     private static final List<Class<?>> ADMIN_CONTROLLERS = List.of(
             LeshouyingOrderController.class,
             LeshouyingRefundController.class,
-            LeshouyingMerchantController.class);
+            LeshouyingMerchantController.class,
+            LeshouyingCashierController.class);
 
-    /** Task 18 的菜单脚本:权限串**契约**的事实来源(不是本测试、也不是控制器)。 */
-    private static final String MENU_SQL = "db/lsy-menu.sql";
+    /**
+     * 菜单脚本清单:权限串**契约**的事实来源(不是本测试、也不是控制器)。
+     *
+     * <p>★ 为什么要多份:`everyAdminMappedEndpoint_…` 的腿③后半断言「注解里的每个权限串都
+     * **落在契约清单里**」。清单只扫 `lsy-menu.sql` 时,`cashier` 那三条串会全部被判成
+     * 「授不出来的野串」—— 也就是**新控制器一登记就必然变红**。
+     * 反过来说:漏扫一份脚本的后果是**假红**(不是假绿),这个方向是安全的。
+     */
+    private static final String[] MENU_SQLS = {
+            "db/lsy-menu.sql",
+            "db/lsy-cashier-menu.sql",
+    };
 
     @Test
     void adminPaths_mustNotBeInApiKeyFilterWhitelist() throws Exception {
@@ -235,12 +260,13 @@ class LeshouyingAdminWiringTest {
     }
 
     /**
-     * 三个管理端控制器,**逐个映射方法**地守四件事:
+     * 每个**已登记**的管理端控制器,**逐个映射方法**地守五件事:
      * <ol>
      *   <li>真实路径(类级 `@RequestMapping` + 方法级 mapping)不落在 ApiKey 认证面内;</li>
      *   <li>每个映射方法都带 {@code @PreAuthorize};</li>
-     *   <li>它引用的权限串落在 {@code lsy-menu.sql} 的 permission 契约里;</li>
-     *   <li>SpEL 里引用的 bean 名就是 {@code PermissionCheck} 上 {@code @Component} 的注册名。</li>
+     *   <li>它引用的权限串落在 {@link #MENU_SQLS} 的 permission 契约里(⊆ 方向);</li>
+     *   <li>SpEL 里引用的 bean 名就是 {@code PermissionCheck} 上 {@code @Component} 的注册名;</li>
+     *   <li><b>登记完备性</b>(⊇ 方向):契约里的每个页面权限都要有被登记的控制器声明它。</li>
      * </ol>
      *
      * <p>【第 2 条为什么承重 —— 它是 C29 那条假担保的兑现】
@@ -274,7 +300,7 @@ class LeshouyingAdminWiringTest {
         String registeredBean = permBeanName();
         Set<String> menuPermissions = permissionsFromMenuSql();
         assertFalse(menuPermissions.isEmpty(),
-                "没能从 " + MENU_SQL + " 解析出任何 permission —— 解析式失效时,"
+                "没能从 " + Arrays.toString(MENU_SQLS) + " 解析出任何 permission —— 解析式失效时,"
                         + "「权限串落在契约里」这条会在空集上平凡通过");
 
         // ── 腿①:准入护栏。发现集合为空 ⇒ 下面每条全称断言都在空集上恒真(假绿)────────
@@ -330,8 +356,32 @@ class LeshouyingAdminWiringTest {
         // 「菜单授了、后端不认」或反过来「后端要一个永远授不出的串」。
         Set<String> strays = new LinkedHashSet<>(declaredPermissions);
         strays.removeAll(menuPermissions);
-        assertTrue(strays.isEmpty(), "@PreAuthorize 用了 " + MENU_SQL + " 里不存在的权限串 ⇒ "
-                + "该权限永远授不出来(非超管一律 403): " + strays + ";契约清单=" + menuPermissions);
+        assertTrue(strays.isEmpty(), "@PreAuthorize 用了 " + Arrays.toString(MENU_SQLS) + " 里"
+                + "都不存在的权限串 ⇒ 该权限永远授不出来(非超管一律 403): " + strays
+                + ";契约清单=" + menuPermissions);
+
+        // ── 腿⑤:登记**完备**性(把上面那条腿缺的那个方向补上)──────────────────────
+        // 上面只断言 declared ⊆ contract。这个方向对「**新控制器忘了登记**」是**瞎的**:
+        // 把 LeshouyingCashierController 从 ADMIN_CONTROLLERS 里删掉,declared 只是少三条串,
+        // strays 仍是空集 ⇒ 全绿,而该控制器**完全不受本类任何守卫**。
+        // 这一条反过来问:契约里的**页面行**权限,有没有被登记的控制器声明它?
+        // ★ `:list` 是这些菜单脚本里**页面行**(type=2)的命名约定 ——
+        //   实测:5 个页面行(order / refund / merchant / onboarding / cashier)全部以 `:list` 结尾,
+        //   按钮行(type=3)全部以 `:create` / `:commit` / `:close` 结尾。约定若变,这里会假红(方向安全)。
+        Set<String> pagePerms = new LinkedHashSet<>();
+        for (String p : menuPermissions) {
+            if (p.endsWith(":list")) {
+                pagePerms.add(p);
+            }
+        }
+        assertFalse(pagePerms.isEmpty(),
+                "契约清单里一个 `:list` 页面权限都没有 —— 本腿会在空集上平凡通过");
+        Set<String> unbacked = new LinkedHashSet<>(pagePerms);
+        unbacked.removeAll(declaredPermissions);
+        assertTrue(unbacked.isEmpty(), "菜单里有页面行,但没有任何**被登记**的控制器声明它的权限串 ⇒ "
+                + "该页面背后的端点落在本类所有守卫之外,而本类不会因此变红。"
+                + "修法:把它加进 ADMIN_CONTROLLERS(连同它的端点到 ADMIN_PATHS、"
+                + "它的菜单脚本到 MENU_SQLS): " + unbacked);
     }
 
     /**
@@ -447,20 +497,25 @@ class LeshouyingAdminWiringTest {
     }
 
     /**
-     * {@code lsy-menu.sql} 里的 permission 清单。**先剥行注释再取串** ——
-     * 该脚本的注释里有大量反引号示例,注释文本混进来会让「串在契约里」这条断言被注释兜住。
+     * {@link #MENU_SQLS} 里每份脚本的 permission 清单,取并集。
+     * **先剥行注释再取串** —— 那些脚本的注释里有大量反引号示例与逐字引用的权限串,
+     * 注释文本混进来会让「串在契约里」这条断言被注释兜住(即:把一个**授不出来**的串
+     * 判成合规)。本类的 `MENU_SQLS` 是**常量清单**,不是目录扫描 ——
+     * 漏登记一份脚本的后果是**假红**,方向安全(见 {@link #MENU_SQLS})。
      */
     private static Set<String> permissionsFromMenuSql() throws Exception {
-        try (InputStream in = LeshouyingAdminWiringTest.class.getClassLoader().getResourceAsStream(MENU_SQL)) {
-            assertNotNull(in, "找不到菜单脚本 " + MENU_SQL + "(应位于 src/main/resources 下)");
-            String sql = new String(in.readAllBytes(), StandardCharsets.UTF_8)
-                    .replaceAll("(?m)--[^\\n]*", "");
-            Set<String> out = new LinkedHashSet<>();
-            Matcher m = Pattern.compile("'([a-z][a-z0-9_]*(?::[a-z0-9_]+){2,})'").matcher(sql);
-            while (m.find()) {
-                out.add(m.group(1));
+        Set<String> out = new LinkedHashSet<>();
+        for (String name : MENU_SQLS) {
+            try (InputStream in = LeshouyingAdminWiringTest.class.getClassLoader().getResourceAsStream(name)) {
+                assertNotNull(in, "找不到菜单脚本 " + name + "(应位于 src/main/resources 下)");
+                String sql = new String(in.readAllBytes(), StandardCharsets.UTF_8)
+                        .replaceAll("(?m)--[^\\n]*", "");
+                Matcher m = Pattern.compile("'([a-z][a-z0-9_]*(?::[a-z0-9_]+){2,})'").matcher(sql);
+                while (m.find()) {
+                    out.add(m.group(1));
+                }
             }
-            return out;
         }
+        return out;
     }
 }

+ 622 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingCashierControllerTest.java

@@ -0,0 +1,622 @@
+package com.payment.platform.module.payment.leshouying.controller;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.AbstractWrapper;
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.payment.platform.common.config.JacksonConfig;
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.common.exception.GlobalExceptionHandler;
+import com.payment.platform.core.security.LoginUser;
+import com.payment.platform.module.payment.leshouying.client.LeshouyingApiException;
+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.enums.LsyOrderStatus;
+import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper;
+import com.payment.platform.module.payment.leshouying.service.LsyCancelService;
+import com.payment.platform.module.payment.leshouying.service.LsyMerchantService;
+import com.payment.platform.module.payment.leshouying.service.LsyOrderService;
+import com.payment.platform.module.payment.leshouying.service.LsyQueryService;
+import com.payment.platform.module.payment.leshouying.util.LsyBizNoPattern;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.AfterEach;
+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.InOrder;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.http.MediaType;
+import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import java.lang.reflect.Method;
+import java.time.OffsetDateTime;
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Pattern;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * 扫码收款(管理端收银台)的三个端点。
+ *
+ * <p>本类钉的五件事,按重要性排:
+ * <ol>
+ *   <li><b>归属只来自登录身份</b> —— 上下文里没有租户 ⇒ 403,且**一个上游调用都不发**;
+ *       查单/关单的 WHERE 里必须自带 {@code tenant_id}(超管主体下拦截器不追加,
+ *       这条断言是那个场景唯一的守卫)。</li>
+ *   <li><b>两张闸的先后</b> —— 商户映射闸(本地)必须排在**任何上游调用**之前。
+ *       反了不是「同样安全」:那等于拿一个还没证明属于本租户的商户号去发起上游调用。</li>
+ *   <li><b>落库装配的不变量</b> —— 这一份是 {@code LeshouyingOpenController.newOrder} 的孪生,
+ *       两份**有意不合并**(见控制器 javadoc)。不变量分头钉:这里钉的是「收银单必须长这样」。</li>
+ *   <li><b>状态端点主动查上游**且**会降级</b> —— 一次查询失败不得变成操作员屏幕上的报错;
+ *       节流与终态短路都要实测(否则「每 3 秒一次」会退化成「每次轮询一次」)。</li>
+ *   <li><b>关单的守卫只能写在 WHERE 里</b>,命中 0 行时必须报「未生效」而不是报成功。</li>
+ * </ol>
+ *
+ * <p>【为什么 mock 服务而不是起容器】本类全部走 {@code standaloneSetup} + 真
+ * {@code GlobalExceptionHandler} + 应用真实的 ObjectMapper({@code JacksonConfig})——
+ * 出口的**形态**(HTTP 码 / 业务码 / 字段的 snake_case)由它们决定,mock 掉就等于把
+ * 这三件事一起 mock 掉。形态照 {@code LeshouyingOnboardingControllerTest}。
+ */
+@ExtendWith(MockitoExtension.class)
+class LeshouyingCashierControllerTest {
+
+    /** 认证上下文里的租户 —— 「归属只许来自这里」的那个值。 */
+    private static final Long TENANT = 7L;
+
+    /** 三个端点共用的路径前缀(菜单 SQL 与前端 API 层按它对齐)。 */
+    private static final String PREFIX = "/payment/collection/cashier";
+
+    private static final String OTN = "SK1789012345678901234";
+
+    private static final String MCH_NO = "Y52211090004";
+    private static final String SHOP_NO = "S001";
+
+    /** 收款金额(**分**)—— 与 {@code pay_lsy_order.total_amount} 同一量纲。 */
+    private static final long AMOUNT = 12345L;
+
+    @Mock private LsyOrderService orderService;
+    @Mock private LsyQueryService queryService;
+    @Mock private LsyCancelService cancelService;
+    @Mock private LsyMerchantService merchantService;
+    @Mock private LsyOrderMapper orderMapper;
+
+    private MockMvc mockMvc;
+
+    @BeforeEach
+    void setUp() {
+        LeshouyingCashierController controller = new LeshouyingCashierController(
+                orderService, queryService, cancelService, merchantService, orderMapper);
+        mockMvc = MockMvcBuilders.standaloneSetup(controller)
+                .setControllerAdvice(new GlobalExceptionHandler())
+                .setMessageConverters(new MappingJackson2HttpMessageConverter(
+                        new JacksonConfig().objectMapper()))
+                .build();
+        // LambdaQueryWrapper 的元数据缓存(无 Spring 上下文时必需),同 LsyMerchantServiceTest。
+        MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
+        TableInfoHelper.initTableInfo(assistant, LsyOrderEntity.class);
+        TableInfoHelper.initTableInfo(assistant, LsyMerchantEntity.class);
+    }
+
+    @AfterEach
+    void clearContext() {
+        SecurityContextHolder.clearContext();
+    }
+
+    // ───────────────────────── 一、归属只来自登录身份 ─────────────────────────
+
+    /**
+     * 上下文里没有租户 ⇒ 403,且**连锁都不锁**。
+     *
+     * <p>同时钉住「先闸后调用」:若实现把商户查询/上游调用提到闸之前,本用例的
+     * {@code verifyNoInteractions} 立刻变红 —— 那时 403 只是「发晚了」。
+     */
+    @Test
+    void create_withoutTenantInContext_is403_andTouchesNothingElse() throws Exception {
+        login(null, true);
+
+        mockMvc.perform(post(PREFIX + "/qrcode")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"amount\":" + AMOUNT + "}"))
+                .andExpect(jsonPath("$.code").value(403));
+
+        verifyNoInteractions(merchantService, orderService, queryService, cancelService, orderMapper);
+    }
+
+    /** 商户映射闸 + 落库 + 下单,三步的**相对顺序**。 */
+    @Test
+    void create_merchantGate_runsBeforeInsert_andInsertBeforeUpstream() throws Exception {
+        login(TENANT, false);
+        when(merchantService.requireByTenant(TENANT)).thenReturn(merchantRow());
+        when(orderService.createQrOrder(any(), any())).thenReturn(null);
+
+        mockMvc.perform(post(PREFIX + "/qrcode")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"amount\":" + AMOUNT + "}"))
+                .andExpect(jsonPath("$.code").value(0));
+
+        InOrder order = inOrder(merchantService, orderMapper, orderService);
+        order.verify(merchantService).requireByTenant(TENANT);
+        order.verify(orderMapper).insert(any(LsyOrderEntity.class));
+        order.verify(orderService).createQrOrder(any(LsyOrderEntity.class), any());
+    }
+
+    /** 本租户没有商户映射 ⇒ 404,**一行都不落**、一次上游都不发。 */
+    @Test
+    void create_withoutMerchantMapping_is404_andInsertsNothing() throws Exception {
+        login(TENANT, false);
+        when(merchantService.requireByTenant(TENANT))
+                .thenThrow(new BusinessException(404, "该租户未配置商户号"));
+
+        mockMvc.perform(post(PREFIX + "/qrcode")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"amount\":" + AMOUNT + "}"))
+                .andExpect(jsonPath("$.code").value(404));
+
+        verify(orderMapper, never()).insert(any(LsyOrderEntity.class));
+        verifyNoInteractions(orderService, queryService);
+    }
+
+    // ───────────────────────── 二、金额校验 ─────────────────────────
+
+    /** 金额是操作员唯一的输入 ⇒ 它的边界必须当场拒掉,不能带着 0 或负数去上游。 */
+    @Test
+    void create_rejectsNonPositiveOrMissingAmount_withoutTouchingAnything() throws Exception {
+        login(TENANT, false);
+
+        for (String body : new String[]{"{\"amount\":0}", "{\"amount\":-1}", "{}"}) {
+            mockMvc.perform(post(PREFIX + "/qrcode")
+                            .contentType(MediaType.APPLICATION_JSON)
+                            .content(body))
+                    .andExpect(status().isBadRequest())
+                    .andExpect(jsonPath("$.code").value(400));
+        }
+
+        verifyNoInteractions(merchantService, orderService, orderMapper);
+    }
+
+    /**
+     * 请求体里出现服务端决定的键({@code tenant_id})⇒ 400 + 一句人话,
+     * **不是** HTTP 500「服务器内部错误」。
+     *
+     * <p>依据同 {@code LeshouyingOpenController.handleUnreadableBody}:本仓
+     * {@code FAIL_ON_UNKNOWN_PROPERTIES} 是 true,未知键原样冒到兜底就是 500,
+     * 而写错一个键名的调用方看到的是「我们崩了」。
+     */
+    @Test
+    void create_rejectsUnknownBodyField_asBadRequestNotServerError() throws Exception {
+        login(TENANT, false);
+
+        mockMvc.perform(post(PREFIX + "/qrcode")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"amount\":" + AMOUNT + ",\"tenant_id\":999}"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400));
+
+        verifyNoInteractions(merchantService, orderService, orderMapper);
+    }
+
+    // ───────────────────────── 三、落库装配的不变量 ─────────────────────────
+
+    /**
+     * 交给 {@code createQrOrder} 的那一行必须是这个形状。
+     *
+     * <p>★ {@code notifyUrl} 必须是 {@code null}:本单**有意不设** notify_url —— 收银端
+     * 靠状态端点主动查上游拿结果,而 {@code notify_url} 这一列同时是「我们推给下游」的地址
+     * ({@code LsyCallbackService.resolveUrl} 第一个就读订单快照)⇒ 填我们自己的回调入口
+     * 会造出一条自环(我们把回调推给自己,415 + 最多 8 次重试 + {@code notify_status=FAIL})。
+     * 理由全文在控制器 {@code newOrder} 的 javadoc。
+     */
+    @Test
+    void create_assemblesTheOrder_rowShapeAndServerDecidedFields() throws Exception {
+        login(TENANT, false);
+        when(merchantService.requireByTenant(TENANT)).thenReturn(merchantRow());
+        when(orderService.createQrOrder(any(), any())).thenReturn(null);
+
+        mockMvc.perform(post(PREFIX + "/qrcode")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"amount\":" + AMOUNT + ",\"remark\":\"3 号桌\"}"))
+                .andExpect(jsonPath("$.code").value(0));
+
+        LsyOrderEntity o = insertedOrder();
+        assertEquals(TENANT, o.getTenantId(), "归属必须来自认证上下文");
+        assertNull(o.getApiKeyId(), "收银台不走开放 API ⇒ 没有 ApiKey");
+        assertEquals(MCH_NO, o.getMchNo(), "商户号来自本租户的映射(不是调用方传的)");
+        assertEquals(SHOP_NO, o.getShopNo(), "门店号同上");
+        assertEquals("QRCODE", o.getPayScene());
+        assertNull(o.getPayType(), "聚合码不传 pay_type,上游自动识别支付宝/微信");
+        assertEquals("CREATED", o.getStatus(), "落库那一刻还没下单,状态只能是 CREATED");
+        assertEquals(AMOUNT, o.getTotalAmount());
+        assertEquals("3 号桌", o.getOrderBody(), "备注进 order_body(上游那一侧的订单标题)");
+        assertEquals(0L, o.getRefundedAmount());
+        assertEquals(0, o.getQueryCount());
+        assertEquals("PENDING", o.getNotifyStatus());
+        assertEquals(0, o.getNotifyCount());
+        assertNull(o.getNotifyUrl(), "有意不设 —— 见本方法 javadoc");
+    }
+
+    /**
+     * 单号由服务端生成:操作员不输入任何单号。
+     *
+     * <p>三条一起钉:① 满足上游对 {@code out_trade_no} 的字符集与长度上界;
+     * ② 两笔不同;③ 与 {@code pay_trace_no} 不是同一个值(两个字段各有各的用途,
+     * 抄成一份会让「按 pay_trace_no 定位回调」查到错误的一行)。
+     */
+    @Test
+    void create_generatesOutTradeNo_perRequest_distinctAndWithinUpstreamLimit() throws Exception {
+        login(TENANT, false);
+        when(merchantService.requireByTenant(TENANT)).thenReturn(merchantRow());
+        when(orderService.createQrOrder(any(), any())).thenReturn(null);
+
+        for (int i = 0; i < 2; i++) {
+            mockMvc.perform(post(PREFIX + "/qrcode")
+                            .contentType(MediaType.APPLICATION_JSON)
+                            .content("{\"amount\":" + AMOUNT + "}"))
+                    .andExpect(jsonPath("$.code").value(0));
+        }
+
+        ArgumentCaptor<LsyOrderEntity> captor = ArgumentCaptor.forClass(LsyOrderEntity.class);
+        verify(orderMapper, times(2)).insert(captor.capture());
+        LsyOrderEntity first = captor.getAllValues().get(0);
+        LsyOrderEntity second = captor.getAllValues().get(1);
+
+        for (LsyOrderEntity o : captor.getAllValues()) {
+            String otn = o.getOutTradeNo();
+            assertNotNull(otn);
+            assertTrue(Pattern.matches(LsyBizNoPattern.REGEXP, otn),
+                    "单号必须过上游那条字符集/长度规则,实测: " + otn);
+            assertTrue(otn.length() <= 64, "上限 64(与 pay_lsy_order.out_trade_no 列一致): " + otn);
+            assertNotEquals(otn, o.getPayTraceNo(), "单号与 pay_trace_no 是两个字段,不得同值");
+            assertNotNull(o.getPayTraceNo());
+            assertNotNull(o.getPayTime(), "pay_time 是上游定位交易的另一半,不得为空");
+        }
+        assertNotEquals(first.getOutTradeNo(), second.getOutTradeNo(), "两笔单不得同号");
+    }
+
+    // ───────────────────────── 四、状态轮询只回本租户 ─────────────────────────
+
+    /**
+     * 查询必须自带 {@code tenant_id} 谓词,且绑定的就是上下文里的租户。
+     *
+     * <p>为什么不能只靠 {@code TenantInnerInterceptor}:超管主体下它不追加谓词
+     * (见 {@code LeshouyingOnboardingController} 的同类断言),而「别人的单号」在这里
+     * 只是一个字符串 —— 少了这条谓词,任何人拿一个单号就能读到别家租户的收款单。
+     */
+    @Test
+    void status_queryCarriesExplicitTenantPredicate() throws Exception {
+        login(TENANT, true);
+        when(orderMapper.selectOne(any())).thenReturn(orderRow("SUCCESS"));
+
+        mockMvc.perform(get(PREFIX + "/" + OTN))
+                .andExpect(jsonPath("$.code").value(0));
+
+        AbstractWrapper<?, ?, ?> w = capturedSelectOne();
+        assertTrue(w.getSqlSegment().contains("tenant_id = #{"),
+                "查询必须自带 tenant_id 谓词(超管主体下拦截器不会追加)。实测: " + w.getSqlSegment());
+        assertTrue(w.getParamNameValuePairs().containsValue(TENANT),
+                "谓词占位符必须绑定上下文里的租户 " + TENANT + "。实测: " + w.getParamNameValuePairs());
+        assertTrue(w.getSqlSegment().contains("out_trade_no = #{"),
+                "定位键是单号。实测: " + w.getSqlSegment());
+        assertTrue(w.getParamNameValuePairs().containsValue(OTN),
+                "单号必须原样进谓词。实测: " + w.getParamNameValuePairs());
+    }
+
+    /** 别家租户的单:查不到 ⇒ 404,**且不去打扰上游**(否则连单号存不存在都能被探出来)。 */
+    @Test
+    void status_ofAnotherTenantsOrder_is404_andNeverAsksUpstream() throws Exception {
+        login(TENANT, false);
+        when(orderMapper.selectOne(any())).thenReturn(null);
+
+        mockMvc.perform(get(PREFIX + "/" + OTN))
+                .andExpect(jsonPath("$.code").value(404));
+
+        verifyNoInteractions(queryService, orderService, cancelService);
+    }
+
+    /**
+     * 订单还挂着 ⇒ 主动问一次上游,并把**查完之后**的状态回给页面。
+     *
+     * <p>★ 这条是本端点的存在理由:不设 notify_url 之后,「顾客付完马上变绿」全靠这一问。
+     * 它同时钉住「顺序」——先查上游再落库,不能反过来。
+     */
+    @Test
+    void status_pendingOrder_asksUpstream_andReportsTheSettledState() throws Exception {
+        login(TENANT, false);
+        LsyOrderEntity row = orderRow("PAYING");
+        when(orderMapper.selectOne(any())).thenReturn(row);
+        Map<String, Object> upstream = new LinkedHashMap<>();
+        upstream.put("result_code", "PAY_SUCCESS");
+        when(queryService.queryOrder(row)).thenReturn(upstream);
+        // 真 settleFromQuery 的文档化副作用就是「命中就把这一行写成终态」,桩按同一形状回。
+        when(orderService.settleFromQuery(eq(row), any())).thenAnswer(inv -> {
+            row.setStatus("SUCCESS");
+            row.setReceiptAmount(AMOUNT);
+            return "SUCCESS";
+        });
+
+        mockMvc.perform(get(PREFIX + "/" + OTN))
+                .andExpect(jsonPath("$.code").value(0))
+                .andExpect(jsonPath("$.data.status").value("SUCCESS"))
+                .andExpect(jsonPath("$.data.receipt_amount").value(AMOUNT))
+                .andExpect(jsonPath("$.data.out_trade_no").value(OTN));
+
+        InOrder order = inOrder(queryService, orderService);
+        order.verify(queryService).queryOrder(row);
+        order.verify(orderService).settleFromQuery(eq(row), any());
+        verify(orderMapper).updateById(any(LsyOrderEntity.class));
+    }
+
+    /** 上游这一跳失败 ⇒ **降级**:照常回库里的状态,页面继续轮询,不是一屏报错。 */
+    @Test
+    void status_upstreamFailure_degradesToDbStatus_notAnError() throws Exception {
+        login(TENANT, false);
+        LsyOrderEntity row = orderRow("PAYING");
+        when(orderMapper.selectOne(any())).thenReturn(row);
+        when(queryService.queryOrder(row))
+                .thenThrow(new LeshouyingApiException("NETWORK_ERROR", "网络异常", null));
+
+        mockMvc.perform(get(PREFIX + "/" + OTN))
+                .andExpect(jsonPath("$.code").value(0))
+                .andExpect(jsonPath("$.data.status").value("PAYING"));
+
+        verify(orderService, never()).settleFromQuery(any(), any());
+        verify(orderMapper, never()).updateById(any());
+    }
+
+    /** 节流:上一问还没到期 ⇒ 这一轮不问上游(否则「每 3 秒一问」会退化成「每次轮询一问」)。 */
+    @Test
+    void status_throttled_skipsUpstream() throws Exception {
+        login(TENANT, false);
+        LsyOrderEntity row = orderRow("PAYING");
+        row.setNextQueryTime(OffsetDateTime.now().plusSeconds(2));
+        when(orderMapper.selectOne(any())).thenReturn(row);
+
+        mockMvc.perform(get(PREFIX + "/" + OTN))
+                .andExpect(jsonPath("$.code").value(0))
+                .andExpect(jsonPath("$.data.status").value("PAYING"));
+
+        verifyNoInteractions(queryService, orderService);
+    }
+
+    /** 已经是终态 ⇒ 永不再问上游(这条腿是「轮询不烧上游配额」的唯一保证)。 */
+    @Test
+    void status_terminalOrder_neverAsksUpstream() throws Exception {
+        login(TENANT, false);
+        when(orderMapper.selectOne(any())).thenReturn(orderRow("CLOSED"));
+
+        mockMvc.perform(get(PREFIX + "/" + OTN))
+                .andExpect(jsonPath("$.code").value(0))
+                .andExpect(jsonPath("$.data.status").value("CLOSED"));
+
+        verifyNoInteractions(queryService, orderService);
+    }
+
+    /** 二维码剩余秒数:页面靠它倒计时,到点自己切到「已过期」。 */
+    @Test
+    void status_reportsRemainingQrSeconds() throws Exception {
+        login(TENANT, false);
+        LsyOrderEntity row = orderRow("PAYING");
+        row.setCodeUrlExpireTime(OffsetDateTime.now().plusSeconds(600));
+        when(orderMapper.selectOne(any())).thenReturn(row);
+        row.setNextQueryTime(OffsetDateTime.now().plusSeconds(30)); // 短路掉上游那一问
+
+        mockMvc.perform(get(PREFIX + "/" + OTN))
+                .andExpect(jsonPath("$.data.expire_seconds").value(org.hamcrest.Matchers.allOf(
+                        org.hamcrest.Matchers.greaterThan(590),
+                        org.hamcrest.Matchers.lessThanOrEqualTo(600))));
+    }
+
+    // ───────────────────────── 五、关单 ─────────────────────────
+
+    /** 关单成功:状态**由服务层给**,且守卫写在 WHERE 里(放行集合是「还没终态」)。 */
+    @Test
+    void close_appliesGuardedUpdate_andReportsTheNewStatus() throws Exception {
+        login(TENANT, false);
+        LsyOrderEntity row = orderRow("PAYING");
+        when(orderMapper.selectOne(any())).thenReturn(row);
+        when(cancelService.closeOrder(row)).thenReturn("CANCELED");
+        when(orderMapper.update(any(), any())).thenReturn(1);
+
+        mockMvc.perform(post(PREFIX + "/" + OTN + "/close"))
+                .andExpect(jsonPath("$.code").value(0))
+                .andExpect(jsonPath("$.data.status").value("CANCELED"));
+
+        AbstractWrapper<?, ?, ?> w = capturedUpdate();
+        assertTrue(w.getSqlSegment().contains("status IN"),
+                "守卫只能写在 WHERE 里(返回值不是守卫)。实测: " + w.getSqlSegment());
+        // 同时接受两种渲染:MP 把集合摊成多个占位符,或整个集合作一个参数。
+        // 断言的是「PENDING 那两个值都在谓词参数里」这件事本身,不是 MP 的渲染方式
+        //(钉渲染方式会让一次库升级变成一条假红)。
+        Collection<Object> values = w.getParamNameValuePairs().values();
+        boolean flat = values.containsAll(LsyOrderStatus.PENDING);
+        boolean nested = values.stream()
+                .anyMatch(v -> v instanceof Collection<?> c && c.containsAll(LsyOrderStatus.PENDING));
+        assertTrue(flat || nested,
+                "放行集合必须是「还没终态」那两个值。实测: " + w.getParamNameValuePairs());
+    }
+
+    /**
+     * 守卫命中 0 行 ⇒ **不得**报成功。
+     *
+     * <p>这一格与 {@code requireOrder} 的快照之间隔着一次完整的上游往返,期间支付成功回调
+     * 可能已经把这一行写成 SUCCESS;此时报「成功 + 已撤销」等于报一个库里不存在的状态。
+     */
+    @Test
+    void close_whenGuardMisses_reportsNotApplied_neverSuccess() throws Exception {
+        login(TENANT, false);
+        LsyOrderEntity row = orderRow("PAYING");
+        when(orderMapper.selectOne(any())).thenReturn(row);
+        when(cancelService.closeOrder(row)).thenReturn("CANCELED");
+        when(orderMapper.update(any(), any())).thenReturn(0);
+
+        mockMvc.perform(post(PREFIX + "/" + OTN + "/close"))
+                .andExpect(jsonPath("$.code").value(500))
+                .andExpect(jsonPath("$.msg").value(org.hamcrest.Matchers.containsString("未生效")));
+    }
+
+    /** 关单也必须带租户谓词:定位不到就 404,**一个上游关单都不发**。 */
+    @Test
+    void close_isTenantScoped_andNeverClosesAnotherTenantsOrder() throws Exception {
+        login(TENANT, false);
+        when(orderMapper.selectOne(any())).thenReturn(null);
+
+        mockMvc.perform(post(PREFIX + "/" + OTN + "/close"))
+                .andExpect(jsonPath("$.code").value(404));
+
+        verifyNoInteractions(cancelService);
+        AbstractWrapper<?, ?, ?> w = capturedSelectOne();
+        assertTrue(w.getSqlSegment().contains("tenant_id = #{"),
+                "关单的定位也必须自带租户谓词。实测: " + w.getSqlSegment());
+        assertTrue(w.getParamNameValuePairs().containsValue(TENANT),
+                "谓词占位符必须绑定上下文里的租户。实测: " + w.getParamNameValuePairs());
+    }
+
+    /**
+     * 上游关单失败 ⇒ 业务码 500 + 可重试文案,**不是** HTTP 500「服务器内部错误」。
+     *
+     * <p>照 {@code LeshouyingOpenController.orderClose} 的出口(同一句服务调用、同一句文案):
+     * 上游文档 §5.5 要求调用方在收到失败时**先查订单状态再说**,而这个页面每 3 秒自己就在查
+     * —— 抹成「服务器内部错误」等于把那条指引删掉。文案不与开放 API 那条腿共用「调用退款接口」
+     * 的后半句:收银台这版没有退款入口,写了等于指一条不存在的路。
+     */
+    @Test
+    void close_upstreamFailure_isRetryable_thenReflectedByTheNextPoll() throws Exception {
+        login(TENANT, false);
+        LsyOrderEntity row = orderRow("PAYING");
+        when(orderMapper.selectOne(any())).thenReturn(row);
+        when(cancelService.closeOrder(row))
+                .thenThrow(new LeshouyingApiException("HTTP_500", "关单未成功", null));
+
+        mockMvc.perform(post(PREFIX + "/" + OTN + "/close"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(500))
+                .andExpect(jsonPath("$.msg").value(org.hamcrest.Matchers.containsString("请稍后重试")));
+
+        verify(orderMapper, never()).update(any(), any());
+    }
+
+    /**
+     * 顾客已经付过了 ⇒ **不得**报「已撤销」。
+     *
+     * <p>{@code cancelService.closeOrder} 对已支付的单返回 {@code "SUCCESS"}(含义是
+     * 「转退款流程」,不是「关单成功」)。收银台没有退款入口 ⇒ 这条只能如实告诉操作员。
+     * 报成「已撤销」的后果是一笔真实收款在操作员眼里消失了。
+     */
+    @Test
+    void close_whenAlreadyPaid_neverReportsClosed() throws Exception {
+        login(TENANT, false);
+        LsyOrderEntity row = orderRow("PAYING");
+        when(orderMapper.selectOne(any())).thenReturn(row);
+        when(cancelService.closeOrder(row)).thenReturn("SUCCESS");
+
+        mockMvc.perform(post(PREFIX + "/" + OTN + "/close"))
+                .andExpect(jsonPath("$.code").value(500))
+                .andExpect(jsonPath("$.msg").value(org.hamcrest.Matchers.containsString("已经付款")));
+
+        verify(orderMapper, never()).update(any(), any());
+    }
+
+    // ───────────────────────── 六、权限串 ─────────────────────────
+
+    /**
+     * 三个端点各自的权限串**逐字**钉死。
+     *
+     * <p>为什么不能只靠「注解在不在」:权限判定是**全等匹配**({@code PermissionCheck.hasAny}
+     * 用 Set.contains),差一个字符即 403 —— 而菜单按 {@code sys_menu.permission} 授权,
+     * 两处不一致的后果是「授了但永远 403」。跨文件的那一半由
+     * {@code LeshouyingAdminWiringTest} 负责(它把注解里的串与菜单 SQL 的清单对撞)。
+     */
+    @Test
+    void everyEndpoint_carriesItsExactPermissionLiteral() {
+        Map<String, String> expected = Map.of(
+                "createQr", "module_payment:leshouying:cashier:create",
+                "status", "module_payment:leshouying:cashier:list",
+                "close", "module_payment:leshouying:cashier:close");
+
+        Set<String> seen = new java.util.LinkedHashSet<>();
+        for (Map.Entry<String, String> e : expected.entrySet()) {
+            Method m = java.util.Arrays.stream(LeshouyingCashierController.class.getDeclaredMethods())
+                    .filter(x -> x.getName().equals(e.getKey()))
+                    .findFirst()
+                    .orElseThrow(() -> new AssertionError("端点方法不见了: " + e.getKey()));
+            PreAuthorize pa = m.getAnnotation(PreAuthorize.class);
+            assertNotNull(pa, e.getKey() + " 缺 @PreAuthorize ⇒ 该端点同时失去认证与租户条件");
+            String literal = "@perm.hasAny('" + e.getValue() + "')";
+            assertEquals(literal, pa.value(), e.getKey() + " 的权限串被改动了");
+            seen.add(e.getValue());
+        }
+        assertEquals(expected.size(), seen.size(), "三条串必须互不相同(一个菜单行只装一个串)");
+    }
+
+    // ───────────────────────── helpers ─────────────────────────
+
+    private static void login(Long tenantId, boolean isSuperuser) {
+        LoginUser u = new LoginUser();
+        u.setUserId(1L);
+        u.setUsername("tester");
+        u.setStatus("0");
+        u.setTenantId(tenantId);
+        u.setIsSuperuser(isSuperuser);
+        u.setAuthorities(java.util.List.of());
+        SecurityContextHolder.getContext().setAuthentication(
+                new UsernamePasswordAuthenticationToken(u, null, java.util.List.of()));
+    }
+
+    private static LsyMerchantEntity merchantRow() {
+        LsyMerchantEntity m = new LsyMerchantEntity();
+        m.setId(1L);
+        m.setTenantId(TENANT);
+        m.setMchNo(MCH_NO);
+        m.setShopNo(SHOP_NO);
+        return m;
+    }
+
+    private static LsyOrderEntity orderRow(String status) {
+        LsyOrderEntity o = new LsyOrderEntity();
+        o.setId(1001L);
+        o.setTenantId(TENANT);
+        o.setOutTradeNo(OTN);
+        o.setMchNo(MCH_NO);
+        o.setShopNo(SHOP_NO);
+        o.setPayScene("QRCODE");
+        o.setStatus(status);
+        o.setTotalAmount(AMOUNT);
+        o.setPayTraceNo("trace-1");
+        o.setPayTime("20260923120000");
+        return o;
+    }
+
+    private LsyOrderEntity insertedOrder() {
+        ArgumentCaptor<LsyOrderEntity> captor = ArgumentCaptor.forClass(LsyOrderEntity.class);
+        verify(orderMapper).insert(captor.capture());
+        return captor.getValue();
+    }
+
+    private AbstractWrapper<?, ?, ?> capturedSelectOne() {
+        ArgumentCaptor<Wrapper<LsyOrderEntity>> captor = ArgumentCaptor.forClass(Wrapper.class);
+        verify(orderMapper).selectOne(captor.capture());
+        return (AbstractWrapper<?, ?, ?>) captor.getValue();
+    }
+
+    private AbstractWrapper<?, ?, ?> capturedUpdate() {
+        ArgumentCaptor<Wrapper<LsyOrderEntity>> captor = ArgumentCaptor.forClass(Wrapper.class);
+        verify(orderMapper).update(any(LsyOrderEntity.class), captor.capture());
+        return (AbstractWrapper<?, ?, ?>) captor.getValue();
+    }
+}