Parcourir la source

feat: add request filters and enhance usage logs functionality

- Introduced new RequestFilter repository for managing request filters with CRUD operations.
- Added support for filtering usage logs based on minimum retry count and exclusion of 200 status codes.
- Updated dashboard components to include new filter options for better log management.
- Enhanced internationalization for new filter fields across multiple languages.

close #248
ding113 il y a 2 mois
Parent
commit
60d910721c

+ 3 - 0
.vscode/settings.json

@@ -0,0 +1,3 @@
+{
+    "chatgpt.openOnStartup": true
+}

+ 5 - 0
AGENTS.md

@@ -0,0 +1,5 @@
[email protected]
[email protected]
[email protected]
+@docs/product-brief-claude-code-hub-2025-11-29.md
+@docs/architecture-claude-code-hub-2025-11-29.md

+ 3 - 0
messages/en/dashboard.json

@@ -70,6 +70,8 @@
       "allStatusCodes": "All Status Codes",
       "apiKey": "API Key",
       "statusCode": "Status Code",
+      "minRetryCount": "Retry Count ≥",
+      "minRetryCountPlaceholder": "Enter minimum retries",
       "apply": "Apply Filter",
       "reset": "Reset"
     },
@@ -182,6 +184,7 @@
       "targetModel": "Target Model"
     },
     "statusCodes": {
+      "not200": "Non-200 (errors/blocked)",
       "200": "200 (Success)",
       "400": "400 (Bad Request)",
       "401": "401 (Unauthorized)",

+ 3 - 0
messages/ja/dashboard.json

@@ -70,6 +70,8 @@
       "allStatusCodes": "すべてのステータスコード",
       "apiKey": "API キー",
       "statusCode": "ステータスコード",
+      "minRetryCount": "リトライ回数≥",
+      "minRetryCountPlaceholder": "回数を入力(0 で制限なし)",
       "apply": "フィルターを適用",
       "reset": "リセット"
     },
@@ -181,6 +183,7 @@
       "targetModel": "ターゲットモデル"
     },
     "statusCodes": {
+      "not200": "非 200(エラー/ブロック)",
       "200": "200 (成功)",
       "400": "400 (不正なリクエスト)",
       "401": "401 (未認証)",

+ 3 - 0
messages/ru/dashboard.json

@@ -70,6 +70,8 @@
       "allStatusCodes": "Все коды состояния",
       "apiKey": "API ключ",
       "statusCode": "Код состояния",
+      "minRetryCount": "Количество ретраев ≥",
+      "minRetryCountPlaceholder": "Введите минимум (0 — без ограничения)",
       "apply": "Применить фильтр",
       "reset": "Сброс"
     },
@@ -181,6 +183,7 @@
       "targetModel": "Целевая модель"
     },
     "statusCodes": {
+      "not200": "Не 200 (ошибки/блокировки)",
       "200": "200 (Успех)",
       "400": "400 (Неверный запрос)",
       "401": "401 (Не авторизован)",

+ 3 - 0
messages/zh-CN/dashboard.json

@@ -70,6 +70,8 @@
       "allStatusCodes": "全部状态码",
       "apiKey": "API 密钥",
       "statusCode": "状态码",
+      "minRetryCount": "重试次数≥",
+      "minRetryCountPlaceholder": "输入次数(0 表示不限)",
       "apply": "应用筛选",
       "reset": "重置"
     },
@@ -182,6 +184,7 @@
       "targetModel": "目标模型"
     },
     "statusCodes": {
+      "not200": "非 200(全部非成功请求)",
       "200": "200 (成功)",
       "400": "400 (错误请求)",
       "401": "401 (未授权)",

+ 3 - 0
messages/zh-TW/dashboard.json

@@ -70,6 +70,8 @@
       "allStatusCodes": "所有狀態碼",
       "apiKey": "API 金鑰",
       "statusCode": "狀態碼",
+      "minRetryCount": "重試次數≥",
+      "minRetryCountPlaceholder": "輸入次數(0 表示不限)",
       "apply": "套用篩選",
       "reset": "重設"
     },
@@ -182,6 +184,7 @@
       "targetModel": "目標模型"
     },
     "statusCodes": {
+      "not200": "非 200(所有非成功請求)",
       "200": "200 (成功)",
       "400": "400 (錯誤請求)",
       "401": "401 (未授權)",

+ 28 - 2
src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx

@@ -33,8 +33,10 @@ interface UsageLogsFiltersProps {
     /** 本地时间字符串,格式: "YYYY-MM-DDTHH:mm" */
     endDateLocal?: string;
     statusCode?: number;
+    excludeStatusCode200?: boolean;
     model?: string;
     endpoint?: string;
+    minRetryCount?: number;
   };
   onChange: (filters: UsageLogsFiltersProps["filters"]) => void;
   onReset: () => void;
@@ -306,11 +308,16 @@ export function UsageLogsFilters({
         <div className="space-y-2 lg:col-span-4">
           <Label>{t("logs.filters.statusCode")}</Label>
           <Select
-            value={localFilters.statusCode?.toString() || ""}
+            value={
+              localFilters.excludeStatusCode200
+                ? "!200"
+                : localFilters.statusCode?.toString() || ""
+            }
             onValueChange={(value: string) =>
               setLocalFilters({
                 ...localFilters,
-                statusCode: value ? parseInt(value, 10) : undefined,
+                statusCode: value && value !== "!200" ? parseInt(value, 10) : undefined,
+                excludeStatusCode200: value === "!200",
               })
             }
           >
@@ -318,6 +325,7 @@ export function UsageLogsFilters({
               <SelectValue placeholder={t("logs.filters.allStatusCodes")} />
             </SelectTrigger>
             <SelectContent>
+              <SelectItem value="!200">{t("logs.statusCodes.not200")}</SelectItem>
               <SelectItem value="200">{t("logs.statusCodes.200")}</SelectItem>
               <SelectItem value="400">{t("logs.statusCodes.400")}</SelectItem>
               <SelectItem value="401">{t("logs.statusCodes.401")}</SelectItem>
@@ -333,6 +341,24 @@ export function UsageLogsFilters({
             </SelectContent>
           </Select>
         </div>
+
+        {/* 重试次数下限 */}
+        <div className="space-y-2 lg:col-span-4">
+          <Label>{t("logs.filters.minRetryCount")}</Label>
+          <Input
+            type="number"
+            min={0}
+            inputMode="numeric"
+            value={localFilters.minRetryCount?.toString() ?? ""}
+            placeholder={t("logs.filters.minRetryCountPlaceholder")}
+            onChange={(e) =>
+              setLocalFilters({
+                ...localFilters,
+                minRetryCount: e.target.value ? parseInt(e.target.value, 10) : undefined,
+              })
+            }
+          />
+        </div>
       </div>
 
       {/* 操作按钮 */}

+ 16 - 4
src/app/[locale]/dashboard/logs/_components/usage-logs-view.tsx

@@ -60,8 +60,10 @@ export function UsageLogsView({
     startDateLocal?: string;
     endDateLocal?: string;
     statusCode?: number;
+    excludeStatusCode200?: boolean;
     model?: string;
     endpoint?: string;
+    minRetryCount?: number;
     page: number;
   } = {
     userId: searchParams.userId ? parseInt(searchParams.userId as string, 10) : undefined,
@@ -72,11 +74,14 @@ export function UsageLogsView({
     // 直接传递本地时间字符串,不转换为 Date
     startDateLocal: searchParams.startDate as string | undefined,
     endDateLocal: searchParams.endDate as string | undefined,
-    statusCode: searchParams.statusCode
-      ? parseInt(searchParams.statusCode as string, 10)
-      : undefined,
+    statusCode:
+      searchParams.statusCode && searchParams.statusCode !== "!200"
+        ? parseInt(searchParams.statusCode as string, 10)
+        : undefined,
+    excludeStatusCode200: searchParams.statusCode === "!200",
     model: searchParams.model as string | undefined,
     endpoint: searchParams.endpoint as string | undefined,
+    minRetryCount: searchParams.minRetry ? parseInt(searchParams.minRetry as string, 10) : undefined,
     page: searchParams.page ? parseInt(searchParams.page as string, 10) : 1,
   };
 
@@ -170,9 +175,16 @@ export function UsageLogsView({
     // 时间直接使用字符串格式(datetime-local 返回的格式)
     if (newFilters.startDateLocal) query.set("startDate", newFilters.startDateLocal);
     if (newFilters.endDateLocal) query.set("endDate", newFilters.endDateLocal);
-    if (newFilters.statusCode) query.set("statusCode", newFilters.statusCode.toString());
+    if (newFilters.excludeStatusCode200) {
+      query.set("statusCode", "!200");
+    } else if (newFilters.statusCode !== undefined) {
+      query.set("statusCode", newFilters.statusCode.toString());
+    }
     if (newFilters.model) query.set("model", newFilters.model);
     if (newFilters.endpoint) query.set("endpoint", newFilters.endpoint);
+    if (newFilters.minRetryCount !== undefined) {
+      query.set("minRetry", newFilters.minRetryCount.toString());
+    }
 
     router.push(`/dashboard/logs?${query.toString()}`);
   };

+ 8 - 0
src/app/api/actions/[...route]/route.ts

@@ -393,10 +393,18 @@ const { route: getUsageLogsRoute, handler: getUsageLogsHandler } = createActionR
   usageLogActions.getUsageLogs,
   {
     requestSchema: z.object({
+      userId: z.number().int().positive().optional(),
+      keyId: z.number().int().positive().optional(),
+      providerId: z.number().int().positive().optional(),
       startDate: z.string().datetime().optional(),
       endDate: z.string().datetime().optional(),
+      startDateLocal: z.string().optional(),
+      endDateLocal: z.string().optional(),
       model: z.string().optional(),
+      endpoint: z.string().optional(),
       statusCode: z.number().optional(),
+      excludeStatusCode200: z.boolean().optional(),
+      minRetryCount: z.number().int().nonnegative().optional(),
       pageSize: z.number().int().positive().max(100).default(50).optional(),
       page: z.number().int().positive().default(1).optional(),
     }),

+ 8 - 0
src/lib/event-emitter.ts

@@ -15,6 +15,7 @@ import { EventEmitter as NodeEventEmitter } from "node:events";
 interface EventMap {
   errorRulesUpdated: [];
   sensitiveWordsUpdated: [];
+  requestFiltersUpdated: [];
 }
 
 /**
@@ -34,6 +35,13 @@ class GlobalEventEmitter extends NodeEventEmitter {
   emitSensitiveWordsUpdated(): void {
     this.emit("sensitiveWordsUpdated");
   }
+
+  /**
+   * 发送 requestFiltersUpdated 事件
+   */
+  emitRequestFiltersUpdated(): void {
+    this.emit("requestFiltersUpdated");
+  }
 }
 
 /**

+ 145 - 0
src/repository/request-filters.ts

@@ -0,0 +1,145 @@
+"use server";
+
+import { desc, eq } from "drizzle-orm";
+import { db } from "@/drizzle/db";
+import { requestFilters } from "@/drizzle/schema";
+import { eventEmitter } from "@/lib/event-emitter";
+
+export type RequestFilterScope = "header" | "body";
+export type RequestFilterAction = "remove" | "set" | "json_path" | "text_replace";
+export type RequestFilterMatchType = "regex" | "contains" | "exact" | null;
+
+export interface RequestFilter {
+  id: number;
+  name: string;
+  description: string | null;
+  scope: RequestFilterScope;
+  action: RequestFilterAction;
+  matchType: RequestFilterMatchType;
+  target: string;
+  replacement: unknown;
+  priority: number;
+  isEnabled: boolean;
+  createdAt: Date;
+  updatedAt: Date;
+}
+
+type Row = typeof requestFilters.$inferSelect;
+
+function mapRow(row: Row): RequestFilter {
+  return {
+    id: row.id,
+    name: row.name,
+    description: row.description,
+    scope: row.scope as RequestFilterScope,
+    action: row.action as RequestFilterAction,
+    matchType: (row.matchType as RequestFilterMatchType | null) ?? null,
+    target: row.target,
+    replacement: row.replacement ?? null,
+    priority: row.priority,
+    isEnabled: row.isEnabled,
+    createdAt: row.createdAt ?? new Date(),
+    updatedAt: row.updatedAt ?? new Date(),
+  };
+}
+
+/**
+ * 获取启用的请求过滤器(按优先级升序排列)
+ */
+export async function getActiveRequestFilters(): Promise<RequestFilter[]> {
+  const rows = await db.query.requestFilters.findMany({
+    where: eq(requestFilters.isEnabled, true),
+    orderBy: [requestFilters.priority, requestFilters.id],
+  });
+
+  return rows.map(mapRow);
+}
+
+/**
+ * 获取全部请求过滤器(包含禁用)
+ */
+export async function getAllRequestFilters(): Promise<RequestFilter[]> {
+  const rows = await db.query.requestFilters.findMany({
+    orderBy: [desc(requestFilters.createdAt)],
+  });
+
+  return rows.map(mapRow);
+}
+
+interface CreateRequestFilterInput {
+  name: string;
+  description?: string;
+  scope: RequestFilterScope;
+  action: RequestFilterAction;
+  matchType?: RequestFilterMatchType;
+  target: string;
+  replacement?: unknown;
+  priority?: number;
+  isEnabled?: boolean;
+}
+
+export async function createRequestFilter(data: CreateRequestFilterInput): Promise<RequestFilter> {
+  const [row] = await db
+    .insert(requestFilters)
+    .values({
+      name: data.name,
+      description: data.description,
+      scope: data.scope,
+      action: data.action,
+      matchType: data.matchType ?? null,
+      target: data.target,
+      replacement: data.replacement ?? null,
+      priority: data.priority ?? 0,
+      isEnabled: data.isEnabled ?? true,
+    })
+    .returning();
+
+  eventEmitter.emitRequestFiltersUpdated();
+  return mapRow(row);
+}
+
+interface UpdateRequestFilterInput {
+  name?: string;
+  description?: string | null;
+  scope?: RequestFilterScope;
+  action?: RequestFilterAction;
+  matchType?: RequestFilterMatchType;
+  target?: string;
+  replacement?: unknown;
+  priority?: number;
+  isEnabled?: boolean;
+}
+
+export async function updateRequestFilter(
+  id: number,
+  data: UpdateRequestFilterInput
+): Promise<RequestFilter | null> {
+  const [row] = await db
+    .update(requestFilters)
+    .set({
+      ...data,
+      updatedAt: new Date(),
+    })
+    .where(eq(requestFilters.id, id))
+    .returning();
+
+  if (!row) {
+    return null;
+  }
+
+  eventEmitter.emitRequestFiltersUpdated();
+  return mapRow(row);
+}
+
+export async function deleteRequestFilter(id: number): Promise<boolean> {
+  const rows = await db.delete(requestFilters).where(eq(requestFilters.id, id)).returning({
+    id: requestFilters.id,
+  });
+
+  if (rows.length > 0) {
+    eventEmitter.emitRequestFiltersUpdated();
+    return true;
+  }
+
+  return false;
+}

+ 18 - 0
src/repository/usage-logs.ts

@@ -15,8 +15,12 @@ export interface UsageLogFilters {
   /** 本地时间字符串,格式: "YYYY-MM-DD HH:mm:ss" 或 "YYYY-MM-DDTHH:mm" */
   endDateLocal?: string;
   statusCode?: number;
+  /** 排除 200 状态码(筛选所有非 200 的请求,包括 NULL) */
+  excludeStatusCode200?: boolean;
   model?: string;
   endpoint?: string;
+  /** 最低重试次数(provider_chain 长度 - 1) */
+  minRetryCount?: number;
   page?: number;
   pageSize?: number;
 }
@@ -89,8 +93,10 @@ export async function findUsageLogsWithDetails(filters: UsageLogFilters): Promis
     startDateLocal,
     endDateLocal,
     statusCode,
+    excludeStatusCode200,
     model,
     endpoint,
+    minRetryCount,
     page = 1,
     pageSize = 50,
   } = filters;
@@ -155,6 +161,11 @@ export async function findUsageLogsWithDetails(filters: UsageLogFilters): Promis
 
   if (statusCode !== undefined) {
     conditions.push(eq(messageRequest.statusCode, statusCode));
+  } else if (excludeStatusCode200) {
+    // 包含 status_code 为空或非 200 的请求
+    conditions.push(
+      sql`(${messageRequest.statusCode} IS NULL OR ${messageRequest.statusCode} <> 200)`
+    );
   }
 
   if (model) {
@@ -165,6 +176,13 @@ export async function findUsageLogsWithDetails(filters: UsageLogFilters): Promis
     conditions.push(eq(messageRequest.endpoint, endpoint));
   }
 
+  if (minRetryCount !== undefined) {
+    // 重试次数 = provider_chain 长度 - 1(最小为 0)
+    conditions.push(
+      sql`GREATEST(COALESCE(jsonb_array_length(${messageRequest.providerChain}) - 1, 0), 0) >= ${minRetryCount}`
+    );
+  }
+
   // 查询总数和统计数据
   const [summaryResult] = await db
     .select({