Explorar o código

feat(统一支付): 后台订单/收银台接口与租户守卫

alphaH hai 1 semana
pai
achega
40b5965562

+ 70 - 0
java/src/main/java/com/payment/platform/module/payment/unified/controller/UnifiedCashierController.java

@@ -0,0 +1,70 @@
+package com.payment.platform.module.payment.unified.controller;
+
+import cn.hutool.core.util.StrUtil;
+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.unified.dto.req.UnifiedOrderCreateCommand;
+import com.payment.platform.module.payment.unified.dto.vo.UnifiedOrderVO;
+import com.payment.platform.module.payment.unified.entity.UnifiedOrderEntity;
+import com.payment.platform.module.payment.unified.service.UnifiedCashierService;
+import com.payment.platform.module.payment.unified.service.UnifiedOrderService;
+import com.payment.platform.module.payment.unified.util.UnifiedTenantGuard;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.access.prepost.PreAuthorize;
+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.RestController;
+
+/**
+ * 后台统一支付收银台(Task 16)。
+ *
+ * <p>两个端点都在第一行取租户守卫;下单的三个请求侧默认值:
+ * {@code outTradeNo} 必填(400)、{@code expiredTime} 缺省 300 秒、
+ * {@code clientIp} 取传输层来源(不让页面自报)。
+ *
+ * <p>视图一律用开放口径 {@link UnifiedOrderVO#open}:收银台页面不需要、也不应拿到内部主键。
+ */
+@RestController
+@RequestMapping("/payment/unified/cashier")
+@RequiredArgsConstructor
+public class UnifiedCashierController {
+
+    /** 收银台单未给 {@code expiredTime} 时的默认过期间隔(秒)。 */
+    private static final int DEFAULT_EXPIRED_TIME = 300;
+
+    private final UnifiedCashierService cashierService;
+    private final UnifiedOrderService orderService;
+    private final UnifiedTenantGuard guard;
+    private final HttpServletRequest request;
+
+    @PostMapping("/order")
+    @PreAuthorize("@perm.hasAny('module_payment:unified:cashier:create')")
+    public Result<UnifiedOrderVO> create(@Valid @RequestBody UnifiedOrderCreateCommand cmd) {
+        LoginUser user = guard.require();
+
+        if (cmd == null || StrUtil.isBlank(cmd.getOutTradeNo())) {
+            throw new BusinessException(400, "outTradeNo 不能为空");
+        }
+        if (cmd.getExpiredTime() == null) {
+            cmd.setExpiredTime(DEFAULT_EXPIRED_TIME);
+        }
+        cmd.setClientIp(request.getRemoteAddr());
+
+        UnifiedOrderEntity order = cashierService.create(cmd, user.getTenantId());
+        return Result.ok(UnifiedOrderVO.open(order, orderService.refundableAmount(order)));
+    }
+
+    @GetMapping("/order/{orderNo}")
+    @PreAuthorize("@perm.hasAny('module_payment:unified:cashier:list')")
+    public Result<UnifiedOrderVO> snapshot(@PathVariable(name = "orderNo") String orderNo) {
+        LoginUser user = guard.require();
+        UnifiedOrderEntity order = cashierService.snapshot(orderNo, user.getTenantId());
+        return Result.ok(UnifiedOrderVO.open(order, orderService.refundableAmount(order)));
+    }
+}

+ 170 - 0
java/src/main/java/com/payment/platform/module/payment/unified/controller/UnifiedOrderController.java

@@ -0,0 +1,170 @@
+package com.payment.platform.module.payment.unified.controller;
+
+import cn.hutool.core.util.StrUtil;
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.common.response.PageResult;
+import com.payment.platform.common.response.Result;
+import com.payment.platform.core.security.LoginUser;
+import com.payment.platform.module.payment.unified.client.UnifiedApiException;
+import com.payment.platform.module.payment.unified.dto.query.UnifiedOrderQueryDTO;
+import com.payment.platform.module.payment.unified.dto.req.UnifiedAdminRefundDTO;
+import com.payment.platform.module.payment.unified.dto.req.UnifiedRefundCreateCommand;
+import com.payment.platform.module.payment.unified.dto.vo.UnifiedOrderVO;
+import com.payment.platform.module.payment.unified.dto.vo.UnifiedRefundVO;
+import com.payment.platform.module.payment.unified.entity.UnifiedOrderEntity;
+import com.payment.platform.module.payment.unified.entity.UnifiedRefundEntity;
+import com.payment.platform.module.payment.unified.enums.UnifiedRefundStatus;
+import com.payment.platform.module.payment.unified.enums.UnifiedSource;
+import com.payment.platform.module.payment.unified.service.UnifiedOrderService;
+import com.payment.platform.module.payment.unified.service.UnifiedRefundService;
+import com.payment.platform.module.payment.unified.util.UnifiedLogSanitizer;
+import com.payment.platform.module.payment.unified.util.UnifiedTenantGuard;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.security.access.prepost.PreAuthorize;
+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.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/**
+ * 后台统一支付订单页(Task 16)。
+ *
+ * <p>每个方法第一行取 {@link UnifiedTenantGuard#require()}:租约守卫必须在解析路径参数、
+ * 业务校验与任何服务调用之前拒绝,否则非超管无租户的身份会先触发 400/404 或被
+ * {@code TenantInnerInterceptor} 静默放行成跨租户读。
+ *
+ * <p>{@code sync} 的两条腿(订单、退款)任一失败都不得返回成功应答:上游异常统一折叠成
+ * {@code 500 上游同步失败}(不回显上游原文),业务异常原样上抛。
+ */
+@Slf4j
+@RestController
+@RequestMapping("/payment/unified/order")
+@RequiredArgsConstructor
+public class UnifiedOrderController {
+
+    /** 后台退款未填原因时的默认文案(上游要求非空)。 */
+    private static final String DEFAULT_REFUND_REASON = "商户申请退款";
+
+    private final UnifiedOrderService orderService;
+    private final UnifiedRefundService refundService;
+    private final UnifiedTenantGuard guard;
+
+    @GetMapping
+    @PreAuthorize("@perm.hasAny('module_payment:unified:order:list')")
+    public Result<PageResult<UnifiedOrderVO>> list(
+            @RequestParam(name = "page_no", defaultValue = "1") int pageNo,
+            @RequestParam(name = "page_size", defaultValue = "20") int pageSize,
+            @RequestParam(name = "order_no", required = false) String orderNo,
+            @RequestParam(name = "out_trade_no", required = false) String outTradeNo,
+            @RequestParam(name = "mch_no", required = false) String mchNo,
+            @RequestParam(name = "way_code", required = false) String wayCode,
+            @RequestParam(name = "trade_status", required = false) String tradeStatus,
+            @RequestParam(name = "start_date", required = false) String startDate,
+            @RequestParam(name = "end_date", required = false) String endDate) {
+        LoginUser user = guard.require();
+
+        UnifiedOrderQueryDTO query = new UnifiedOrderQueryDTO();
+        query.setPageNo(pageNo);
+        query.setPageSize(pageSize);
+        query.setOrderNo(orderNo);
+        query.setOutTradeNo(outTradeNo);
+        query.setMchNo(mchNo);
+        query.setWayCode(wayCode);
+        query.setTradeStatus(tradeStatus);
+        query.setStartDate(startDate);
+        query.setEndDate(endDate);
+        return Result.ok(orderService.list(query));
+    }
+
+    @GetMapping("/{id}")
+    @PreAuthorize("@perm.hasAny('module_payment:unified:order:detail')")
+    public Result<UnifiedOrderVO> detail(@PathVariable(name = "id") String id) {
+        LoginUser user = guard.require();
+        UnifiedOrderEntity order = orderService.requireById(parseId(id));
+        return Result.ok(UnifiedOrderVO.of(order, orderService.refundableAmount(order)));
+    }
+
+    /**
+     * 手动同步:先订单后退款,退款只同步非终态({@code state IN ('0','1')})。
+     *
+     * <p>终态退款再打上游只会白耗一次调用,且上游对已退款单的查询可能直接报错。
+     */
+    @PostMapping("/{id}/sync")
+    @PreAuthorize("@perm.hasAny('module_payment:unified:order:sync')")
+    public Result<Void> sync(@PathVariable(name = "id") String id) {
+        LoginUser user = guard.require();
+        UnifiedOrderEntity order = orderService.requireById(parseId(id));
+
+        try {
+            orderService.syncManually(order);
+            List<UnifiedRefundEntity> refunds = refundService.findByOrderNo(order.getOrderNo());
+            if (refunds != null) {
+                for (UnifiedRefundEntity refund : refunds) {
+                    if (UnifiedRefundStatus.PENDING.contains(refund.getState())) {
+                        refundService.syncManually(refund);
+                    }
+                }
+            }
+        } catch (UnifiedApiException e) {
+            // 上游原文可能含内部地址/签名细节,只进日志(脱敏后),不进响应
+            log.warn("[统一支付后台] 手动同步失败: order_no={}, code={}, msg={}",
+                    UnifiedLogSanitizer.flatten(order.getOrderNo()),
+                    e.getCode(), UnifiedLogSanitizer.flatten(e.getMessage()));
+            throw new BusinessException(500, "上游同步失败");
+        }
+        return Result.ok();
+    }
+
+    @PostMapping("/{id}/refund")
+    @PreAuthorize("@perm.hasAny('module_payment:unified:order:refund')")
+    public Result<UnifiedRefundVO> refund(@PathVariable(name = "id") String id,
+                                          @Valid @RequestBody UnifiedAdminRefundDTO dto) {
+        LoginUser user = guard.require();
+        UnifiedOrderEntity order = orderService.requireById(parseId(id));
+
+        // 直调方法时 @Valid 不生效;幂等键缺失必须在调服务前显式拦住
+        if (dto == null || StrUtil.isBlank(dto.getOutRefundNo())) {
+            throw new BusinessException(400, "outRefundNo 不能为空");
+        }
+
+        UnifiedRefundCreateCommand cmd = new UnifiedRefundCreateCommand();
+        cmd.setOrderNo(order.getOrderNo());
+        cmd.setRefundAmount(dto.getRefundAmount());
+        cmd.setOutRefundNo(dto.getOutRefundNo());
+        cmd.setRefundReason(StrUtil.isBlank(dto.getRefundReason())
+                ? DEFAULT_REFUND_REASON
+                : dto.getRefundReason());
+
+        UnifiedRefundEntity created = refundService.createRefund(
+                cmd, order.getTenantId(), null, UnifiedSource.ADMIN);
+        return Result.ok(UnifiedRefundVO.of(created));
+    }
+
+    @GetMapping("/{id}/refunds")
+    @PreAuthorize("@perm.hasAny('module_payment:unified:order:detail')")
+    public Result<List<UnifiedRefundVO>> refunds(@PathVariable(name = "id") String id) {
+        LoginUser user = guard.require();
+        UnifiedOrderEntity order = orderService.requireById(parseId(id));
+        List<UnifiedRefundEntity> refunds = refundService.findByOrderNo(order.getOrderNo());
+        List<UnifiedRefundVO> rows = refunds == null
+                ? List.of()
+                : refunds.stream().map(UnifiedRefundVO::of).toList();
+        return Result.ok(rows);
+    }
+
+    /** 路径参数转主键;非数字/溢出都是 400,不能漏成框架 500。 */
+    private static Long parseId(String raw) {
+        try {
+            return Long.parseLong(raw == null ? "" : raw.trim());
+        } catch (NumberFormatException e) {
+            throw new BusinessException(400, "无效的 id");
+        }
+    }
+}

+ 30 - 0
java/src/main/java/com/payment/platform/module/payment/unified/dto/req/UnifiedAdminRefundDTO.java

@@ -0,0 +1,30 @@
+package com.payment.platform.module.payment.unified.dto.req;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Positive;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+/**
+ * 后台订单详情页的退款入参。
+ *
+ * <p>订单由路径上的 {@code {id}} 定位,请求体不接受 {@code orderNo}/{@code outTradeNo} ——
+ * 否则页面可以把 A 单的退款挂到 B 单上。{@code outRefundNo} 是幂等键,必填。
+ */
+@Data
+public class UnifiedAdminRefundDTO {
+
+    @NotBlank(message = "outRefundNo 不能为空")
+    @Size(max = 64, message = "outRefundNo 不能超过 64 字符")
+    private String outRefundNo;
+
+    /** 退款金额,分。 */
+    @NotNull(message = "退款金额不能为空")
+    @Positive(message = "退款金额必须大于 0")
+    private Long refundAmount;
+
+    /** 退款原因;空时控制器补「商户申请退款」。 */
+    @Size(max = 64, message = "退款原因不能超过 64 字符")
+    private String refundReason;
+}

+ 86 - 0
java/src/main/java/com/payment/platform/module/payment/unified/service/UnifiedCashierService.java

@@ -0,0 +1,86 @@
+package com.payment.platform.module.payment.unified.service;
+
+import cn.hutool.core.util.StrUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.module.payment.unified.dto.req.UnifiedOrderCreateCommand;
+import com.payment.platform.module.payment.unified.entity.UnifiedOrderEntity;
+import com.payment.platform.module.payment.unified.enums.UnifiedOrderStatus;
+import com.payment.platform.module.payment.unified.enums.UnifiedSource;
+import com.payment.platform.module.payment.unified.mapper.UnifiedOrderMapper;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.time.OffsetDateTime;
+
+/**
+ * 收银台(免登录顾客页)的下单与轮询快照。
+ *
+ * <p>下单复用订单主流程({@link UnifiedSource#CASHIER});请求侧的默认值
+ * ({@code outTradeNo} 必填、未给过期时间默认 300 秒)在控制器与服务层各兜一次。
+ *
+ * <p>快照按 <b>{@code order_no + tenant_id}</b> 定位:少了租户条件就是跨租户读,
+ * 且 {@code order_no} 在前端可见,不能作为唯一键。
+ */
+@Service
+@RequiredArgsConstructor
+public class UnifiedCashierService {
+
+    /** 收银台单未给 {@code expiredTime} 时的默认过期间隔(秒)。 */
+    private static final int DEFAULT_EXPIRED_TIME = 300;
+
+    /** 同一次页面轮询的节流窗(秒):窗内重复请求不打上游。 */
+    private static final long SNAPSHOT_THROTTLE_SECONDS = 3;
+
+    private final UnifiedOrderMapper orderMapper;
+    private final UnifiedOrderService orderService;
+
+    /**
+     * 收银台下单。
+     *
+     * @param cmd      请求体;{@code channelExtra} 的值只在本调用栈内使用
+     * @param tenantId 当前登录身份的归属租户
+     */
+    public UnifiedOrderEntity create(UnifiedOrderCreateCommand cmd, Long tenantId) {
+        if (cmd == null || StrUtil.isBlank(cmd.getOutTradeNo())) {
+            throw new BusinessException(400, "outTradeNo 不能为空");
+        }
+        if (cmd.getExpiredTime() == null) {
+            cmd.setExpiredTime(DEFAULT_EXPIRED_TIME);
+        }
+        return orderService.create(cmd, tenantId, null, UnifiedSource.CASHIER);
+    }
+
+    /**
+     * 收银台轮询快照。
+     *
+     * <p>不再等待支付的状态(非 0/1,含 {@code SUCCESS})直接返回库内行;仍在等待时
+     * 3 秒节流窗内的重复点击同样直接返回;窗口外的过期快照先同步上游再<b>重载</b>,
+     * 保证返回的是库内最新行而不是同步前的旧对象。
+     *
+     * @throws BusinessException 404 订单不存在(含不属于当前租户)
+     */
+    public UnifiedOrderEntity snapshot(String orderNo, Long tenantId) {
+        UnifiedOrderEntity order = find(orderNo, tenantId);
+        if (order == null) {
+            throw new BusinessException(404, "订单不存在");
+        }
+        if (!UnifiedOrderStatus.PENDING.contains(order.getTradeStatus())) {
+            return order;
+        }
+        OffsetDateTime lastQueryTime = order.getLastQueryTime();
+        if (lastQueryTime != null
+                && lastQueryTime.isAfter(OffsetDateTime.now().minusSeconds(SNAPSHOT_THROTTLE_SECONDS))) {
+            return order;
+        }
+        orderService.syncFromUpstream(order);
+        UnifiedOrderEntity reloaded = find(orderNo, tenantId);
+        return reloaded == null ? order : reloaded;
+    }
+
+    private UnifiedOrderEntity find(String orderNo, Long tenantId) {
+        return orderMapper.selectOne(new LambdaQueryWrapper<UnifiedOrderEntity>()
+                .eq(UnifiedOrderEntity::getOrderNo, orderNo)
+                .eq(UnifiedOrderEntity::getTenantId, tenantId));
+    }
+}

+ 265 - 0
java/src/test/java/com/payment/platform/module/payment/unified/controller/UnifiedAdminGuardTest.java

@@ -0,0 +1,265 @@
+package com.payment.platform.module.payment.unified.controller;
+
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.common.response.PageResult;
+import com.payment.platform.core.security.LoginUser;
+import com.payment.platform.module.payment.unified.dto.req.UnifiedAdminRefundDTO;
+import com.payment.platform.module.payment.unified.dto.req.UnifiedOrderCreateCommand;
+import com.payment.platform.module.payment.unified.entity.UnifiedOrderEntity;
+import com.payment.platform.module.payment.unified.entity.UnifiedRefundEntity;
+import com.payment.platform.module.payment.unified.enums.UnifiedSource;
+import com.payment.platform.module.payment.unified.service.UnifiedCashierService;
+import com.payment.platform.module.payment.unified.service.UnifiedOrderService;
+import com.payment.platform.module.payment.unified.service.UnifiedRefundService;
+import com.payment.platform.module.payment.unified.util.UnifiedTenantGuard;
+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.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+/**
+ * 七个后台端点共用同一道租户守卫(Task 16)。
+ *
+ * <p>用**真实的** {@link UnifiedTenantGuard} + {@code SecurityContextHolder},而不是 mock
+ * guard:本类要证明的是「控制器每个方法的第一行都调了它」,mock 掉之后只能证明
+ * 「它被调了」,证明不了它在业务之前拒绝。每个拒绝用例都故意传入一条**会先失败**的
+ * 入参(非数字 id / 空 outTradeNo / 空 outRefundNo):若守卫不在第一行,断言会先收到
+ * 400 而不是 403。
+ *
+ * <p>★ {@code SecurityContextHolder} 是线程本地且跨用例存活,每个用例后必须清空。
+ */
+@ExtendWith(MockitoExtension.class)
+class UnifiedAdminGuardTest {
+
+    private static final long ORDER_ID = 11L;
+
+    @Mock private UnifiedOrderService orderService;
+    @Mock private UnifiedRefundService refundService;
+    @Mock private UnifiedCashierService cashierService;
+
+    private final UnifiedTenantGuard guard = new UnifiedTenantGuard();
+
+    private UnifiedOrderController orderController;
+    private UnifiedCashierController cashierController;
+
+    @BeforeEach
+    void setUp() {
+        orderController = new UnifiedOrderController(orderService, refundService, guard);
+        MockHttpServletRequest request =
+                new MockHttpServletRequest("POST", "/payment/unified/cashier/order");
+        request.setRemoteAddr("203.0.113.9");
+        cashierController = new UnifiedCashierController(
+                cashierService, orderService, guard, request);
+    }
+
+    @AfterEach
+    void clearSecurityContext() {
+        SecurityContextHolder.clearContext();
+    }
+
+    private static LoginUser loginUser(Long tenantId, boolean superuser) {
+        LoginUser u = new LoginUser();
+        u.setUserId(1L);
+        u.setUsername("tester");
+        u.setStatus("0");
+        u.setTenantId(tenantId);
+        u.setIsSuperuser(superuser);
+        u.setAuthorities(List.of());
+        return u;
+    }
+
+    private static void login(Long tenantId, boolean superuser) {
+        SecurityContextHolder.getContext().setAuthentication(
+                new UsernamePasswordAuthenticationToken(loginUser(tenantId, superuser), null, List.of()));
+    }
+
+    private static UnifiedAdminRefundDTO refundDto() {
+        UnifiedAdminRefundDTO dto = new UnifiedAdminRefundDTO();
+        dto.setOutRefundNo("RF1");
+        dto.setRefundAmount(300L);
+        return dto;
+    }
+
+    private static UnifiedOrderCreateCommand cashierCreateCmd() {
+        UnifiedOrderCreateCommand cmd = new UnifiedOrderCreateCommand();
+        cmd.setAmount(100L);
+        cmd.setWayCode("QR_CASHIER");
+        cmd.setSubject("测试订单");
+        cmd.setOutTradeNo("C1");
+        return cmd;
+    }
+
+    // ──────────────── 一、非超管且无租户:七个端点逐个拒绝 ────────────────
+
+    @Test
+    void orderList_rejectsNonSuperuserWithoutTenant_beforeAnyServiceCall() {
+        login(null, false);
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> orderController.list(1, 20, null, null, null, null, null, null, null));
+
+        assertEquals(403, e.getCode(), "实测消息: " + e.getMessage());
+        verifyNoInteractions(orderService, refundService, cashierService);
+    }
+
+    @Test
+    void orderDetail_rejectsNonSuperuserWithoutTenant_beforeParsingTheId() {
+        login(null, false);
+
+        // 故意给解析不过的 id:守卫若不在第一行,这里会是 400 而不是 403
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> orderController.detail("not-a-number"));
+
+        assertEquals(403, e.getCode(), "闸必须是第一行,实测消息: " + e.getMessage());
+        verifyNoInteractions(orderService, refundService, cashierService);
+    }
+
+    @Test
+    void orderSync_rejectsNonSuperuserWithoutTenant_beforeAnyServiceCall() {
+        login(null, false);
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> orderController.sync("not-a-number"));
+
+        assertEquals(403, e.getCode(), "实测消息: " + e.getMessage());
+        verifyNoInteractions(orderService, refundService, cashierService);
+    }
+
+    @Test
+    void orderRefund_rejectsNonSuperuserWithoutTenant_beforeValidation() {
+        login(null, false);
+        UnifiedAdminRefundDTO blank = new UnifiedAdminRefundDTO();
+        blank.setRefundAmount(300L);
+
+        // 空 outRefundNo + 非数字 id:两者都比 403 更晚发生
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> orderController.refund("not-a-number", blank));
+
+        assertEquals(403, e.getCode(), "实测消息: " + e.getMessage());
+        verifyNoInteractions(orderService, refundService, cashierService);
+    }
+
+    @Test
+    void orderRefunds_rejectsNonSuperuserWithoutTenant_beforeAnyServiceCall() {
+        login(null, false);
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> orderController.refunds("not-a-number"));
+
+        assertEquals(403, e.getCode(), "实测消息: " + e.getMessage());
+        verifyNoInteractions(orderService, refundService, cashierService);
+    }
+
+    @Test
+    void cashierCreate_rejectsNonSuperuserWithoutTenant_beforeValidation() {
+        login(null, false);
+        UnifiedOrderCreateCommand blank = cashierCreateCmd();
+        blank.setOutTradeNo("   ");
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> cashierController.create(blank));
+
+        assertEquals(403, e.getCode(), "实测消息: " + e.getMessage());
+        verifyNoInteractions(orderService, refundService, cashierService);
+    }
+
+    @Test
+    void cashierSnapshot_rejectsNonSuperuserWithoutTenant_beforeAnyServiceCall() {
+        login(null, false);
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> cashierController.snapshot("UO1"));
+
+        assertEquals(403, e.getCode(), "实测消息: " + e.getMessage());
+        verifyNoInteractions(orderService, refundService, cashierService);
+    }
+
+    // ──────────────── 二、正对照:放行的两类身份 ────────────────
+
+    @Test
+    void superuserWithoutTenant_isNotBlocked_byAnyOfTheSevenEndpoints() {
+        login(null, true);
+        stubHappyPath(null);
+
+        assertEquals(0, orderController.list(1, 20, null, null, null, null, null, null, null)
+                .getCode());
+        assertEquals(0, orderController.detail("11").getCode());
+        assertEquals(0, orderController.sync("11").getCode());
+        assertEquals(0, orderController.refund("11", refundDto()).getCode());
+        assertEquals(0, orderController.refunds("11").getCode());
+        assertEquals(0, cashierController.create(cashierCreateCmd()).getCode());
+        assertEquals(0, cashierController.snapshot("UO1").getCode());
+
+        verify(cashierService).create(any(), isNull());
+        verify(cashierService).snapshot("UO1", null);
+    }
+
+    @Test
+    void tenantUser_isNotBlocked_andCashierUsesOwnTenant() {
+        login(2L, false);
+        stubHappyPath(2L);
+
+        assertEquals(0, orderController.list(1, 20, null, null, null, null, null, null, null)
+                .getCode());
+        assertEquals(0, orderController.detail("11").getCode());
+        assertEquals(0, orderController.sync("11").getCode());
+        assertEquals(0, orderController.refund("11", refundDto()).getCode());
+        assertEquals(0, orderController.refunds("11").getCode());
+        assertEquals(0, cashierController.create(cashierCreateCmd()).getCode());
+        assertEquals(0, cashierController.snapshot("UO1").getCode());
+
+        verify(cashierService).create(any(), eq(2L));
+        verify(cashierService).snapshot("UO1", 2L);
+    }
+
+    // ──────────────── 工具 ────────────────
+
+    /** 放行侧的桩:七个端点各被调一次,所有桩都会被消费(strict stubs 不报未使用)。 */
+    private void stubHappyPath(Long cashierTenant) {
+        UnifiedOrderEntity order = new UnifiedOrderEntity();
+        order.setId(ORDER_ID);
+        order.setTenantId(2L);
+        order.setOrderNo("UO1");
+        order.setAmount(1000L);
+        order.setTradeStatus("0");
+
+        UnifiedRefundEntity refund = new UnifiedRefundEntity();
+        refund.setId(31L);
+        refund.setOrderId(ORDER_ID);
+        refund.setOrderNo("UO1");
+        refund.setRefundNo("UR31");
+        refund.setOutRefundNo("RF1");
+        refund.setAmount(300L);
+        refund.setState("0");
+
+        when(orderService.list(any())).thenReturn(PageResult.of(1, 20, 0, List.of()));
+        when(orderService.requireById(ORDER_ID)).thenReturn(order);
+        when(orderService.refundableAmount(order)).thenReturn(1000L);
+        when(refundService.findByOrderNo("UO1")).thenReturn(List.of());
+        when(refundService.createRefund(any(), eq(2L), isNull(), eq(UnifiedSource.ADMIN)))
+                .thenReturn(refund);
+        if (cashierTenant == null) {
+            when(cashierService.create(any(), isNull())).thenReturn(order);
+            when(cashierService.snapshot("UO1", null)).thenReturn(order);
+        } else {
+            when(cashierService.create(any(), eq(cashierTenant))).thenReturn(order);
+            when(cashierService.snapshot("UO1", cashierTenant)).thenReturn(order);
+        }
+    }
+}

+ 362 - 0
java/src/test/java/com/payment/platform/module/payment/unified/controller/UnifiedCashierControllerTest.java

@@ -0,0 +1,362 @@
+package com.payment.platform.module.payment.unified.controller;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+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.unified.dto.req.UnifiedOrderCreateCommand;
+import com.payment.platform.module.payment.unified.dto.vo.UnifiedOrderVO;
+import com.payment.platform.module.payment.unified.entity.UnifiedOrderEntity;
+import com.payment.platform.module.payment.unified.enums.UnifiedSource;
+import com.payment.platform.module.payment.unified.mapper.UnifiedOrderMapper;
+import com.payment.platform.module.payment.unified.service.UnifiedCashierService;
+import com.payment.platform.module.payment.unified.service.UnifiedOrderService;
+import com.payment.platform.module.payment.unified.util.UnifiedTenantGuard;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+import java.time.OffsetDateTime;
+import java.util.Collection;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+/**
+ * 收银台控制器 + 收银台服务(Task 16)。
+ *
+ * <p>控制器只负责请求侧的三件事:{@code outTradeNo} 非空、{@code expiredTime} 缺省 300、
+ * {@code clientIp} 取传输层来源;金额/wayCode 校验委托下单主流程。视图必须用开放口径
+ * (无内部主键)。
+ *
+ * <p>服务层(同一个文件内直测,避免为 3 个方法新增第 8 个文件)负责:
+ * <ul>
+ *   <li>按 {@code order_no + tenant_id} 定位 —— 少一个条件就是跨租户读;</li>
+ *   <li>不再需要等待支付的状态直接返回,不打上游;</li>
+ *   <li>3 秒节流窗内的重复轮询不打上游;</li>
+ *   <li>过期快照同步后**重载**,返回库内最新行而不是旧对象。</li>
+ * </ul>
+ */
+@ExtendWith(MockitoExtension.class)
+class UnifiedCashierControllerTest {
+
+    @Mock private UnifiedCashierService cashierService;
+    @Mock private UnifiedOrderService orderService;
+    @Mock private UnifiedTenantGuard guard;
+    @Mock private UnifiedOrderMapper orderMapper;
+
+    private MockHttpServletRequest request;
+    private UnifiedCashierController controller;
+    private UnifiedCashierService service;
+
+    @BeforeEach
+    void setUp() {
+        request = new MockHttpServletRequest("POST", "/payment/unified/cashier/order");
+        controller = new UnifiedCashierController(cashierService, orderService, guard, request);
+        service = new UnifiedCashierService(orderMapper, orderService);
+        // 初始化 lambda 元数据缓存,使 wrapper.getSqlSegment() 在无 Spring 上下文的单测中可用
+        MybatisConfiguration configuration = new MybatisConfiguration();
+        MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
+        TableInfoHelper.initTableInfo(assistant, UnifiedOrderEntity.class);
+    }
+
+    private static LoginUser user() {
+        LoginUser u = new LoginUser();
+        u.setUserId(1L);
+        u.setUsername("tester");
+        u.setStatus("0");
+        u.setTenantId(2L);
+        u.setIsSuperuser(false);
+        u.setAuthorities(List.of());
+        return u;
+    }
+
+    private static UnifiedOrderCreateCommand createCmd(String outTradeNo) {
+        UnifiedOrderCreateCommand cmd = new UnifiedOrderCreateCommand();
+        cmd.setAmount(100L);
+        cmd.setWayCode("QR_CASHIER");
+        cmd.setSubject("测试订单");
+        cmd.setOutTradeNo(outTradeNo);
+        return cmd;
+    }
+
+    private static UnifiedOrderEntity order(String tradeStatus) {
+        UnifiedOrderEntity entity = new UnifiedOrderEntity();
+        entity.setId(11L);
+        entity.setTenantId(2L);
+        entity.setOrderNo("UO1");
+        entity.setOutTradeNo("C1");
+        entity.setMchNo("M001");
+        entity.setWayCode("QR_CASHIER");
+        entity.setAmount(1000L);
+        entity.setRefundedAmount(0L);
+        entity.setTradeStatus(tradeStatus);
+        return entity;
+    }
+
+    private static ArgumentCaptor<LambdaQueryWrapper<UnifiedOrderEntity>> queryCaptor() {
+        return ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+    }
+
+    // ────────────────────────────── 控制器:创建 ──────────────────────────────
+
+    @Test
+    void create_defaultsExpiredTime300_andTakesClientIpFromRequest() {
+        when(guard.require()).thenReturn(user());
+        request.setRemoteAddr("203.0.113.9");
+        UnifiedOrderEntity saved = order("0");
+        when(cashierService.create(any(), eq(2L))).thenReturn(saved);
+        when(orderService.refundableAmount(saved)).thenReturn(900L);
+
+        UnifiedOrderCreateCommand cmd = createCmd("C1");
+        cmd.setExpiredTime(null);
+        Result<UnifiedOrderVO> result = controller.create(cmd);
+
+        ArgumentCaptor<UnifiedOrderCreateCommand> cap =
+                ArgumentCaptor.forClass(UnifiedOrderCreateCommand.class);
+        verify(cashierService).create(cap.capture(), eq(2L));
+        assertSame(cmd, cap.getValue(), "请求体原样交给收银台服务");
+        assertEquals(300, cap.getValue().getExpiredTime(), "收银台单未给过期时间时默认 5 分钟");
+        assertEquals("203.0.113.9", cap.getValue().getClientIp(),
+                "clientIp 必须取传输层来源,不能让页面自报");
+
+        assertNull(result.getData().getId(), "收银台视图是开放口径,不暴露内部主键");
+        assertEquals("UO1", result.getData().getOrderNo());
+        assertEquals(900L, result.getData().getRefundableAmount(),
+                "可退金额与开放 API 同口径");
+    }
+
+    @Test
+    void create_keepsExplicitExpiredTime() {
+        when(guard.require()).thenReturn(user());
+        request.setRemoteAddr("203.0.113.9");
+        UnifiedOrderEntity saved = order("0");
+        when(cashierService.create(any(), eq(2L))).thenReturn(saved);
+
+        UnifiedOrderCreateCommand cmd = createCmd("C1");
+        cmd.setExpiredTime(600);
+        controller.create(cmd);
+
+        ArgumentCaptor<UnifiedOrderCreateCommand> cap =
+                ArgumentCaptor.forClass(UnifiedOrderCreateCommand.class);
+        verify(cashierService).create(cap.capture(), eq(2L));
+        assertEquals(600, cap.getValue().getExpiredTime(), "显式给了过期时间就不覆盖");
+    }
+
+    @Test
+    void create_blankOutTradeNo_is400_beforeAnyServiceCall() {
+        when(guard.require()).thenReturn(user());
+
+        UnifiedOrderCreateCommand cmd = createCmd("   ");
+        BusinessException e = assertThrows(BusinessException.class, () -> controller.create(cmd));
+
+        assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
+        assertTrue(e.getMessage().contains("outTradeNo"), "实测消息: " + e.getMessage());
+        verifyNoInteractions(cashierService, orderService);
+    }
+
+    // ────────────────────────────── 控制器:快照 ──────────────────────────────
+
+    @Test
+    void snapshot_passesOrderNoAndTenant_andReturnsOpenView() {
+        when(guard.require()).thenReturn(user());
+        UnifiedOrderEntity row = order("2");
+        when(cashierService.snapshot("UO1", 2L)).thenReturn(row);
+        when(orderService.refundableAmount(row)).thenReturn(200L);
+
+        Result<UnifiedOrderVO> result = controller.snapshot("UO1");
+
+        verify(cashierService).snapshot("UO1", 2L);
+        assertNull(result.getData().getId(), "轮询视图不得暴露内部主键");
+        assertEquals("UO1", result.getData().getOrderNo());
+        assertEquals("2", result.getData().getTradeStatus());
+        assertEquals(200L, result.getData().getRefundableAmount());
+    }
+
+    // ────────────────────────────── 服务:创建 ──────────────────────────────
+
+    @Test
+    void serviceCreate_delegatesToOrderCreate_withCashierSource() {
+        UnifiedOrderCreateCommand cmd = createCmd("C1");
+        cmd.setExpiredTime(null);
+        UnifiedOrderEntity saved = order("0");
+        when(orderService.create(any(), eq(2L), isNull(), eq(UnifiedSource.CASHIER)))
+                .thenReturn(saved);
+
+        UnifiedOrderEntity result = service.create(cmd, 2L);
+
+        assertSame(saved, result);
+        ArgumentCaptor<UnifiedOrderCreateCommand> cap =
+                ArgumentCaptor.forClass(UnifiedOrderCreateCommand.class);
+        verify(orderService).create(cap.capture(), eq(2L), isNull(), eq(UnifiedSource.CASHIER));
+        assertEquals(300, cap.getValue().getExpiredTime(),
+                "直接调服务同样要补 300,不能只靠控制器");
+    }
+
+    @Test
+    void serviceCreate_keepsExplicitExpiredTime() {
+        UnifiedOrderCreateCommand cmd = createCmd("C1");
+        cmd.setExpiredTime(600);
+        when(orderService.create(any(), eq(2L), isNull(), eq(UnifiedSource.CASHIER)))
+                .thenReturn(order("0"));
+
+        service.create(cmd, 2L);
+
+        ArgumentCaptor<UnifiedOrderCreateCommand> cap =
+                ArgumentCaptor.forClass(UnifiedOrderCreateCommand.class);
+        verify(orderService).create(cap.capture(), eq(2L), isNull(), eq(UnifiedSource.CASHIER));
+        assertEquals(600, cap.getValue().getExpiredTime());
+    }
+
+    @Test
+    void serviceCreate_blankOutTradeNo_is400_withoutCallingOrderService() {
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.create(createCmd(" "), 2L));
+
+        assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
+        verifyNoInteractions(orderService);
+    }
+
+    // ────────────────────────────── 服务:快照 ──────────────────────────────
+
+    @Test
+    void serviceSnapshot_queriesByOrderNoAndTenant() {
+        UnifiedOrderEntity row = order("0");
+        row.setLastQueryTime(OffsetDateTime.now());
+        when(orderMapper.selectOne(any())).thenReturn(row);
+
+        UnifiedOrderEntity result = service.snapshot("UO1", 2L);
+
+        assertSame(row, result);
+        ArgumentCaptor<LambdaQueryWrapper<UnifiedOrderEntity>> cap = queryCaptor();
+        verify(orderMapper).selectOne(cap.capture());
+        String sql = cap.getValue().getSqlSegment();
+        assertTrue(sql.contains("order_no"), "缺订单号条件,实测: " + sql);
+        assertTrue(sql.contains("tenant_id"), "缺租户条件就是跨租户读,实测: " + sql);
+        Collection<Object> params = cap.getValue().getParamNameValuePairs().values();
+        assertTrue(params.contains("UO1"), "实测参数: " + params);
+        assertTrue(params.contains(2L), "实测参数: " + params);
+        verify(orderService, never()).syncFromUpstream(any());
+    }
+
+    /**
+     * 收银台只等 0/1。{@code SUCCESS(2)} 不在 `FINAL` 集合里(它还能走 2→5),
+     * 但对收银台已经是「不用再等」的状态;若按 FINAL 判,付完款的页面每 3 秒
+     * 还会打一次上游。
+     */
+    @Test
+    void serviceSnapshot_paidOrFailedOrder_returnsWithoutUpstreamCall() {
+        UnifiedOrderEntity paid = order("2");
+        paid.setLastQueryTime(OffsetDateTime.now().minusMinutes(5));
+        when(orderMapper.selectOne(any())).thenReturn(paid);
+
+        assertSame(paid, service.snapshot("UO1", 2L));
+
+        verify(orderService, never()).syncFromUpstream(any());
+    }
+
+    @Test
+    void serviceSnapshot_recentQueryWithin3s_returnsWithoutUpstreamCall() {
+        UnifiedOrderEntity recent = order("0");
+        recent.setLastQueryTime(OffsetDateTime.now().minusSeconds(1));
+        when(orderMapper.selectOne(any())).thenReturn(recent);
+
+        assertSame(recent, service.snapshot("UO1", 2L));
+
+        verify(orderService, never()).syncFromUpstream(any());
+    }
+
+    @Test
+    void serviceSnapshot_stalePending_syncsThenReloads() {
+        UnifiedOrderEntity stale = order("0");
+        stale.setLastQueryTime(OffsetDateTime.now().minusSeconds(10));
+        UnifiedOrderEntity fresh = order("2");
+        when(orderMapper.selectOne(any())).thenReturn(stale, fresh);
+
+        UnifiedOrderEntity result = service.snapshot("UO1", 2L);
+
+        verify(orderService).syncFromUpstream(stale);
+        assertSame(fresh, result, "必须返回重载后的库内行,而不是同步前的旧对象");
+        ArgumentCaptor<LambdaQueryWrapper<UnifiedOrderEntity>> cap = queryCaptor();
+        verify(orderMapper, times(2)).selectOne(cap.capture());
+        for (LambdaQueryWrapper<UnifiedOrderEntity> wrapper : cap.getAllValues()) {
+            assertTrue(wrapper.getSqlSegment().contains("tenant_id"),
+                    "重载同样必须带租户条件,实测: " + wrapper.getSqlSegment());
+            assertTrue(wrapper.getParamNameValuePairs().containsValue(2L),
+                    "重载同样必须带租户参数,实测: " + wrapper.getParamNameValuePairs().values());
+        }
+    }
+
+    @Test
+    void serviceSnapshot_unknownOrder_is404() {
+        when(orderMapper.selectOne(any())).thenReturn(null);
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.snapshot("UO-NOPE", 2L));
+
+        assertEquals(404, e.getCode(), "实测消息: " + e.getMessage());
+        verify(orderService, never()).syncFromUpstream(any());
+    }
+
+    // ────────────────────────────── 契约 ──────────────────────────────
+
+    @Test
+    void classPath_isTheFrontendContract() {
+        RequestMapping rm = UnifiedCashierController.class.getAnnotation(RequestMapping.class);
+
+        assertNotNull(rm, "类级 @RequestMapping 不得为空");
+        assertEquals("/payment/unified/cashier", rm.value()[0],
+                "前端 unified.ts 与菜单 SQL 依赖这一条路径");
+    }
+
+    @Test
+    void twoMappings_andPermissions_areLiteral() throws Exception {
+        PreAuthorize createPa = UnifiedCashierController.class
+                .getMethod("create", UnifiedOrderCreateCommand.class)
+                .getAnnotation(PreAuthorize.class);
+        assertNotNull(createPa, "创建端点必须有 @PreAuthorize");
+        assertEquals("@perm.hasAny('module_payment:unified:cashier:create')", createPa.value());
+
+        PreAuthorize snapshotPa = UnifiedCashierController.class
+                .getMethod("snapshot", String.class)
+                .getAnnotation(PreAuthorize.class);
+        assertNotNull(snapshotPa, "轮询端点必须有 @PreAuthorize");
+        assertEquals("@perm.hasAny('module_payment:unified:cashier:list')", snapshotPa.value());
+
+        PostMapping post = UnifiedCashierController.class
+                .getMethod("create", UnifiedOrderCreateCommand.class)
+                .getAnnotation(PostMapping.class);
+        assertNotNull(post, "创建是 POST");
+        assertEquals("/order", post.value()[0]);
+
+        GetMapping get = UnifiedCashierController.class
+                .getMethod("snapshot", String.class)
+                .getAnnotation(GetMapping.class);
+        assertNotNull(get, "轮询是 GET");
+        assertEquals("/order/{orderNo}", get.value()[0]);
+    }
+}

+ 440 - 0
java/src/test/java/com/payment/platform/module/payment/unified/controller/UnifiedOrderControllerTest.java

@@ -0,0 +1,440 @@
+package com.payment.platform.module.payment.unified.controller;
+
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.common.response.PageResult;
+import com.payment.platform.common.response.Result;
+import com.payment.platform.core.security.LoginUser;
+import com.payment.platform.module.payment.unified.client.UnifiedApiException;
+import com.payment.platform.module.payment.unified.dto.query.UnifiedOrderQueryDTO;
+import com.payment.platform.module.payment.unified.dto.req.UnifiedAdminRefundDTO;
+import com.payment.platform.module.payment.unified.dto.req.UnifiedRefundCreateCommand;
+import com.payment.platform.module.payment.unified.dto.vo.UnifiedOrderVO;
+import com.payment.platform.module.payment.unified.dto.vo.UnifiedRefundVO;
+import com.payment.platform.module.payment.unified.entity.UnifiedOrderEntity;
+import com.payment.platform.module.payment.unified.entity.UnifiedRefundEntity;
+import com.payment.platform.module.payment.unified.enums.UnifiedSource;
+import com.payment.platform.module.payment.unified.service.UnifiedOrderService;
+import com.payment.platform.module.payment.unified.service.UnifiedRefundService;
+import com.payment.platform.module.payment.unified.util.UnifiedTenantGuard;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Positive;
+import jakarta.validation.constraints.Size;
+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.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+import java.lang.reflect.Field;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+/**
+ * 后台订单控制器(Task 16)的四类职责:
+ * <ol>
+ *   <li><b>列表不碰可退金额</b>:{@code refundable_amount} 为 null 来自服务层,
+ *       控制器不得为每一行再查一次退款表;</li>
+ *   <li><b>详情算可退金额</b>:与开放 API 同一口径({@link UnifiedOrderService#refundableAmount});</li>
+ *   <li><b>手动同步手动腿</b>:先订单、后<b>仅非终态</b>退款;上游异常转统一 500,
+ *       业务异常原样上抛 —— 任何一条腿失败都不许返回成功应答(R39 / R47);</li>
+ *   <li><b>契约</b>:路径、方法、权限串逐字钉死(Task 17 菜单 SQL 与前端依赖)。</li>
+ * </ol>
+ *
+ * <p>直调方法,{@code @Valid} 不生效 —— 缺 {@code outRefundNo} 的 400 必须由控制器
+ * 自己显式校验,这里按真实调用路径断言。
+ */
+@ExtendWith(MockitoExtension.class)
+class UnifiedOrderControllerTest {
+
+    private static final long ORDER_ID = 11L;
+
+    @Mock private UnifiedOrderService orderService;
+    @Mock private UnifiedRefundService refundService;
+    @Mock private UnifiedTenantGuard guard;
+
+    private UnifiedOrderController controller;
+
+    @BeforeEach
+    void setUp() {
+        controller = new UnifiedOrderController(orderService, refundService, guard);
+    }
+
+    private static LoginUser user() {
+        LoginUser u = new LoginUser();
+        u.setUserId(1L);
+        u.setUsername("tester");
+        u.setStatus("0");
+        u.setTenantId(2L);
+        u.setIsSuperuser(false);
+        u.setAuthorities(List.of());
+        return u;
+    }
+
+    private static UnifiedOrderEntity order() {
+        UnifiedOrderEntity entity = new UnifiedOrderEntity();
+        entity.setId(ORDER_ID);
+        entity.setTenantId(2L);
+        entity.setOrderNo("UO1");
+        entity.setOutTradeNo("T1");
+        entity.setMchNo("M001");
+        entity.setWayCode("QR_CASHIER");
+        entity.setAmount(1000L);
+        entity.setRefundedAmount(0L);
+        entity.setTradeStatus("2");
+        return entity;
+    }
+
+    private static UnifiedRefundEntity refund(long id, String state) {
+        UnifiedRefundEntity entity = new UnifiedRefundEntity();
+        entity.setId(id);
+        entity.setTenantId(2L);
+        entity.setOrderId(ORDER_ID);
+        entity.setOrderNo("UO1");
+        entity.setRefundNo("UR" + id);
+        entity.setOutRefundNo("RF" + id);
+        entity.setAmount(100L);
+        entity.setState(state);
+        return entity;
+    }
+
+    private static UnifiedAdminRefundDTO refundDto(String outRefundNo) {
+        UnifiedAdminRefundDTO dto = new UnifiedAdminRefundDTO();
+        dto.setOutRefundNo(outRefundNo);
+        dto.setRefundAmount(300L);
+        return dto;
+    }
+
+    // ────────────────────────────── 列表 / 详情 ──────────────────────────────
+
+    @Test
+    void list_passesEveryFilter_andNeverComputesRefundable() {
+        when(guard.require()).thenReturn(user());
+        UnifiedOrderVO row = UnifiedOrderVO.of(order(), null);
+        PageResult<UnifiedOrderVO> page = PageResult.of(1, 20, 1, List.of(row));
+        when(orderService.list(any())).thenReturn(page);
+
+        Result<PageResult<UnifiedOrderVO>> result = controller.list(
+                1, 20, "UO1", "T1", "M001", "QR_CASHIER", "2", "2026-10-01", "2026-10-20");
+
+        assertSame(page, result.getData());
+        assertNull(result.getData().getList().get(0).getRefundableAmount(),
+                "列表不算可退金额(每行一条退款聚合会放大成 N+1)");
+
+        ArgumentCaptor<UnifiedOrderQueryDTO> cap = ArgumentCaptor.forClass(UnifiedOrderQueryDTO.class);
+        verify(orderService).list(cap.capture());
+        assertEquals(1, cap.getValue().getPageNo());
+        assertEquals(20, cap.getValue().getPageSize());
+        assertEquals("UO1", cap.getValue().getOrderNo());
+        assertEquals("T1", cap.getValue().getOutTradeNo());
+        assertEquals("M001", cap.getValue().getMchNo());
+        assertEquals("QR_CASHIER", cap.getValue().getWayCode());
+        assertEquals("2", cap.getValue().getTradeStatus());
+        assertEquals("2026-10-01", cap.getValue().getStartDate());
+        assertEquals("2026-10-20", cap.getValue().getEndDate());
+        verify(orderService, never()).refundableAmount(any());
+    }
+
+    @Test
+    void detail_computesRefundableAmount() {
+        when(guard.require()).thenReturn(user());
+        UnifiedOrderEntity order = order();
+        when(orderService.requireById(ORDER_ID)).thenReturn(order);
+        when(orderService.refundableAmount(order)).thenReturn(700L);
+
+        Result<UnifiedOrderVO> result = controller.detail("11");
+
+        verify(orderService).requireById(ORDER_ID);
+        assertEquals("UO1", result.getData().getOrderNo());
+        assertEquals(700L, result.getData().getRefundableAmount(),
+                "后台详情必须带与开放 API 同口径的可退金额");
+        assertEquals(2L, result.getData().getTenantId());
+    }
+
+    @Test
+    void detail_nonNumericId_is400_beforeTouchingAnyService() {
+        when(guard.require()).thenReturn(user());
+
+        BusinessException e = assertThrows(BusinessException.class, () -> controller.detail("abc"));
+
+        assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
+        assertEquals("无效的 id", e.getMessage());
+        verifyNoInteractions(orderService, refundService);
+    }
+
+    @Test
+    void detail_overflowId_is400_notFramework500() {
+        when(guard.require()).thenReturn(user());
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> controller.detail("99999999999999999999"));
+
+        assertEquals(400, e.getCode(), "超出 Long 的入参同样是无效 id,实测消息: " + e.getMessage());
+        verifyNoInteractions(orderService, refundService);
+    }
+
+    // ────────────────────────────── 手动同步 ──────────────────────────────
+
+    /**
+     * 顺序与集合都必须对:先订单、后按 {@code order_no} 退款;退款只同步 0/1,
+     * 2/3/4 已是终态再打上游只会白耗一次调用。
+     */
+    @Test
+    void sync_ordersFirst_thenOnlyPendingRefunds() {
+        when(guard.require()).thenReturn(user());
+        UnifiedOrderEntity order = order();
+        when(orderService.requireById(ORDER_ID)).thenReturn(order);
+        UnifiedRefundEntity generated = refund(1L, "0");
+        UnifiedRefundEntity refunding = refund(2L, "1");
+        UnifiedRefundEntity success = refund(3L, "2");
+        UnifiedRefundEntity failed = refund(4L, "3");
+        UnifiedRefundEntity closed = refund(5L, "4");
+        when(refundService.findByOrderNo("UO1"))
+                .thenReturn(List.of(generated, refunding, success, failed, closed));
+
+        Result<Void> result = controller.sync("11");
+
+        assertEquals(0, result.getCode());
+        InOrder inOrder = inOrder(orderService, refundService);
+        inOrder.verify(orderService).syncManually(order);
+        inOrder.verify(refundService).findByOrderNo("UO1");
+
+        verify(refundService).syncManually(generated);
+        verify(refundService).syncManually(refunding);
+        verify(refundService, times(2)).syncManually(any());
+        verify(refundService, never()).syncManually(success);
+        verify(refundService, never()).syncManually(failed);
+        verify(refundService, never()).syncManually(closed);
+        verify(orderService, never()).syncFromUpstream(any());
+    }
+
+    @Test
+    void sync_orderUpstreamFailure_isGeneric500_andHidesUpstreamDetail() {
+        when(guard.require()).thenReturn(user());
+        UnifiedOrderEntity order = order();
+        when(orderService.requireById(ORDER_ID)).thenReturn(order);
+        org.mockito.Mockito.doThrow(
+                        new UnifiedApiException(UnifiedApiException.CODE_TRANSPORT,
+                                "connect timed out: internal-host:8080"))
+                .when(orderService).syncManually(order);
+
+        BusinessException e = assertThrows(BusinessException.class, () -> controller.sync("11"));
+
+        assertEquals(500, e.getCode());
+        assertEquals("上游同步失败", e.getMessage(), "不得回显上游原始 message(R47)");
+        assertFalse(e.getMessage().contains("internal-host"), "实测消息: " + e.getMessage());
+        verify(refundService, never()).findByOrderNo(any());
+    }
+
+    @Test
+    void sync_refundUpstreamFailure_isAlsoGeneric500() {
+        when(guard.require()).thenReturn(user());
+        UnifiedOrderEntity order = order();
+        when(orderService.requireById(ORDER_ID)).thenReturn(order);
+        UnifiedRefundEntity pending = refund(1L, "0");
+        when(refundService.findByOrderNo("UO1")).thenReturn(List.of(pending));
+        org.mockito.Mockito.doThrow(new UnifiedApiException(UnifiedApiException.CODE_SIGN, "bad sign"))
+                .when(refundService).syncManually(pending);
+
+        BusinessException e = assertThrows(BusinessException.class, () -> controller.sync("11"));
+
+        assertEquals(500, e.getCode(), "退款腿失败同样不得返回成功,实测消息: " + e.getMessage());
+        assertEquals("上游同步失败", e.getMessage());
+    }
+
+    /**
+     * 业务异常(订单不存在/配置缺失/数据损坏)由服务层给出精确语义,控制器不得
+     * 把它吞成「同步成功」,也不该二次包装。
+     */
+    @Test
+    void sync_businessException_propagatesUnchanged() {
+        when(guard.require()).thenReturn(user());
+        UnifiedOrderEntity order = order();
+        when(orderService.requireById(ORDER_ID)).thenReturn(order);
+        BusinessException original = new BusinessException(404, "商户配置不存在");
+        org.mockito.Mockito.doThrow(original).when(orderService).syncManually(order);
+
+        BusinessException e = assertThrows(BusinessException.class, () -> controller.sync("11"));
+
+        assertSame(original, e, "BusinessException 必须原样上抛(R47)");
+        assertEquals(404, e.getCode());
+        assertEquals("商户配置不存在", e.getMessage());
+    }
+
+    // ────────────────────────────── 发起退款 ──────────────────────────────
+
+    @Test
+    void refund_blankOutRefundNo_is400_withoutCallingCreateRefund() {
+        when(guard.require()).thenReturn(user());
+        when(orderService.requireById(ORDER_ID)).thenReturn(order());
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> controller.refund("11", refundDto("  ")));
+
+        assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
+        assertTrue(e.getMessage().contains("outRefundNo"), "实测消息: " + e.getMessage());
+        verifyNoInteractions(refundService);
+    }
+
+    @Test
+    void refund_buildsCommandFromPathOrder_withDefaultReason_andAdminSource() {
+        when(guard.require()).thenReturn(user());
+        UnifiedOrderEntity order = order();
+        when(orderService.requireById(ORDER_ID)).thenReturn(order);
+        UnifiedRefundEntity created = refund(31L, "0");
+        when(refundService.createRefund(any(), eq(2L), isNull(), eq(UnifiedSource.ADMIN)))
+                .thenReturn(created);
+
+        UnifiedAdminRefundDTO dto = refundDto("RF1");
+        dto.setRefundReason("  ");
+        Result<UnifiedRefundVO> result = controller.refund("11", dto);
+
+        ArgumentCaptor<UnifiedRefundCreateCommand> cap =
+                ArgumentCaptor.forClass(UnifiedRefundCreateCommand.class);
+        verify(refundService).createRefund(cap.capture(), eq(2L), isNull(),
+                eq(UnifiedSource.ADMIN));
+        UnifiedRefundCreateCommand cmd = cap.getValue();
+        assertEquals("UO1", cmd.getOrderNo(), "后台退款按路径订单定位,不接受请求体里的订单号");
+        assertEquals(300L, cmd.getRefundAmount());
+        assertEquals("RF1", cmd.getOutRefundNo());
+        assertEquals("商户申请退款", cmd.getRefundReason(), "空原因必须补默认文案");
+        assertNull(cmd.getOutTradeNo());
+
+        assertEquals("UR31", result.getData().getRefundNo());
+        assertNotNull(result.getData().getId(), "后台视图带内部主键(序列化为字符串)");
+    }
+
+    @Test
+    void refund_keepsExplicitReason() {
+        when(guard.require()).thenReturn(user());
+        when(orderService.requireById(ORDER_ID)).thenReturn(order());
+        when(refundService.createRefund(any(), eq(2L), isNull(), eq(UnifiedSource.ADMIN)))
+                .thenReturn(refund(31L, "0"));
+
+        UnifiedAdminRefundDTO dto = refundDto("RF1");
+        dto.setRefundReason("客户投诉");
+        controller.refund("11", dto);
+
+        ArgumentCaptor<UnifiedRefundCreateCommand> cap =
+                ArgumentCaptor.forClass(UnifiedRefundCreateCommand.class);
+        verify(refundService).createRefund(cap.capture(), eq(2L), isNull(),
+                eq(UnifiedSource.ADMIN));
+        assertEquals("客户投诉", cap.getValue().getRefundReason());
+    }
+
+    // ────────────────────────────── 退款列表 ──────────────────────────────
+
+    @Test
+    void refunds_returnsEveryRefundOfTheOrder() {
+        when(guard.require()).thenReturn(user());
+        when(orderService.requireById(ORDER_ID)).thenReturn(order());
+        when(refundService.findByOrderNo("UO1"))
+                .thenReturn(List.of(refund(1L, "0"), refund(2L, "2")));
+
+        Result<List<UnifiedRefundVO>> result = controller.refunds("11");
+
+        assertEquals(2, result.getData().size());
+        assertEquals("UR1", result.getData().get(0).getRefundNo());
+        assertEquals("UR2", result.getData().get(1).getRefundNo());
+    }
+
+    // ────────────────────────────── 契约 ──────────────────────────────
+
+    @Test
+    void classPath_isTheFrontendContract() {
+        RequestMapping rm = UnifiedOrderController.class.getAnnotation(RequestMapping.class);
+
+        assertNotNull(rm, "类级 @RequestMapping 不得为空");
+        assertEquals("/payment/unified/order", rm.value()[0],
+                "前端 unified.ts 与菜单 SQL 依赖这一条路径");
+    }
+
+    @Test
+    void fiveMappings_andFourPermissions_areLiteral() throws Exception {
+        assertEquals("@perm.hasAny('module_payment:unified:order:list')",
+                permission("list", int.class, int.class, String.class, String.class,
+                        String.class, String.class, String.class, String.class, String.class));
+        assertEquals("@perm.hasAny('module_payment:unified:order:detail')",
+                permission("detail", String.class));
+        assertEquals("@perm.hasAny('module_payment:unified:order:sync')",
+                permission("sync", String.class));
+        assertEquals("@perm.hasAny('module_payment:unified:order:refund')",
+                permission("refund", String.class, UnifiedAdminRefundDTO.class));
+        assertEquals("@perm.hasAny('module_payment:unified:order:detail')",
+                permission("refunds", String.class), "退款列表复用详情权限串");
+
+        assertNotNull(method("list", int.class, int.class, String.class, String.class,
+                String.class, String.class, String.class, String.class, String.class)
+                .getAnnotation(GetMapping.class));
+        assertEquals("/{id}", mappingValue(method("detail", String.class)));
+        assertEquals("/{id}/sync", mappingValue(method("sync", String.class)));
+        assertEquals("/{id}/refund", mappingValue(method("refund", String.class,
+                UnifiedAdminRefundDTO.class)));
+        assertEquals("/{id}/refunds", mappingValue(method("refunds", String.class)));
+        assertEquals("POST", method("sync", String.class).getAnnotation(PostMapping.class) != null
+                ? "POST" : null);
+        assertEquals("POST", method("refund", String.class, UnifiedAdminRefundDTO.class)
+                .getAnnotation(PostMapping.class) != null ? "POST" : null);
+    }
+
+    /** HTTP 绑定路径会跑 {@code @Valid};这条只钉注解本身没被删掉。 */
+    @Test
+    void adminRefundDto_declaresValidationForHttpBinding() throws Exception {
+        Field outRefundNo = UnifiedAdminRefundDTO.class.getDeclaredField("outRefundNo");
+        assertNotNull(outRefundNo.getAnnotation(NotBlank.class));
+        assertEquals(64, outRefundNo.getAnnotation(Size.class).max());
+
+        Field refundAmount = UnifiedAdminRefundDTO.class.getDeclaredField("refundAmount");
+        assertNotNull(refundAmount.getAnnotation(NotNull.class));
+        assertNotNull(refundAmount.getAnnotation(Positive.class));
+
+        Field refundReason = UnifiedAdminRefundDTO.class.getDeclaredField("refundReason");
+        assertEquals(64, refundReason.getAnnotation(Size.class).max());
+    }
+
+    // ────────────────────────────── 工具 ──────────────────────────────
+
+    private static java.lang.reflect.Method method(String name, Class<?>... params)
+            throws NoSuchMethodException {
+        return UnifiedOrderController.class.getMethod(name, params);
+    }
+
+    private static String permission(String name, Class<?>... params) throws Exception {
+        PreAuthorize pa = method(name, params).getAnnotation(PreAuthorize.class);
+        assertNotNull(pa, name + " 必须有 @PreAuthorize");
+        return pa.value();
+    }
+
+    private static String mappingValue(java.lang.reflect.Method m) {
+        GetMapping get = m.getAnnotation(GetMapping.class);
+        if (get != null) {
+            return get.value().length == 0 ? "" : get.value()[0];
+        }
+        PostMapping post = m.getAnnotation(PostMapping.class);
+        assertNotNull(post, "端点必须有 GET/POST 映射");
+        return post.value()[0];
+    }
+}