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

fix: address user search review feedback

Amp-Thread-ID: https://ampcode.com/threads/T-019d1679-0b36-75ba-803e-7c414bac6d0f
Co-authored-by: Amp <[email protected]>
ding113 пре 3 недеља
родитељ
комит
1211fca2cc

+ 28 - 20
src/actions/users.ts

@@ -70,9 +70,14 @@ const USER_LIST_DEFAULT_LIMIT = 50;
 const USER_LIST_MAX_LIMIT = 200;
 
 function normalizeLegacySearchTerm(params?: GetUsersBatchParams): string | undefined {
-  const candidate = params?.searchTerm ?? params?.query ?? params?.keyword;
-  const trimmed = candidate?.trim();
-  return trimmed ? trimmed : undefined;
+  for (const candidate of [params?.searchTerm, params?.query, params?.keyword]) {
+    const trimmed = candidate?.trim();
+    if (trimmed) {
+      return trimmed;
+    }
+  }
+
+  return undefined;
 }
 
 function normalizeUserListParams(params?: GetUsersBatchParams): GetUsersBatchParams {
@@ -112,34 +117,37 @@ function normalizeUserListParams(params?: GetUsersBatchParams): GetUsersBatchPar
   };
 }
 
-function hasExplicitPaginationParams(params?: GetUsersBatchParams): boolean {
+function hasExplicitPaginationParams(
+  params?: GetUsersBatchParams,
+  normalizedParams = normalizeUserListParams(params)
+): boolean {
   return Boolean(
-    params?.cursor ||
-      params?.limit !== undefined ||
+    normalizedParams.cursor !== undefined ||
+      normalizedParams.limit !== undefined ||
       params?.page !== undefined ||
       params?.offset !== undefined
   );
 }
 
-function hasSearchOrFilterOverrides(params?: GetUsersBatchParams): boolean {
-  const normalized = normalizeUserListParams(params);
+function hasSearchOrFilterOverrides(normalizedParams: GetUsersBatchParams): boolean {
   return Boolean(
-    normalized.searchTerm ||
-      (normalized.tagFilters?.length ?? 0) > 0 ||
-      (normalized.keyGroupFilters?.length ?? 0) > 0 ||
-      normalized.statusFilter ||
-      normalized.sortBy ||
-      normalized.sortOrder
+    normalizedParams.searchTerm ||
+      (normalizedParams.tagFilters?.length ?? 0) > 0 ||
+      (normalizedParams.keyGroupFilters?.length ?? 0) > 0 ||
+      normalizedParams.statusFilter ||
+      normalizedParams.sortBy ||
+      normalizedParams.sortOrder
   );
 }
 
 async function loadAllUsersForAdmin(baseParams?: GetUsersBatchParams): Promise<User[]> {
   const users: User[] = [];
-  let cursor = normalizeUserListParams(baseParams).cursor;
+  const normalizedBaseParams = normalizeUserListParams(baseParams);
+  let cursor = normalizedBaseParams.cursor;
 
   while (true) {
     const page = await findUserListBatch({
-      ...normalizeUserListParams(baseParams),
+      ...normalizedBaseParams,
       cursor,
       limit: USER_LIST_MAX_LIMIT,
     });
@@ -308,15 +316,15 @@ export async function getUsers(params?: GetUsersBatchParams): Promise<UserDispla
 
     // Treat any non-admin role as non-admin for safety.
     const isAdmin = session.user.role === "admin";
+    const normalizedParams = normalizeUserListParams(params);
 
     // 非 admin 用户只能看到自己的数据(从 DB 获取完整用户信息)
     let users: User[] = [];
     if (isAdmin) {
-      if (hasExplicitPaginationParams(params)) {
-        const normalizedParams = normalizeUserListParams(params);
+      if (hasExplicitPaginationParams(params, normalizedParams)) {
         users = (await findUserListBatch(normalizedParams)).users;
-      } else if (hasSearchOrFilterOverrides(params)) {
-        users = await loadAllUsersForAdmin(params);
+      } else if (hasSearchOrFilterOverrides(normalizedParams)) {
+        users = await loadAllUsersForAdmin(normalizedParams);
       } else {
         users = await loadAllUsersForAdmin();
       }

+ 23 - 5
src/app/[locale]/dashboard/_components/rate-limit-top-users.tsx

@@ -3,7 +3,7 @@
 import { ArrowUpDown } from "lucide-react";
 import { useLocale, useTranslations } from "next-intl";
 import * as React from "react";
-import { getUsers } from "@/actions/users";
+import { searchUsers } from "@/actions/users";
 import { Button } from "@/components/ui/button";
 import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
 import {
@@ -36,10 +36,28 @@ export function RateLimitTopUsers({ data }: RateLimitTopUsersProps) {
 
   // 加载用户详情
   React.useEffect(() => {
-    getUsers().then((userList) => {
-      setUsers(userList);
-      setLoading(false);
-    });
+    let cancelled = false;
+
+    void searchUsers()
+      .then((result) => {
+        if (!cancelled) {
+          setUsers(result.ok ? result.data : []);
+        }
+      })
+      .catch(() => {
+        if (!cancelled) {
+          setUsers([]);
+        }
+      })
+      .finally(() => {
+        if (!cancelled) {
+          setLoading(false);
+        }
+      });
+
+    return () => {
+      cancelled = true;
+    };
   }, []);
 
   // 组合数据:用户信息 + 事件计数

+ 23 - 5
src/app/[locale]/dashboard/rate-limits/_components/rate-limit-filters.tsx

@@ -5,7 +5,7 @@ import { Calendar, X } from "lucide-react";
 import { useTranslations } from "next-intl";
 import * as React from "react";
 import { getProviders } from "@/actions/providers";
-import { getUsers } from "@/actions/users";
+import { searchUsers } from "@/actions/users";
 import { Button } from "@/components/ui/button";
 import { Input } from "@/components/ui/input";
 import { Label } from "@/components/ui/label";
@@ -61,10 +61,28 @@ export function RateLimitFilters({
 
   // 加载用户列表
   React.useEffect(() => {
-    getUsers().then((userList) => {
-      setUsers(userList);
-      setLoadingUsers(false);
-    });
+    let cancelled = false;
+
+    void searchUsers()
+      .then((result) => {
+        if (!cancelled) {
+          setUsers(result.ok ? result.data : []);
+        }
+      })
+      .catch(() => {
+        if (!cancelled) {
+          setUsers([]);
+        }
+      })
+      .finally(() => {
+        if (!cancelled) {
+          setLoadingUsers(false);
+        }
+      });
+
+    return () => {
+      cancelled = true;
+    };
   }, []);
 
   // 加载供应商列表

+ 11 - 5
src/app/api/actions/[...route]/route.ts

@@ -125,11 +125,11 @@ const userListItemSchema = z.object({
 const getUsersBatchRequestSchema = z
   .object({
     cursor: z.string().optional(),
-    limit: z.number().int().positive().max(200).optional(),
+    limit: z.number().int().positive().optional(),
     searchTerm: z.string().optional(),
     query: z.string().optional(),
     keyword: z.string().optional(),
-    page: z.number().int().min(1).optional(),
+    page: z.number().int().min(0).optional(),
     offset: z.number().int().min(0).optional(),
     tagFilters: z.array(z.string()).optional(),
     keyGroupFilters: z.array(z.string()).optional(),
@@ -167,11 +167,11 @@ const { route: getUsersRoute, handler: getUsersHandler } = createActionRoute(
     requestSchema: z
       .object({
         cursor: z.string().optional().describe("游标,兼容旧 offset 游标"),
-        limit: z.number().int().positive().max(200).optional().describe("返回条数上限"),
+        limit: z.number().int().positive().optional().describe("返回条数上限"),
         searchTerm: z.string().optional().describe("搜索用户名/备注/标签/密钥"),
         query: z.string().optional().describe("旧版搜索参数别名"),
         keyword: z.string().optional().describe("旧版搜索参数别名"),
-        page: z.number().int().min(1).optional().describe("旧版页码,从 1 开始"),
+        page: z.number().int().min(0).optional().describe("旧版页码,从 0 或 1 开始"),
         offset: z.number().int().min(0).optional().describe("旧版偏移量"),
       })
       .passthrough()
@@ -224,7 +224,13 @@ const { route: searchUsersRoute, handler: searchUsersHandler } = createActionRou
     summary: "搜索用户",
     tags: ["用户管理"],
     requiredRole: "admin",
-    argsMapper: (body) => [body.searchTerm ?? body.query ?? body.keyword],
+    argsMapper: (body) => {
+      const searchTerm = [body.searchTerm, body.query, body.keyword]
+        .map((value: string | undefined) => value?.trim())
+        .find((value): value is string => Boolean(value));
+
+      return [searchTerm];
+    },
   }
 );
 app.openapi(searchUsersRoute, searchUsersHandler);

+ 34 - 0
tests/api/api-openapi-spec.test.ts

@@ -46,6 +46,29 @@ type OpenAPIDocument = {
   };
 };
 
+type JsonSchemaProperty = {
+  minimum?: number;
+  maximum?: number;
+  properties?: Record<string, JsonSchemaProperty>;
+};
+
+function getJsonRequestSchema(
+  openApiDoc: OpenAPIDocument,
+  path: string
+): JsonSchemaProperty | undefined {
+  const requestBody = openApiDoc.paths[path]?.post?.requestBody as
+    | {
+        content?: {
+          "application/json"?: {
+            schema?: JsonSchemaProperty;
+          };
+        };
+      }
+    | undefined;
+
+  return requestBody?.content?.["application/json"]?.schema;
+}
+
 describe("OpenAPI 规范验证", () => {
   let openApiDoc: OpenAPIDocument;
 
@@ -218,4 +241,15 @@ describe("OpenAPI 规范验证", () => {
     // 但不应该太多(允许 35% 以内)
     expect(violations.length).toBeLessThan(totalPaths * 0.35);
   });
+
+  test("users 列表请求 schema 应与兼容参数归一化保持一致", () => {
+    for (const path of ["/api/actions/users/getUsers", "/api/actions/users/getUsersBatch"]) {
+      const schema = getJsonRequestSchema(openApiDoc, path);
+      const pageSchema = schema?.properties?.page;
+      const limitSchema = schema?.properties?.limit;
+
+      expect(pageSchema?.minimum).toBe(0);
+      expect(limitSchema?.maximum).toBeUndefined();
+    }
+  });
 });

+ 71 - 0
tests/api/users-search-users-compat.test.ts

@@ -0,0 +1,71 @@
+import { beforeEach, describe, expect, test, vi } from "vitest";
+
+const searchUsersMock = vi.fn();
+const validateAuthTokenMock = vi.fn();
+const runWithAuthSessionMock = vi.fn();
+
+vi.mock("@/actions/users", () => ({
+  getUsers: vi.fn(),
+  getUsersBatch: vi.fn(),
+  searchUsers: searchUsersMock,
+  addUser: vi.fn(),
+  editUser: vi.fn(),
+  removeUser: vi.fn(),
+  getUserLimitUsage: vi.fn(),
+}));
+
+vi.mock("@/lib/auth", async (importOriginal) => {
+  const actual = await importOriginal<typeof import("@/lib/auth")>();
+  return {
+    ...actual,
+    AUTH_COOKIE_NAME: "auth-token",
+    validateAuthToken: validateAuthTokenMock,
+    runWithAuthSession: runWithAuthSessionMock,
+  };
+});
+
+describe("users searchUsers route compatibility", () => {
+  beforeEach(() => {
+    vi.resetModules();
+    searchUsersMock.mockReset();
+    validateAuthTokenMock.mockReset();
+    runWithAuthSessionMock.mockReset();
+
+    validateAuthTokenMock.mockResolvedValue({
+      user: { id: 1, role: "admin" },
+      key: { canLoginWebUi: true },
+    });
+    runWithAuthSessionMock.mockImplementation(async (_session, callback) => callback());
+    searchUsersMock.mockResolvedValue({
+      ok: true,
+      data: [{ id: 1, name: "Alice" }],
+    });
+  });
+
+  test("falls back to trimmed query when searchTerm is blank", async () => {
+    const { POST } = await import("@/app/api/actions/[...route]/route");
+
+    const response = await POST(
+      new Request("http://localhost/api/actions/users/searchUsers", {
+        method: "POST",
+        headers: {
+          "content-type": "application/json",
+          authorization: "Bearer test-token",
+          cookie: "auth-token=test-token",
+        },
+        body: JSON.stringify({
+          searchTerm: "   ",
+          query: "  alice  ",
+          keyword: "bob",
+        }),
+      })
+    );
+
+    expect(response.status).toBe(200);
+    expect(searchUsersMock).toHaveBeenCalledWith("alice");
+    await expect(response.json()).resolves.toEqual({
+      ok: true,
+      data: [{ id: 1, name: "Alice" }],
+    });
+  });
+});

+ 72 - 0
tests/unit/users-action-get-users-compat.test.ts

@@ -155,6 +155,32 @@ describe("getUsers compatibility", () => {
     expect(result[0]?.name).toBe("xiaolunanbei");
   });
 
+  test("falls back to legacy query when searchTerm is blank", async () => {
+    findUserListBatchMock.mockResolvedValueOnce({
+      users: [makeUser(77, "legacy-query-hit")],
+      nextCursor: null,
+      hasMore: false,
+    });
+
+    const { getUsersBatch } = await import("@/actions/users");
+
+    await getUsersBatch({
+      searchTerm: "   ",
+      query: "  alice  ",
+    });
+
+    expect(findUserListBatchMock).toHaveBeenCalledWith({
+      cursor: undefined,
+      limit: undefined,
+      searchTerm: "alice",
+      tagFilters: undefined,
+      keyGroupFilters: undefined,
+      statusFilter: undefined,
+      sortBy: undefined,
+      sortOrder: undefined,
+    });
+  });
+
   test("search-only getUsers requests keep paging until all matches are returned", async () => {
     findUserListBatchMock
       .mockResolvedValueOnce({
@@ -196,6 +222,52 @@ describe("getUsers compatibility", () => {
     expect(result.at(-1)?.name).toBe("match-201");
   });
 
+  test("treats whitespace cursor as missing pagination and keeps loading matches", async () => {
+    findUserListBatchMock
+      .mockResolvedValueOnce({
+        users: Array.from({ length: 200 }, (_, index) =>
+          makeUser(index + 1, `cursor-match-${index + 1}`)
+        ),
+        nextCursor: '{"v":"2026-03-01T00:00:00.000Z","id":200}',
+        hasMore: true,
+      })
+      .mockResolvedValueOnce({
+        users: [makeUser(201, "cursor-match-201")],
+        nextCursor: null,
+        hasMore: false,
+      });
+
+    const { getUsers } = await import("@/actions/users");
+
+    const result = await getUsers({
+      cursor: "   ",
+      query: "cursor-match",
+    });
+
+    expect(findUserListBatchMock).toHaveBeenNthCalledWith(1, {
+      cursor: undefined,
+      limit: 200,
+      searchTerm: "cursor-match",
+      tagFilters: undefined,
+      keyGroupFilters: undefined,
+      statusFilter: undefined,
+      sortBy: undefined,
+      sortOrder: undefined,
+    });
+    expect(findUserListBatchMock).toHaveBeenNthCalledWith(2, {
+      cursor: '{"v":"2026-03-01T00:00:00.000Z","id":200}',
+      limit: 200,
+      searchTerm: "cursor-match",
+      tagFilters: undefined,
+      keyGroupFilters: undefined,
+      statusFilter: undefined,
+      sortBy: undefined,
+      sortOrder: undefined,
+    });
+    expect(result).toHaveLength(201);
+    expect(result.at(-1)?.name).toBe("cursor-match-201");
+  });
+
   test("normalizes legacy getUsersBatch keyword and offset params", async () => {
     findUserListBatchMock.mockResolvedValueOnce({
       users: [makeUser(88, "keyword-hit")],