Explorar el Código

fix: harden usage logs csv export (#966)

* fix: harden usage logs csv export

Amp-Thread-ID: https://ampcode.com/threads/T-019d1590-be48-7749-987d-936bc6abd5f7
Co-authored-by: Amp <[email protected]>

* fix: address review feedback on usage logs export

- Replace hardcoded Chinese error strings with English messages
- Add 5-minute polling timeout to prevent UI hanging on stuck jobs
- Check Redis CSV write result and fail job if persist fails
- Add clarifying comment on setTimeout for self-hosted server

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: increase export polling timeout from 5 to 10 minutes

Align client-side deadline with backend's 15-minute TTL to avoid
prematurely aborting large exports that legitimately run longer.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Amp <[email protected]>
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
Ding hace 2 semanas
padre
commit
d774721be5

+ 2 - 0
messages/en/dashboard.json

@@ -95,6 +95,8 @@
       "exporting": "Exporting...",
       "exportSuccess": "Export completed successfully",
       "exportError": "Export failed",
+      "exportPreparing": "Preparing export...",
+      "exportProgress": "Exported {current} / {total}",
       "quickFilters": {
         "today": "Today",
         "thisWeek": "This Week",

+ 2 - 0
messages/ja/dashboard.json

@@ -95,6 +95,8 @@
       "exporting": "エクスポート中...",
       "exportSuccess": "エクスポートが完了しました",
       "exportError": "エクスポートに失敗しました",
+      "exportPreparing": "エクスポートを準備中...",
+      "exportProgress": "{current} / {total} 件をエクスポート済み",
       "quickFilters": {
         "today": "今日",
         "thisWeek": "今週",

+ 2 - 0
messages/ru/dashboard.json

@@ -95,6 +95,8 @@
       "exporting": "Экспорт...",
       "exportSuccess": "Экспорт завершен",
       "exportError": "Ошибка экспорта",
+      "exportPreparing": "Подготовка экспорта...",
+      "exportProgress": "Экспортировано {current} / {total}",
       "quickFilters": {
         "today": "Сегодня",
         "thisWeek": "Эта неделя",

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

@@ -95,6 +95,8 @@
       "exporting": "导出中...",
       "exportSuccess": "导出成功",
       "exportError": "导出失败",
+      "exportPreparing": "正在准备导出...",
+      "exportProgress": "已导出 {current} / {total} 条",
       "quickFilters": {
         "today": "今天",
         "thisWeek": "本周",

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

@@ -95,6 +95,8 @@
       "exporting": "匯出中...",
       "exportSuccess": "匯出成功",
       "exportError": "匯出失敗",
+      "exportPreparing": "正在準備匯出...",
+      "exportProgress": "已匯出 {current} / {total} 筆",
       "quickFilters": {
         "today": "今天",
         "thisWeek": "本週",

+ 342 - 67
src/actions/usage-logs.ts

@@ -8,6 +8,7 @@ import {
 } from "@/lib/constants/usage-logs.constants";
 import { logger } from "@/lib/logger";
 import { readLiveChainBatch } from "@/lib/redis/live-chain-store";
+import { RedisKVStore } from "@/lib/redis/redis-kv-store";
 import { getRetryCount } from "@/lib/utils/provider-chain-formatter";
 import { isProviderFinalized } from "@/lib/utils/provider-display";
 import {
@@ -39,6 +40,245 @@ let filterOptionsCache: {
   expiresAt: number;
 } | null = null;
 
+const USAGE_LOGS_EXPORT_BATCH_SIZE = 500;
+const USAGE_LOGS_EXPORT_JOB_TTL_MS = 15 * 60 * 1000;
+const USAGE_LOGS_EXPORT_JOB_TTL_SECONDS = Math.floor(USAGE_LOGS_EXPORT_JOB_TTL_MS / 1000);
+const CSV_HEADERS = [
+  "Time",
+  "User",
+  "Key",
+  "Provider",
+  "Model",
+  "Original Model",
+  "Endpoint",
+  "Status Code",
+  "Input Tokens",
+  "Output Tokens",
+  "Cache Write 5m",
+  "Cache Write 1h",
+  "Cache Read",
+  "Total Tokens",
+  "Cost (USD)",
+  "Duration (ms)",
+  "Session ID",
+  "Retry Count",
+] as const;
+
+type UsageLogsSession = NonNullable<Awaited<ReturnType<typeof getSession>>>;
+
+export interface UsageLogsExportStatus {
+  jobId: string;
+  status: "queued" | "running" | "completed" | "failed";
+  processedRows: number;
+  totalRows: number;
+  progressPercent: number;
+  error?: string;
+}
+
+interface UsageLogsExportJobRecord extends UsageLogsExportStatus {
+  ownerUserId: number;
+}
+
+const usageLogsExportStatusStore = new RedisKVStore<UsageLogsExportJobRecord>({
+  prefix: "cch:usage-logs:export:status:",
+  defaultTtlSeconds: USAGE_LOGS_EXPORT_JOB_TTL_SECONDS,
+});
+
+const usageLogsExportCsvStore = new RedisKVStore<string>({
+  prefix: "cch:usage-logs:export:csv:",
+  defaultTtlSeconds: USAGE_LOGS_EXPORT_JOB_TTL_SECONDS,
+});
+
+function usageLogsExportCsvKey(jobId: string): string {
+  return `${jobId}:csv`;
+}
+
+function resolveUsageLogFiltersForSession(
+  session: UsageLogsSession,
+  filters: Omit<UsageLogFilters, "userId" | "page" | "pageSize">
+): Omit<UsageLogFilters, "page" | "pageSize"> {
+  return session.user.role === "admin" ? filters : { ...filters, userId: session.user.id };
+}
+
+function toUsageLogsExportStatus(job: UsageLogsExportJobRecord): UsageLogsExportStatus {
+  return {
+    jobId: job.jobId,
+    status: job.status,
+    processedRows: job.processedRows,
+    totalRows: job.totalRows,
+    progressPercent: job.progressPercent,
+    error: job.error,
+  };
+}
+
+function getUsageLogsExportJob(
+  session: UsageLogsSession,
+  job: UsageLogsExportJobRecord | null,
+  _jobId: string
+): UsageLogsExportJobRecord | null {
+  if (!job || job.ownerUserId !== session.user.id) {
+    return null;
+  }
+  return job;
+}
+
+function buildCsvRows(logs: UsageLogRow[]): string[] {
+  return logs.map((log) => {
+    const retryCount = log.providerChain ? getRetryCount(log.providerChain) : 0;
+    return [
+      log.createdAt ? new Date(log.createdAt).toISOString() : "",
+      escapeCsvField(log.userName),
+      escapeCsvField(log.keyName),
+      escapeCsvField(log.providerName ?? ""),
+      escapeCsvField(log.model ?? ""),
+      escapeCsvField(log.originalModel ?? ""),
+      escapeCsvField(log.endpoint ?? ""),
+      log.statusCode?.toString() ?? "",
+      log.inputTokens?.toString() ?? "0",
+      log.outputTokens?.toString() ?? "0",
+      log.cacheCreation5mInputTokens?.toString() ?? "0",
+      log.cacheCreation1hInputTokens?.toString() ?? "0",
+      log.cacheReadInputTokens?.toString() ?? "0",
+      log.totalTokens.toString(),
+      log.costUsd ?? "0",
+      log.durationMs?.toString() ?? "",
+      escapeCsvField(log.sessionId ?? ""),
+      retryCount.toString(),
+    ].join(",");
+  });
+}
+
+function buildUsageLogsExportProgress(
+  processedRows: number,
+  totalRows: number,
+  hasMore: boolean
+): Pick<UsageLogsExportStatus, "processedRows" | "totalRows" | "progressPercent"> {
+  const effectiveTotalRows = Math.max(totalRows, hasMore ? processedRows + 1 : processedRows);
+  const progressPercent =
+    effectiveTotalRows <= 0
+      ? 100
+      : hasMore
+        ? Math.min(99, Math.floor((processedRows / effectiveTotalRows) * 100))
+        : 100;
+
+  return {
+    processedRows,
+    totalRows: effectiveTotalRows,
+    progressPercent,
+  };
+}
+
+async function buildUsageLogsExportCsv(
+  filters: Omit<UsageLogFilters, "page" | "pageSize">,
+  onProgress?: (
+    progress: Pick<UsageLogsExportStatus, "processedRows" | "totalRows" | "progressPercent">
+  ) => Promise<void> | void
+): Promise<string> {
+  const initialResult = await findUsageLogsWithDetails({ ...filters, page: 1, pageSize: 1 });
+  let estimatedTotalRows = initialResult.total;
+
+  if (estimatedTotalRows === 0) {
+    const stats = await findUsageLogsStats(filters);
+    estimatedTotalRows = stats.totalRequests;
+  }
+
+  const csvLines = [CSV_HEADERS.join(",")];
+  let cursor: UsageLogBatchFilters["cursor"] | undefined;
+  let processedRows = 0;
+
+  while (true) {
+    const batch = await findUsageLogsBatch({
+      ...filters,
+      cursor,
+      limit: USAGE_LOGS_EXPORT_BATCH_SIZE,
+    });
+
+    if (batch.logs.length > 0) {
+      csvLines.push(...buildCsvRows(batch.logs));
+      processedRows += batch.logs.length;
+    }
+
+    const progress = buildUsageLogsExportProgress(processedRows, estimatedTotalRows, batch.hasMore);
+    estimatedTotalRows = progress.totalRows;
+    await onProgress?.(progress);
+
+    if (!batch.hasMore || !batch.nextCursor) {
+      break;
+    }
+
+    cursor = batch.nextCursor;
+  }
+
+  return `\uFEFF${csvLines.join("\n")}`;
+}
+
+async function runUsageLogsExportJob(
+  jobId: string,
+  filters: Omit<UsageLogFilters, "page" | "pageSize">
+): Promise<void> {
+  const existingJob = await usageLogsExportStatusStore.get(jobId);
+  if (!existingJob) {
+    return;
+  }
+
+  await usageLogsExportStatusStore.set(jobId, {
+    ...existingJob,
+    status: "running",
+    error: undefined,
+  });
+
+  try {
+    const csv = await buildUsageLogsExportCsv(filters, async (progress) => {
+      const currentJob = await usageLogsExportStatusStore.get(jobId);
+      if (!currentJob) {
+        return;
+      }
+
+      await usageLogsExportStatusStore.set(jobId, {
+        ...currentJob,
+        status: "running",
+        ...progress,
+      });
+    });
+
+    const currentJob = await usageLogsExportStatusStore.get(jobId);
+    if (!currentJob) {
+      return;
+    }
+
+    const csvStored = await usageLogsExportCsvStore.set(usageLogsExportCsvKey(jobId), csv);
+    if (!csvStored) {
+      await usageLogsExportStatusStore.set(jobId, {
+        ...currentJob,
+        status: "failed",
+        progressPercent: 0,
+        error: "Failed to persist CSV to Redis",
+      });
+      return;
+    }
+
+    await usageLogsExportStatusStore.set(jobId, {
+      ...currentJob,
+      status: "completed",
+      progressPercent: 100,
+      error: undefined,
+    });
+  } catch (error) {
+    logger.error("Failed to run usage logs export job:", error);
+    const currentJob = await usageLogsExportStatusStore.get(jobId);
+    if (!currentJob) {
+      return;
+    }
+
+    await usageLogsExportStatusStore.set(jobId, {
+      ...currentJob,
+      status: "failed",
+      progressPercent: 0,
+      error: error instanceof Error ? error.message : "Export failed",
+    });
+  }
+}
+
 /**
  * 获取使用日志(根据权限过滤)
  */
@@ -77,16 +317,8 @@ export async function exportUsageLogs(
       return { ok: false, error: "未登录" };
     }
 
-    // 如果不是 admin,强制过滤为当前用户
-    const finalFilters: UsageLogFilters =
-      session.user.role === "admin"
-        ? { ...filters, page: 1, pageSize: 10000 }
-        : { ...filters, userId: session.user.id, page: 1, pageSize: 10000 };
-
-    const result = await findUsageLogsWithDetails(finalFilters);
-
-    // 生成 CSV
-    const csv = generateCsv(result.logs);
+    const finalFilters = resolveUsageLogFiltersForSession(session, filters);
+    const csv = await buildUsageLogsExportCsv(finalFilters);
 
     return { ok: true, data: csv };
   } catch (error) {
@@ -96,74 +328,117 @@ export async function exportUsageLogs(
   }
 }
 
-/**
- * 生成 CSV 字符串
- */
-function generateCsv(logs: UsageLogRow[]): string {
-  const headers = [
-    "Time",
-    "User",
-    "Key",
-    "Provider",
-    "Model",
-    "Original Model",
-    "Endpoint",
-    "Status Code",
-    "Input Tokens",
-    "Output Tokens",
-    "Cache Write 5m",
-    "Cache Write 1h",
-    "Cache Read",
-    "Total Tokens",
-    "Cost (USD)",
-    "Duration (ms)",
-    "Session ID",
-    "Retry Count",
-  ];
-
-  const rows = logs.map((log) => {
-    const retryCount = log.providerChain ? getRetryCount(log.providerChain) : 0;
-    return [
-      log.createdAt ? new Date(log.createdAt).toISOString() : "",
-      escapeCsvField(log.userName),
-      escapeCsvField(log.keyName),
-      escapeCsvField(log.providerName ?? ""),
-      escapeCsvField(log.model ?? ""),
-      escapeCsvField(log.originalModel ?? ""),
-      escapeCsvField(log.endpoint ?? ""),
-      log.statusCode?.toString() ?? "",
-      log.inputTokens?.toString() ?? "0",
-      log.outputTokens?.toString() ?? "0",
-      log.cacheCreation5mInputTokens?.toString() ?? "0",
-      log.cacheCreation1hInputTokens?.toString() ?? "0",
-      log.cacheReadInputTokens?.toString() ?? "0",
-      log.totalTokens.toString(),
-      log.costUsd ?? "0",
-      log.durationMs?.toString() ?? "",
-      escapeCsvField(log.sessionId ?? ""),
-      retryCount.toString(),
-    ];
-  });
+export async function startUsageLogsExport(
+  filters: Omit<UsageLogFilters, "userId" | "page" | "pageSize">
+): Promise<ActionResult<{ jobId: string }>> {
+  try {
+    const session = await getSession();
+    if (!session) {
+      return { ok: false, error: "未登录" };
+    }
+
+    const jobId = crypto.randomUUID();
+    const finalFilters = resolveUsageLogFiltersForSession(session, filters);
+
+    const stored = await usageLogsExportStatusStore.set(jobId, {
+      jobId,
+      ownerUserId: session.user.id,
+      status: "queued",
+      processedRows: 0,
+      totalRows: 0,
+      progressPercent: 0,
+    });
+
+    if (!stored) {
+      return { ok: false, error: "Export job initialization failed" };
+    }
 
-  // 添加 BOM 以支持 Excel 正确识别 UTF-8
-  const bom = "\uFEFF";
-  const csvContent = [headers.join(","), ...rows.map((row) => row.join(","))].join("\n");
+    // Defer to next tick so the action returns the jobId immediately.
+    // Safe for self-hosted Bun server (long-lived process); NOT suitable for serverless.
+    setTimeout(() => {
+      void runUsageLogsExportJob(jobId, finalFilters);
+    }, 0);
 
-  return bom + csvContent;
+    return { ok: true, data: { jobId } };
+  } catch (error) {
+    logger.error("Failed to start usage logs export:", error);
+    const message = error instanceof Error ? error.message : "Failed to start export";
+    return { ok: false, error: message };
+  }
+}
+
+export async function getUsageLogsExportStatus(
+  jobId: string
+): Promise<ActionResult<UsageLogsExportStatus>> {
+  try {
+    const session = await getSession();
+    if (!session) {
+      return { ok: false, error: "未登录" };
+    }
+
+    const job = getUsageLogsExportJob(session, await usageLogsExportStatusStore.get(jobId), jobId);
+    if (!job) {
+      return { ok: false, error: "Export job not found or expired" };
+    }
+
+    return { ok: true, data: toUsageLogsExportStatus(job) };
+  } catch (error) {
+    logger.error("Failed to get usage logs export status:", error);
+    const message = error instanceof Error ? error.message : "Failed to get export status";
+    return { ok: false, error: message };
+  }
+}
+
+export async function downloadUsageLogsExport(jobId: string): Promise<ActionResult<string>> {
+  try {
+    const session = await getSession();
+    if (!session) {
+      return { ok: false, error: "未登录" };
+    }
+
+    const job = getUsageLogsExportJob(session, await usageLogsExportStatusStore.get(jobId), jobId);
+    if (!job) {
+      return { ok: false, error: "Export job not found or expired" };
+    }
+
+    if (job.status === "failed") {
+      return { ok: false, error: job.error || "Export failed" };
+    }
+
+    if (job.status !== "completed") {
+      return { ok: false, error: "Export not yet completed" };
+    }
+
+    const csv = await usageLogsExportCsvStore.get(usageLogsExportCsvKey(jobId));
+    if (!csv) {
+      return { ok: false, error: "Export file not found or expired" };
+    }
+
+    return { ok: true, data: csv };
+  } catch (error) {
+    logger.error("Failed to download usage logs export:", error);
+    const message = error instanceof Error ? error.message : "Failed to download export";
+    return { ok: false, error: message };
+  }
 }
 
 /**
  * 转义 CSV 字段(防止 CSV 公式注入攻击)
  */
 function escapeCsvField(field: string): string {
-  // Prevent CSV formula injection by prefixing dangerous characters
-  const dangerousChars = ["=", "+", "-", "@", "\t", "\r"];
+  const dangerousChars = ["=", "+", "-", "@"];
+  const trimmedField = field.trimStart();
   let safeField = field;
-  if (dangerousChars.some((char) => field.startsWith(char))) {
-    safeField = `'${field}`; // Prefix with single quote to prevent formula execution
+  if (trimmedField && dangerousChars.some((char) => trimmedField.startsWith(char))) {
+    safeField = `'${field}`;
   }
 
-  if (safeField.includes(",") || safeField.includes('"') || safeField.includes("\n")) {
+  if (
+    safeField.includes(",") ||
+    safeField.includes('"') ||
+    safeField.includes("\n") ||
+    safeField.includes("\r")
+  ) {
     return `"${safeField.replace(/"/g, '""')}"`;
   }
   return safeField;

+ 125 - 15
src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx

@@ -3,10 +3,16 @@
 import { format, startOfDay, startOfWeek } from "date-fns";
 import { Clock, Download, Network, Server, User } from "lucide-react";
 import { useTranslations } from "next-intl";
-import { useCallback, useMemo, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
 import { toast } from "sonner";
-import { exportUsageLogs } from "@/actions/usage-logs";
+import {
+  downloadUsageLogsExport,
+  getUsageLogsExportStatus,
+  startUsageLogsExport,
+  type UsageLogsExportStatus,
+} from "@/actions/usage-logs";
 import { Button } from "@/components/ui/button";
+import { Progress } from "@/components/ui/progress";
 import type { Key } from "@/types/key";
 import type { ProviderDisplay } from "@/types/provider";
 import { ActiveFiltersDisplay } from "./filters/active-filters-display";
@@ -70,7 +76,9 @@ export function UsageLogsFilters({
 
   const [localFilters, setLocalFilters] = useState<UsageLogFilters>(filters);
   const [isExporting, setIsExporting] = useState(false);
+  const [exportStatus, setExportStatus] = useState<UsageLogsExportStatus | null>(null);
   const [activePreset, setActivePreset] = useState<FilterPreset | null>(null);
+  const exportRunIdRef = useRef(0);
 
   // Track users and keys for display name resolution
   const [availableUsers, setAvailableUsers] = useState<Array<{ id: number; name: string }>>([]);
@@ -133,42 +141,128 @@ export function UsageLogsFilters({
     return count;
   }, [localFilters.statusCode, localFilters.excludeStatusCode200, localFilters.minRetryCount]);
 
+  useEffect(() => {
+    setLocalFilters(filters);
+  }, [filters]);
+
+  useEffect(() => {
+    return () => {
+      exportRunIdRef.current += 1;
+    };
+  }, []);
+
   const handleApply = useCallback(() => {
     onChange(sanitizeFilters(localFilters));
   }, [localFilters, onChange]);
 
   const handleReset = useCallback(() => {
+    exportRunIdRef.current += 1;
     setLocalFilters({});
     setKeys([]);
     setActivePreset(null);
+    setIsExporting(false);
+    setExportStatus(null);
     onReset();
   }, [onReset]);
 
+  const downloadCsv = useCallback((csv: string) => {
+    const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
+    const url = window.URL.createObjectURL(blob);
+    const a = document.createElement("a");
+    a.href = url;
+    a.download = `usage-logs-${format(new Date(), "yyyy-MM-dd-HHmmss")}.csv`;
+    document.body.appendChild(a);
+    a.click();
+    document.body.removeChild(a);
+    window.URL.revokeObjectURL(url);
+  }, []);
+
   const handleExport = async () => {
+    const runId = exportRunIdRef.current + 1;
+    exportRunIdRef.current = runId;
     setIsExporting(true);
+    setExportStatus({
+      jobId: "",
+      status: "queued",
+      processedRows: 0,
+      totalRows: 0,
+      progressPercent: 0,
+    });
+
     try {
-      const result = await exportUsageLogs(localFilters);
-      if (!result.ok) {
-        toast.error(result.error || t("logs.filters.exportError"));
+      const exportFilters = sanitizeFilters(filters);
+      const startResult = await startUsageLogsExport(exportFilters);
+      if (exportRunIdRef.current !== runId) {
         return;
       }
 
-      const blob = new Blob([result.data], { type: "text/csv;charset=utf-8;" });
-      const url = window.URL.createObjectURL(blob);
-      const a = document.createElement("a");
-      a.href = url;
-      a.download = `usage-logs-${format(new Date(), "yyyy-MM-dd-HHmmss")}.csv`;
-      document.body.appendChild(a);
-      a.click();
-      document.body.removeChild(a);
-      window.URL.revokeObjectURL(url);
+      if (!startResult.ok) {
+        setExportStatus(null);
+        toast.error(startResult.error || t("logs.filters.exportError"));
+        return;
+      }
+
+      const jobId = startResult.data.jobId;
+      const EXPORT_TIMEOUT_MS = 10 * 60 * 1000;
+      const deadline = Date.now() + EXPORT_TIMEOUT_MS;
+
+      while (true) {
+        if (exportRunIdRef.current !== runId) {
+          return;
+        }
+
+        if (Date.now() > deadline) {
+          setExportStatus(null);
+          toast.error(t("logs.filters.exportError"));
+          return;
+        }
+
+        const statusResult = await getUsageLogsExportStatus(jobId);
+        if (exportRunIdRef.current !== runId) {
+          return;
+        }
+
+        if (!statusResult.ok) {
+          setExportStatus(null);
+          toast.error(statusResult.error || t("logs.filters.exportError"));
+          return;
+        }
+
+        setExportStatus(statusResult.data);
+
+        if (statusResult.data.status === "failed") {
+          toast.error(statusResult.data.error || t("logs.filters.exportError"));
+          return;
+        }
+
+        if (statusResult.data.status === "completed") {
+          break;
+        }
+
+        await new Promise((resolve) => window.setTimeout(resolve, 800));
+      }
+
+      const downloadResult = await downloadUsageLogsExport(jobId);
+      if (exportRunIdRef.current !== runId) {
+        return;
+      }
+
+      if (!downloadResult.ok) {
+        toast.error(downloadResult.error || t("logs.filters.exportError"));
+        return;
+      }
+
+      downloadCsv(downloadResult.data);
 
       toast.success(t("logs.filters.exportSuccess"));
     } catch (error) {
       console.error("Export failed:", error);
       toast.error(t("logs.filters.exportError"));
     } finally {
-      setIsExporting(false);
+      if (exportRunIdRef.current === runId) {
+        setExportStatus(null);
+        setIsExporting(false);
+      }
     }
   };
 
@@ -326,6 +420,22 @@ export function UsageLogsFilters({
           <Download className="mr-2 h-4 w-4" aria-hidden="true" />
           {isExporting ? t("logs.filters.exporting") : t("logs.filters.export")}
         </Button>
+        {isExporting && exportStatus ? (
+          <div className="min-w-[220px] flex-1 space-y-1 rounded-md border border-border/60 bg-muted/30 p-3">
+            <div className="flex items-center justify-between gap-3 text-xs text-muted-foreground">
+              <span>
+                {exportStatus.totalRows > 0
+                  ? t("logs.filters.exportProgress", {
+                      current: exportStatus.processedRows,
+                      total: exportStatus.totalRows,
+                    })
+                  : t("logs.filters.exportPreparing")}
+              </span>
+              <span>{exportStatus.progressPercent}%</span>
+            </div>
+            <Progress value={Math.max(exportStatus.progressPercent, 2)} />
+          </div>
+        ) : null}
       </div>
     </div>
   );

+ 190 - 24
tests/unit/actions/usage-logs-export-retry-count.test.ts

@@ -2,6 +2,10 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
 
 const getSessionMock = vi.fn();
 const findUsageLogsWithDetailsMock = vi.fn();
+const findUsageLogsBatchMock = vi.fn();
+const findUsageLogsStatsMock = vi.fn();
+const exportStatusStore = new Map<string, unknown>();
+const exportCsvStore = new Map<string, string>();
 
 vi.mock("@/lib/auth", () => {
   return {
@@ -9,21 +13,55 @@ vi.mock("@/lib/auth", () => {
   };
 });
 
+vi.mock("@/lib/redis/redis-kv-store", () => ({
+  RedisKVStore: class MockRedisKVStore<T> {
+    private readonly prefix: string;
+
+    constructor(options: { prefix: string }) {
+      this.prefix = options.prefix;
+    }
+
+    async set(key: string, value: T) {
+      if (this.prefix.includes(":status:")) {
+        exportStatusStore.set(key, value);
+      } else {
+        exportCsvStore.set(key, value as string);
+      }
+      return true;
+    }
+
+    async get(key: string) {
+      if (this.prefix.includes(":status:")) {
+        return (exportStatusStore.get(key) as T | undefined) ?? null;
+      }
+      return ((exportCsvStore.get(key) as T | undefined) ?? null) as T | null;
+    }
+
+    async getAndDelete(key: string) {
+      if (this.prefix.includes(":status:")) {
+        const value = (exportStatusStore.get(key) as T | undefined) ?? null;
+        exportStatusStore.delete(key);
+        return value;
+      }
+      const value = ((exportCsvStore.get(key) as T | undefined) ?? null) as T | null;
+      exportCsvStore.delete(key);
+      return value;
+    }
+
+    async delete(key: string) {
+      if (this.prefix.includes(":status:")) {
+        return exportStatusStore.delete(key);
+      }
+      return exportCsvStore.delete(key);
+    }
+  },
+}));
+
 vi.mock("@/repository/usage-logs", () => {
   return {
     findUsageLogSessionIdSuggestions: vi.fn(async () => []),
-    findUsageLogsBatch: vi.fn(async () => ({ logs: [], nextCursor: null, hasMore: false })),
-    findUsageLogsStats: vi.fn(async () => ({
-      totalRequests: 0,
-      totalCost: 0,
-      totalTokens: 0,
-      totalInputTokens: 0,
-      totalOutputTokens: 0,
-      totalCacheCreationTokens: 0,
-      totalCacheReadTokens: 0,
-      totalCacheCreation5mTokens: 0,
-      totalCacheCreation1hTokens: 0,
-    })),
+    findUsageLogsBatch: findUsageLogsBatchMock,
+    findUsageLogsStats: findUsageLogsStatsMock,
     findUsageLogsWithDetails: findUsageLogsWithDetailsMock,
     getUsedEndpoints: vi.fn(async () => []),
     getUsedModels: vi.fn(async () => []),
@@ -31,6 +69,44 @@ vi.mock("@/repository/usage-logs", () => {
   };
 });
 
+function createSummary(totalRequests = 0) {
+  return {
+    totalRequests,
+    totalCost: 0,
+    totalTokens: 0,
+    totalInputTokens: 0,
+    totalOutputTokens: 0,
+    totalCacheCreationTokens: 0,
+    totalCacheReadTokens: 0,
+    totalCacheCreation5mTokens: 0,
+    totalCacheCreation1hTokens: 0,
+  };
+}
+
+function createLog(overrides: Record<string, unknown> = {}) {
+  return {
+    createdAt: new Date("2026-03-16T00:00:00.000Z"),
+    userName: "u",
+    keyName: "k",
+    providerName: "p",
+    model: "m",
+    originalModel: "om",
+    endpoint: "/v1/messages",
+    statusCode: 200,
+    inputTokens: 1,
+    outputTokens: 2,
+    cacheCreation5mInputTokens: 0,
+    cacheCreation1hInputTokens: 0,
+    cacheReadInputTokens: 0,
+    totalTokens: 3,
+    costUsd: "0",
+    durationMs: 10,
+    sessionId: "s1",
+    providerChain: null,
+    ...overrides,
+  };
+}
+
 function parseCsvLine(line: string): string[] {
   const fields: string[] = [];
   let current = "";
@@ -76,12 +152,28 @@ function parseCsvLine(line: string): string[] {
 
 describe("Usage logs CSV export retryCount", () => {
   beforeEach(() => {
+    vi.resetModules();
     vi.clearAllMocks();
+    vi.useRealTimers();
+    exportStatusStore.clear();
+    exportCsvStore.clear();
     getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
+    findUsageLogsWithDetailsMock.mockResolvedValue({
+      logs: [],
+      total: 0,
+      summary: createSummary(),
+    });
+    findUsageLogsBatchMock.mockResolvedValue({ logs: [], nextCursor: null, hasMore: false });
+    findUsageLogsStatsMock.mockResolvedValue(createSummary());
   });
 
   test("exportUsageLogs: Retry Count 应对齐 getRetryCount(hedge race 为 0)", async () => {
     findUsageLogsWithDetailsMock.mockResolvedValue({
+      logs: [],
+      total: 3,
+      summary: createSummary(3),
+    });
+    findUsageLogsBatchMock.mockResolvedValueOnce({
       logs: [
         {
           createdAt: new Date("2026-03-16T00:00:00.000Z"),
@@ -157,18 +249,8 @@ describe("Usage logs CSV export retryCount", () => {
           ],
         },
       ],
-      total: 3,
-      summary: {
-        totalRequests: 3,
-        totalCost: 0,
-        totalTokens: 9,
-        totalInputTokens: 3,
-        totalOutputTokens: 6,
-        totalCacheCreationTokens: 0,
-        totalCacheReadTokens: 0,
-        totalCacheCreation5mTokens: 0,
-        totalCacheCreation1hTokens: 0,
-      },
+      nextCursor: null,
+      hasMore: false,
     });
 
     const { exportUsageLogs } = await import("@/actions/usage-logs");
@@ -194,4 +276,88 @@ describe("Usage logs CSV export retryCount", () => {
     expect(row2[retryCountIndex]).toBe("1");
     expect(row3[retryCountIndex]).toBe("0");
   });
+
+  test("exportUsageLogs: 按批次全量导出,并拦截前导空白公式注入", async () => {
+    findUsageLogsWithDetailsMock.mockResolvedValue({
+      logs: [],
+      total: 3,
+      summary: createSummary(3),
+    });
+    findUsageLogsBatchMock
+      .mockResolvedValueOnce({
+        logs: [
+          createLog({ sessionId: "s1", model: " =1+1" }),
+          createLog({ sessionId: "s2", model: "+2+2" }),
+        ],
+        nextCursor: { createdAt: "2026-03-16T00:00:01.000000Z", id: 2 },
+        hasMore: true,
+      })
+      .mockResolvedValueOnce({
+        logs: [createLog({ sessionId: "s3", endpoint: " \t@SUM(A1:A2)" })],
+        nextCursor: null,
+        hasMore: false,
+      });
+
+    const { exportUsageLogs } = await import("@/actions/usage-logs");
+    const result = await exportUsageLogs({});
+
+    expect(result.ok).toBe(true);
+    expect(findUsageLogsBatchMock).toHaveBeenCalledTimes(2);
+
+    const csvNoBom = result.data.replace(/^\uFEFF/, "");
+    const lines = csvNoBom
+      .trim()
+      .split("\n")
+      .map((line) => line.replace(/\r$/, ""));
+
+    expect(lines).toHaveLength(4);
+    const header = parseCsvLine(lines[0] ?? "");
+    const modelIndex = header.indexOf("Model");
+    const endpointIndex = header.indexOf("Endpoint");
+    const row1 = parseCsvLine(lines[1] ?? "");
+    const row2 = parseCsvLine(lines[2] ?? "");
+    const row3 = parseCsvLine(lines[3] ?? "");
+
+    expect(row1[modelIndex]).toBe("' =1+1");
+    expect(row2[modelIndex]).toBe("'+2+2");
+    expect(row3[endpointIndex]).toBe("' \t@SUM(A1:A2)");
+  });
+
+  test("startUsageLogsExport: 异步导出任务完成后可轮询并下载", async () => {
+    vi.useFakeTimers();
+    findUsageLogsWithDetailsMock.mockResolvedValue({
+      logs: [],
+      total: 1,
+      summary: createSummary(1),
+    });
+    findUsageLogsBatchMock.mockResolvedValueOnce({
+      logs: [createLog({ sessionId: "job-session" })],
+      nextCursor: null,
+      hasMore: false,
+    });
+
+    const { downloadUsageLogsExport, getUsageLogsExportStatus, startUsageLogsExport } =
+      await import("@/actions/usage-logs");
+
+    const startResult = await startUsageLogsExport({});
+    expect(startResult.ok).toBe(true);
+    const jobId = startResult.data.jobId;
+
+    const queuedStatus = await getUsageLogsExportStatus(jobId);
+    expect(queuedStatus.ok).toBe(true);
+    expect(queuedStatus.data.status).toBe("queued");
+
+    await vi.runAllTimersAsync();
+
+    const completedStatus = await getUsageLogsExportStatus(jobId);
+    expect(completedStatus.ok).toBe(true);
+    expect(completedStatus.data.status).toBe("completed");
+    expect(completedStatus.data.progressPercent).toBe(100);
+    expect(completedStatus.data.processedRows).toBe(1);
+
+    const downloadResult = await downloadUsageLogsExport(jobId);
+    expect(downloadResult.ok).toBe(true);
+    expect(downloadResult.data).toContain("Session ID");
+    expect(downloadResult.data).toContain("job-session");
+  });
 });

+ 225 - 0
tests/unit/dashboard-logs-export-progress-ui.test.tsx

@@ -0,0 +1,225 @@
+/**
+ * @vitest-environment happy-dom
+ */
+
+import type { ReactNode } from "react";
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import { NextIntlClientProvider } from "next-intl";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import { UsageLogsFilters } from "@/app/[locale]/dashboard/logs/_components/usage-logs-filters";
+import dashboardMessages from "../../messages/en/dashboard.json";
+
+const {
+  downloadUsageLogsExportMock,
+  getUsageLogsExportStatusMock,
+  startUsageLogsExportMock,
+  toastErrorMock,
+  toastSuccessMock,
+} = vi.hoisted(() => ({
+  startUsageLogsExportMock: vi.fn(),
+  getUsageLogsExportStatusMock: vi.fn(),
+  downloadUsageLogsExportMock: vi.fn(),
+  toastSuccessMock: vi.fn(),
+  toastErrorMock: vi.fn(),
+}));
+
+vi.mock("@/actions/usage-logs", () => ({
+  startUsageLogsExport: startUsageLogsExportMock,
+  getUsageLogsExportStatus: getUsageLogsExportStatusMock,
+  downloadUsageLogsExport: downloadUsageLogsExportMock,
+}));
+
+vi.mock("sonner", () => ({
+  toast: {
+    success: toastSuccessMock,
+    error: toastErrorMock,
+  },
+}));
+
+vi.mock("@/app/[locale]/dashboard/logs/_components/filters/active-filters-display", () => ({
+  ActiveFiltersDisplay: () => <div data-testid="active-filters-display" />,
+}));
+
+vi.mock("@/app/[locale]/dashboard/logs/_components/filters/filter-section", () => ({
+  FilterSection: ({ children }: { children: ReactNode }) => <div>{children}</div>,
+}));
+
+vi.mock("@/app/[locale]/dashboard/logs/_components/filters/identity-filters", () => ({
+  IdentityFilters: () => <div data-testid="identity-filters" />,
+}));
+
+vi.mock("@/app/[locale]/dashboard/logs/_components/filters/quick-filters-bar", () => ({
+  QuickFiltersBar: () => <div data-testid="quick-filters-bar" />,
+}));
+
+vi.mock("@/app/[locale]/dashboard/logs/_components/filters/request-filters", () => ({
+  RequestFilters: ({
+    onFiltersChange,
+  }: {
+    onFiltersChange: (filters: Record<string, unknown>) => void;
+  }) => (
+    <button
+      type="button"
+      data-testid="request-filters"
+      onClick={() => onFiltersChange({ sessionId: "draft-session" })}
+    >
+      Draft Request Filters
+    </button>
+  ),
+}));
+
+vi.mock("@/app/[locale]/dashboard/logs/_components/filters/status-filters", () => ({
+  StatusFilters: () => <div data-testid="status-filters" />,
+}));
+
+vi.mock("@/app/[locale]/dashboard/logs/_components/filters/time-filters", () => ({
+  TimeFilters: () => <div data-testid="time-filters" />,
+}));
+
+function renderWithIntl(node: ReactNode) {
+  const container = document.createElement("div");
+  document.body.appendChild(container);
+  const root = createRoot(container);
+
+  act(() => {
+    root.render(
+      <NextIntlClientProvider
+        locale="en"
+        messages={{ dashboard: dashboardMessages }}
+        timeZone="UTC"
+      >
+        {node}
+      </NextIntlClientProvider>
+    );
+  });
+
+  return {
+    container,
+    unmount: () => {
+      act(() => root.unmount());
+      container.remove();
+    },
+  };
+}
+
+async function actClick(el: Element | null) {
+  if (!el) throw new Error("element not found");
+  await act(async () => {
+    el.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+  });
+}
+
+async function flushPromises() {
+  await act(async () => {
+    await Promise.resolve();
+    await Promise.resolve();
+  });
+}
+
+describe("UsageLogsFilters export progress UI", () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    vi.useFakeTimers();
+    globalThis.URL.createObjectURL = vi.fn(() => "blob:usage-logs");
+    globalThis.URL.revokeObjectURL = vi.fn();
+    HTMLAnchorElement.prototype.click = vi.fn();
+  });
+
+  test("shows export progress while polling and downloads when completed", async () => {
+    startUsageLogsExportMock.mockResolvedValue({ ok: true, data: { jobId: "job-1" } });
+    getUsageLogsExportStatusMock
+      .mockResolvedValueOnce({
+        ok: true,
+        data: {
+          jobId: "job-1",
+          status: "running",
+          processedRows: 50,
+          totalRows: 200,
+          progressPercent: 25,
+        },
+      })
+      .mockResolvedValueOnce({
+        ok: true,
+        data: {
+          jobId: "job-1",
+          status: "completed",
+          processedRows: 200,
+          totalRows: 200,
+          progressPercent: 100,
+        },
+      });
+    downloadUsageLogsExportMock.mockResolvedValue({ ok: true, data: "\uFEFFTime,User\n" });
+
+    const { container, unmount } = renderWithIntl(
+      <UsageLogsFilters
+        isAdmin={true}
+        providers={[]}
+        initialKeys={[]}
+        filters={{}}
+        onChange={() => {}}
+        onReset={() => {}}
+      />
+    );
+
+    const exportButton = Array.from(container.querySelectorAll("button")).find(
+      (button) => (button.textContent || "").trim() === "Export"
+    );
+
+    await actClick(exportButton ?? null);
+    await flushPromises();
+
+    expect(container.textContent).toContain("Exported 50 / 200");
+    expect(container.textContent).toContain("25%");
+
+    await act(async () => {
+      await vi.advanceTimersByTimeAsync(800);
+    });
+    await flushPromises();
+
+    expect(downloadUsageLogsExportMock).toHaveBeenCalledWith("job-1");
+    expect(toastSuccessMock).toHaveBeenCalledWith("Export completed successfully");
+    expect(toastErrorMock).not.toHaveBeenCalled();
+
+    unmount();
+  });
+
+  test("exports the applied filters instead of unapplied local draft filters", async () => {
+    startUsageLogsExportMock.mockResolvedValue({ ok: true, data: { jobId: "job-2" } });
+    getUsageLogsExportStatusMock.mockResolvedValueOnce({
+      ok: true,
+      data: {
+        jobId: "job-2",
+        status: "completed",
+        processedRows: 1,
+        totalRows: 1,
+        progressPercent: 100,
+      },
+    });
+    downloadUsageLogsExportMock.mockResolvedValue({ ok: true, data: "\uFEFFTime,User\n" });
+
+    const { container, unmount } = renderWithIntl(
+      <UsageLogsFilters
+        isAdmin={true}
+        providers={[]}
+        initialKeys={[]}
+        filters={{ sessionId: "applied-session" }}
+        onChange={() => {}}
+        onReset={() => {}}
+      />
+    );
+
+    await actClick(container.querySelector("[data-testid='request-filters']"));
+
+    const exportButton = Array.from(container.querySelectorAll("button")).find(
+      (button) => (button.textContent || "").trim() === "Export"
+    );
+
+    await actClick(exportButton ?? null);
+    await flushPromises();
+
+    expect(startUsageLogsExportMock).toHaveBeenCalledWith({ sessionId: "applied-session" });
+
+    unmount();
+  });
+});