浏览代码

feat(乐收赢): 订单页给超管加租户筛选(唯一一处对超管开放的显式租户条件)

超管的订单页此前只能看全量:租户隔离靠 TenantInnerInterceptor,而**超管正是那个
拦截器不追加谓词的身份**,于是「收敛到某一个租户」无处可做 —— 本方法是它唯一能做的地方。
`/page` 新增可选 `tenantId`,前端的租户下拉只对超管渲染。

三条取舍:

① 非超管传 tenantId 一律 403,**不是静默忽略**。拦截器对非超管会追加
   `tenant_id = 自己的`;忽略他传的值,结果是「请求 5 号租户、拿回自己租户的订单、
   HTTP 200」—— 界面会把这份列表当成 5 号租户的订单显示。这比空列表更坏:
   它是一个**看起来正确**的错误答案。同一条纪律见 LeshouyingOnboardingController
   对请求体里 tenant_id 的处理(那边也是「拒绝而不是忽略」)。

② 判据是 `isSuperuser` 而不是某个权限串 —— 必须与拦截器**用同一个开关**
   (TenantInnerInterceptor:243 / UserDetailsServiceImpl:71)。若放行了一个拦截器
   仍会追加谓词的身份,渲染出来是 `tenant_id = 5 AND tenant_id = 3`:一个恒假谓词,
   返回空列表且不报错。取不到主体时默认拒绝(写成 Boolean.FALSE.equals 会在这里放行,
   有一条用例专门钉它)。

③ 列表给超管加了一列「租户」:超管的列表是跨租户混排的,没有这一列,筛选有没有生效
   在界面上无法核对。非超管看不到这一列,与他们的视野一致。

顺带确认(不是推测,是读注解读出来的):租户下拉走 `GET /system/tenant/list`,
权限串 `module_system:tenant:query`;本页只对超管发起该请求,而超管在 PermissionCheck
直通 ⇒ **本功能不需要任何新的菜单/授权 SQL**。

两向变异(新加的四条用例,脚本已删、读数留在这里):
  M1 闸摘掉 `if (false)`                    ⇒ 红 2
  M2 极性反转(超管反被拒)                 ⇒ 红 1 + 错 1
  M3 谓词写到 null 守卫之前                 ⇒ 红 2(多出的那条是「只绑 5L」变成绑两次)
  三个变异体都断言过「锚点匹配上了」(否则那次读数是假的);
  还原后 sha256 逐字节等于基线 52ad45417ca62d0f9918e83a22d5d855924ac5c2c13d1764c8147f4e9a95b986,复跑 13/13 绿。

读数:LSY 全类 615 绿(+4,原 611);vue-tsc 289 条(与记录基线同数)且本次改动
的两个前端文件零报错。★ 浏览器渲染未验 —— 那是用户的最终验收。
alphaH 2 周之前
父节点
当前提交
bcf4680ac4

+ 5 - 0
frontend/src/api/module_payment/collection.ts

@@ -28,6 +28,11 @@ export function getLsyOrderPage(params: {
   status?: string;
   payScene?: string;
   keyword?: string;
+  /**
+   * ★ 只有超管会传(2026-09-30)。后端 `LeshouyingOrderController#applyTenantFilter`
+   * 对非超管传它一律 403 —— 不是「忽略」,理由见那边的 javadoc。
+   */
+  tenantId?: number;
 }) {
   return request({ url: "/payment/collection/order/page", method: "get", params });
 }

+ 86 - 2
frontend/src/views/module_payment/collection/order/index.vue

@@ -2,6 +2,27 @@
   <div class="app-container">
     <el-card shadow="never">
       <el-form :inline="true" :model="query">
+        <!-- ★ 2026-09-30:租户筛选**只给超管**。
+             后端 `LeshouyingOrderController#applyTenantFilter` 对非超管传这个参数一律 403,
+             所以给非超管渲染这个下拉不只是多余,是**一个点了必然报错**的控件。
+             非超管根本不需要它:他们的租户条件由 `TenantInnerInterceptor` 自动追加。 -->
+        <el-form-item v-if="isSuperAdmin" label="租户">
+          <el-select
+            v-model="query.tenantId"
+            placeholder="全部租户"
+            clearable
+            filterable
+            style="width: 200px"
+            @clear="query.tenantId = undefined"
+          >
+            <el-option
+              v-for="t in tenantList"
+              :key="t.id"
+              :label="t.name || String(t.id)"
+              :value="t.id"
+            />
+          </el-select>
+        </el-form-item>
         <el-form-item label="订单号">
           <el-input
             v-model="query.keyword"
@@ -37,6 +58,11 @@
 
     <el-card shadow="never" style="margin-top: 12px">
       <el-table v-loading="loading" :data="list" border stripe>
+        <!-- ★ 超管的列表是**跨租户混排**的(拦截器对他们不加谓词):没有这一列,
+             两笔不同租户的订单长得一模一样,筛选有没有生效也无法在界面上核对。 -->
+        <el-table-column v-if="isSuperAdmin" label="租户" width="150" show-overflow-tooltip>
+          <template #default="{ row }">{{ tenantName(row.tenant_id) }}</template>
+        </el-table-column>
         <el-table-column
           prop="out_trade_no"
           label="商户订单号"
@@ -249,7 +275,7 @@ defineOptions({
   inheritAttrs: false,
 });
 
-import { onMounted, reactive, ref } from "vue";
+import { computed, onMounted, reactive, ref } from "vue";
 import { ElMessage, ElMessageBox } from "element-plus";
 import {
   getLsyOrderPage,
@@ -258,6 +284,37 @@ import {
   getLsyOrderRefunds,
   refundLsyOrder,
 } from "@/api/module_payment/collection";
+import TenantAPI, { type TenantTable } from "@/api/module_system/tenant";
+import { useUserStore } from "@/store";
+
+const userStore = useUserStore();
+
+/**
+ * 超管判据 = `sys_user.is_superuser`,与后端 `applyTenantFilter` 读的
+ * `LoginUser#getIsSuperuser` **同一个来源**(那边还说清了为什么必须与拦截器同开关)。
+ *
+ * ★ 不要换成 `v-hasPerm`:那读的是角色/菜单权限串,而 `/page` 上挂的权限串
+ *   `module_payment:leshouying:order:list` 是**每个租户都有的** —— 用它判「是不是超管」
+ *   会把租户也放进来(他们的租户下拉只有一个值,且选了会被后端 403)。
+ *   同款判据与注释见本模块的商户页 `collection/merchant/index.vue`。
+ */
+const isSuperAdmin = computed(() => userStore.basicInfo.is_superuser === true);
+
+/**
+ * 租户下拉数据。只有超管会用到,非超管不请求。
+ * ★ 类型把 `id` 收窄成**必有**:`el-option` 的 `value` 不收 `undefined`(vue-tsc 报 TS2322),
+ *   而 `sys_tenant.id` 是主键、本来就不可能为空(见 onMounted 里的 filter)。
+ */
+const tenantList = ref<(TenantTable & { id: number })[]>([]);
+
+/** 列表那一列用:拿不到名字(还没加载完 / 加载失败)就退回显示 id,不留空白格。 */
+const tenantName = (id?: number) => {
+  if (id === undefined || id === null) {
+    return "-";
+  }
+  const t = tenantList.value.find((x) => x.id === id);
+  return t ? t.name || String(id) : String(id);
+};
 
 const STATUS_OPTIONS = [
   { label: "已建单", value: "CREATED" },
@@ -332,6 +389,12 @@ const query = reactive({
   status: "",
   payScene: "",
   keyword: "",
+  /**
+   * ★ 只有超管会填它。用 `undefined` 而不是 `""`:axios 会**丢掉** undefined 的键,
+   * 于是非超管的请求里连 `tenantId=` 都不会出现 —— 后端那条 403 闸就只拦真正的越权调用,
+   * 不会被自家界面误触。(空串也不会 403,Spring 把它转成 null;但「不发」比「发了再转」少一层。)
+   */
+  tenantId: undefined as number | undefined,
 });
 const list = ref<any[]>([]);
 const total = ref(0);
@@ -417,6 +480,7 @@ function handleReset() {
   query.keyword = "";
   query.status = "";
   query.payScene = "";
+  query.tenantId = undefined;
   query.pageNo = 1;
   loadData();
 }
@@ -509,5 +573,25 @@ async function handleRefund(row: any) {
   }
 }
 
-onMounted(loadData);
+onMounted(async () => {
+  loadData();
+  if (!isSuperAdmin.value) {
+    return;
+  }
+  try {
+    const res: any = await TenantAPI.listTenant({ page_no: 1, page_size: 100 });
+    // ★ 两个键都读:后端 `PageResult` 的字段名是 `list`,而全局 TS 声明写的是 `items`
+    //   (同款实测记在 `collection/merchant/index.vue`)—— 只读 `items` 会**静默**拿到
+    //   空数组,表现为超管的租户下拉是空的、筛选根本用不了。
+    const page = res.data.data as { list?: TenantTable[]; items?: TenantTable[] } | undefined;
+    // ★ filter 掉没有 id 的行:租户 id 是主键,缺它只可能是响应被截断/字段被改名的**已经坏了**
+    //   的数据,而这种行进下拉就是个「选中了却什么也筛不出来」的选项。
+    tenantList.value = (page?.list || page?.items || []).filter(
+      (t): t is TenantTable & { id: number } => typeof t.id === "number"
+    );
+  } catch {
+    // 拉不到租户列表不阻塞订单查询:那一列退化成显示 tenant_id 数字,下拉为空。
+    // ★ 失败是**响亮**的(下拉里一个选项都没有),不是静默筛错租户 —— 这个方向可接受。
+  }
+});
 </script>

+ 42 - 2
java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingOrderController.java

@@ -7,6 +7,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.payment.platform.common.exception.BusinessException;
 import com.payment.platform.common.response.PageResult;
 import com.payment.platform.common.response.Result;
+import com.payment.platform.core.security.LoginUser;
 import com.payment.platform.module.payment.leshouying.dto.LsyOrderRefundDTO;
 import com.payment.platform.module.payment.leshouying.entity.LsyNotifyLogEntity;
 import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
@@ -19,6 +20,8 @@ import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 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.*;
 
 import java.util.ArrayList;
@@ -29,7 +32,12 @@ import java.util.List;
  *
  * <p>租户隔离**靠 TenantInnerInterceptor**(已认证 ⇒ 自动追加 tenant_id),
  * 故此处查询刻意**不写**显式租户条件 —— 与 OpenAPI 侧(无认证、必须自己拼)正好相反。
- * 前提是「本控制器所有端点都有认证」,它由每个映射方法上的 {@code @PreAuthorize} 提供,
+ *
+ * <p>★ 2026-09-30 起有一处例外:{@link #page} 的 {@code tenantId} 参数,且它**只对超管生效**。
+ * 超管恰恰就是那个拦截器**不追加**谓词的身份,所以「收敛到某一个租户」只能由本方法自己提供,
+ * 否则超管的订单页只能看全量。非超管传它一律 403 —— 理由见 {@link #applyTenantFilter}。
+ *
+ * <p>前提是「本控制器所有端点都有认证」,它由每个映射方法上的 {@code @PreAuthorize} 提供,
  * 并由
  * {@code LeshouyingAdminWiringTest#everyAdminMappedEndpoint_isAuthenticated_offTheApiKeySurface_andOnTheMenuContract}
  * **逐方法反射校验**(少一行注解即红灯)。
@@ -66,9 +74,12 @@ public class LeshouyingOrderController {
             @RequestParam(defaultValue = "20") int pageSize,
             @RequestParam(required = false) String status,
             @RequestParam(required = false) String payScene,
-            @RequestParam(required = false) String keyword) {
+            @RequestParam(required = false) String keyword,
+            // ★ 2026-09-30:仅超管可用,判据与理由见 applyTenantFilter
+            @RequestParam(required = false) Long tenantId) {
         Page<LsyOrderEntity> page = new Page<>(pageNo, pageSize);
         LambdaQueryWrapper<LsyOrderEntity> w = new LambdaQueryWrapper<>();
+        applyTenantFilter(w, tenantId);
         if (StrUtil.isNotBlank(status)) {
             w.eq(LsyOrderEntity::getStatus, status);
         }
@@ -85,6 +96,35 @@ public class LeshouyingOrderController {
         return Result.ok(PageResult.of(pageNo, pageSize, r.getTotal(), r.getRecords()));
     }
 
+    /**
+     * 超管在订单页按租户筛选。{@code tenantId} 为空 ⇒ **什么都不做**,隔离照旧交给拦截器。
+     *
+     * <p>【为什么非超管传了就 403,而不是静默忽略】{@code TenantInnerInterceptor} 对**非超管**
+     * 会自动追加 {@code tenant_id = 自己的}。忽略他传进来的值,结果是
+     * 「请求 5 号租户、拿回自己租户的订单、HTTP 200」—— 界面会把这份列表当成 5 号租户的订单显示。
+     * 这比返回空列表更坏:它是一个**看起来正确的错误答案**。
+     * 同一条纪律见 {@code LeshouyingOnboardingController} 对请求体里 {@code tenant_id} 的处理
+     * (那边也是「拒绝而不是忽略」)。
+     *
+     * <p>【为什么判据是 {@code isSuperuser} 而不是某个权限串】它必须与拦截器**用同一个开关**。
+     * 若本方法放行了一个拦截器仍会追加谓词的身份,渲染出来是
+     * {@code tenant_id = 5 AND tenant_id = 3} —— 一个恒假谓词,返回空列表且不报错。
+     * 拦截器读的是 {@code LoginUser#getIsSuperuser}({@code TenantInnerInterceptor:243}
+     * 与 {@code UserDetailsServiceImpl:71}),这里逐字照抄;取不到主体时**默认拒绝**,
+     * 不是默认放行。
+     */
+    private void applyTenantFilter(LambdaQueryWrapper<LsyOrderEntity> w, Long tenantId) {
+        if (tenantId == null) {
+            return;
+        }
+        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
+        LoginUser user = (auth != null && auth.getPrincipal() instanceof LoginUser u) ? u : null;
+        if (user == null || !Boolean.TRUE.equals(user.getIsSuperuser())) {
+            throw new BusinessException(403, "仅超级管理员可按租户筛选订单");
+        }
+        w.eq(LsyOrderEntity::getTenantId, tenantId);
+    }
+
     /** 详情含回调审计轨迹,便于对账排查 */
     @PreAuthorize("@perm.hasAny('module_payment:leshouying:order:list')")
     @GetMapping("/{outTradeNo}")

+ 100 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingOrderControllerTest.java

@@ -3,7 +3,9 @@ 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.metadata.TableInfoHelper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.core.security.LoginUser;
 import com.payment.platform.module.payment.leshouying.entity.LsyNotifyLogEntity;
 import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
 import com.payment.platform.module.payment.leshouying.dto.LsyOrderRefundDTO;
@@ -15,6 +17,9 @@ import com.payment.platform.module.payment.leshouying.service.LsyRefundService;
 import org.apache.ibatis.builder.MapperBuilderAssistant;
 import org.mockito.ArgumentMatchers;
 import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+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;
@@ -69,8 +74,41 @@ class LeshouyingOrderControllerTest {
         TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), LsyNotifyLogEntity.class);
     }
 
+    /**
+     * ★ 必须清:{@code SecurityContextHolder} 是**线程本地且跨用例存活**的,
+     * 而 Surefire 默认在同一线程里跑完整个类。不清的话,某条用例的
+     * {@code login(null, true)} 会泄漏给后面每一条「不该放行」的用例,
+     * 让它们集体变绿 —— 这正是那种「全绿但什么也没验」的形态。
+     */
+    @AfterEach
+    void clearSecurityContext() {
+        SecurityContextHolder.clearContext();
+    }
+
     // ────────────────────────────── 工具 ──────────────────────────────
 
+    /** 把当前主体放进 SecurityContext(照 {@code LeshouyingOnboardingControllerTest#login})。 */
+    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(List.of());
+        SecurityContextHolder.getContext().setAuthentication(
+                new UsernamePasswordAuthenticationToken(u, null, List.of()));
+    }
+
+    /** 取出 {@code /page} 实际用掉的 wrapper。 */
+    @SuppressWarnings("unchecked")
+    private AbstractWrapper<?, ?, ?> capturedPageQuery() {
+        ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.Wrapper<LsyOrderEntity>> c =
+                ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.Wrapper.class);
+        verify(orderMapper).selectPage(any(), c.capture());
+        return (AbstractWrapper<?, ?, ?>) c.getValue();
+    }
+
     private LsyOrderEntity order() {
         LsyOrderEntity o = new LsyOrderEntity();
         o.setId(1L);
@@ -289,4 +327,66 @@ class LeshouyingOrderControllerTest {
                 "退款必须有自己的权限串。复用 order:list 的后果不是「少一层保护」而是**放大权限**:"
                         + "任何能查一笔单的人都能把这笔钱退出去");
     }
+
+    // ───────────────────── 超管按租户筛选(2026-09-30) ─────────────────────
+
+    /**
+     * 这四条是一组:①②是**负/正对照**(同一个量具读同一个 wrapper),③④是**闸**。
+     *
+     * <p>【为什么非超管传 tenantId 要 403,而不是静默忽略】
+     * {@code TenantInnerInterceptor} 对非超管会自动追加 {@code tenant_id = 自己的}。
+     * 若这里忽略他传的值,调用方请求的是 5 号租户、拿到的却是**自己租户的订单**,
+     * 而 HTTP 是 200 —— 界面会把这份列表当成「5 号租户的订单」显示。
+     * 这比返回空列表更坏:它是**一个看起来正确的错误答案**。
+     * 同一条纪律见 {@code LeshouyingOnboardingController} 对请求体里 {@code tenant_id} 的处理。
+     */
+    @Test
+    void page_不传租户_不加租户谓词_由拦截器兜底() {
+        when(orderMapper.selectPage(any(), any())).thenReturn(new Page<>(1, 20));
+
+        controller.page(1, 20, null, null, null, null);
+
+        String seg = capturedPageQuery().getSqlSegment();
+        assertFalse(seg.contains("tenant_id"),
+                "不传 tenantId 时本方法**不得**自己拼租户条件:非超管的隔离由 "
+                        + "TenantInnerInterceptor 追加,两处都写会让谓词互相打架。实测: " + seg);
+    }
+
+    @Test
+    void page_超管传租户_按该租户过滤() {
+        login(null, true); // 超管自己可以没有归属租户 —— 这恰是它能跨租户看的原因
+        when(orderMapper.selectPage(any(), any())).thenReturn(new Page<>(1, 20));
+
+        controller.page(1, 20, null, null, null, 5L);
+
+        AbstractWrapper<?, ?, ?> w = capturedPageQuery();
+        assertTrue(w.getSqlSegment().contains("tenant_id"),
+                "超管传了 tenantId 就必须落到 WHERE 上;实测: " + w.getSqlSegment());
+        assertEquals(List.of(5L), boundValues(w),
+                "筛的必须是**那一个**租户,且是等值(不是 like)");
+    }
+
+    @Test
+    void page_非超管传租户_403_且一条库都不查() {
+        login(3L, false);
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> controller.page(1, 20, null, null, null, 5L));
+
+        assertEquals(403, e.getCode(),
+                "非超管按租户筛选 = 越权探测;忽略它会给出一份**看起来正确**的错列表");
+        verify(orderMapper, never()).selectPage(any(), any());
+    }
+
+    @Test
+    void page_无登录主体时传租户_也按非超管拒() {
+        SecurityContextHolder.clearContext();
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> controller.page(1, 20, null, null, null, 5L));
+
+        assertEquals(403, e.getCode(),
+                "取不到主体时**不能默认放行**(写成 Boolean.FALSE.equals 就会在这里放行)");
+        verify(orderMapper, never()).selectPage(any(), any());
+    }
 }