From 962fc301fe9e33ed496998d1517c413a863489c7 Mon Sep 17 00:00:00 2001 From: Zic-Wang <1281265929@qq.com> Date: Sat, 7 Feb 2026 02:37:50 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=B8=A0=E9=81=93?= =?UTF-8?q?=E7=94=A8=E9=87=8F=E7=BB=9F=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 通过usage的auth_index去匹配auth_files的auth_index 如果无匹配,则使用usage的source去匹配ai-provider的api-key Database增加:channel列 统计内容 请求次数 token 费用 成功率 输入 缓存 输出 思考 token数 auth认证以该渠道名称显示,如antigravity,如果有多账号可展开查看账号用量 ai-provider渠道显示渠道名称,若无渠道名称则显示Base_url --- app/api/channels/route.ts | 161 +++++++++ app/api/sync/route.ts | 116 ++++++- app/channels/page.tsx | 472 ++++++++++++++++++++++++++ app/components/Sidebar.tsx | 3 +- drizzle/0002_add_channel_tracking.sql | 3 + drizzle/meta/0002_snapshot.json | 206 +++++++++++ drizzle/meta/_journal.json | 7 + lib/db/schema.ts | 2 + lib/usage.ts | 27 +- next-env.d.ts | 2 +- scripts/migrate.mjs | 39 ++- 11 files changed, 1028 insertions(+), 10 deletions(-) create mode 100644 app/api/channels/route.ts create mode 100644 app/channels/page.tsx create mode 100644 drizzle/0002_add_channel_tracking.sql create mode 100644 drizzle/meta/0002_snapshot.json diff --git a/app/api/channels/route.ts b/app/api/channels/route.ts new file mode 100644 index 0000000..e586dd9 --- /dev/null +++ b/app/api/channels/route.ts @@ -0,0 +1,161 @@ +import { NextResponse } from "next/server"; +import { and, sql, gte, lte } from "drizzle-orm"; +import type { SQL } from "drizzle-orm"; +import { db } from "@/lib/db/client"; +import { usageRecords, modelPrices } from "@/lib/db/schema"; +import { estimateCost, priceMap } from "@/lib/usage"; + +type ChannelAggRow = { + channel: string | null; + requests: number; + tokens: number; + inputTokens: number; + outputTokens: number; + reasoningTokens: number; + cachedTokens: number; + errorCount: number; +}; + +type ChannelModelAggRow = { + channel: string | null; + model: string; + requests: number; + inputTokens: number; + outputTokens: number; + reasoningTokens: number; + cachedTokens: number; +}; + +type PriceRow = typeof modelPrices.$inferSelect; + +function toNumber(value: unknown): number { + const num = Number(value ?? 0); + return Number.isFinite(num) ? num : 0; +} + +function parseDateInput(value?: string | Date | null) { + if (!value) return null; + const date = value instanceof Date ? value : new Date(value); + return Number.isFinite(date.getTime()) ? date : null; +} + +function withDayStart(date: Date) { + const d = new Date(date); + d.setHours(0, 0, 0, 0); + return d; +} + +function withDayEnd(date: Date) { + const d = new Date(date); + d.setHours(23, 59, 59, 999); + return d; +} + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const startParam = searchParams.get("start"); + const endParam = searchParams.get("end"); + const daysParam = searchParams.get("days"); + + const startDate = parseDateInput(startParam); + const endDate = parseDateInput(endParam); + const hasCustomRange = startDate && endDate && endDate >= startDate; + + const DAY_MS = 24 * 60 * 60 * 1000; + const days = hasCustomRange + ? Math.max(1, Math.round((withDayEnd(endDate).getTime() - withDayStart(startDate).getTime()) / DAY_MS) + 1) + : Math.min(Math.max(Math.floor(Number(daysParam) || 14), 1), 90); + + const since = hasCustomRange ? withDayStart(startDate!) : new Date(Date.now() - days * DAY_MS); + const until = hasCustomRange ? withDayEnd(endDate!) : undefined; + + const whereParts: SQL[] = [gte(usageRecords.occurredAt, since)]; + if (until) whereParts.push(lte(usageRecords.occurredAt, until)); + const whereClause = whereParts.length ? and(...whereParts) : undefined; + + try { + // Fetch aggregated channel statistics + const channelAggRows: ChannelAggRow[] = await db + .select({ + channel: usageRecords.channel, + requests: sql`count(*)`, + tokens: sql`sum(${usageRecords.totalTokens})`, + inputTokens: sql`sum(${usageRecords.inputTokens})`, + outputTokens: sql`sum(${usageRecords.outputTokens})`, + reasoningTokens: sql`coalesce(sum(${usageRecords.reasoningTokens}), 0)`, + cachedTokens: sql`coalesce(sum(${usageRecords.cachedTokens}), 0)`, + errorCount: sql`sum(case when ${usageRecords.isError} then 1 else 0 end)` + }) + .from(usageRecords) + .where(whereClause) + .groupBy(usageRecords.channel) + .orderBy(sql`count(*) desc`); + + // Fetch channel-model breakdown for cost calculation + const channelModelAggRows: ChannelModelAggRow[] = await db + .select({ + channel: usageRecords.channel, + model: usageRecords.model, + requests: sql`count(*)`, + inputTokens: sql`sum(${usageRecords.inputTokens})`, + outputTokens: sql`sum(${usageRecords.outputTokens})`, + reasoningTokens: sql`coalesce(sum(${usageRecords.reasoningTokens}), 0)`, + cachedTokens: sql`coalesce(sum(${usageRecords.cachedTokens}), 0)` + }) + .from(usageRecords) + .where(whereClause) + .groupBy(usageRecords.channel, usageRecords.model); + + // Fetch pricing information + const priceRows: PriceRow[] = await db.select().from(modelPrices); + const prices = priceMap( + priceRows.map((p: PriceRow) => ({ + model: p.model, + inputPricePer1M: Number(p.inputPricePer1M), + cachedInputPricePer1M: Number(p.cachedInputPricePer1M), + outputPricePer1M: Number(p.outputPricePer1M) + })) + ); + + // Calculate costs per channel + const channelCostMap = new Map(); + for (const row of channelModelAggRows) { + const channelKey = row.channel ?? "未知渠道"; + const cost = estimateCost( + { + inputTokens: toNumber(row.inputTokens), + cachedTokens: toNumber(row.cachedTokens), + outputTokens: toNumber(row.outputTokens), + reasoningTokens: toNumber(row.reasoningTokens) + }, + row.model, + prices + ); + channelCostMap.set(channelKey, (channelCostMap.get(channelKey) ?? 0) + cost); + } + + // Build response + const channels = channelAggRows.map((row) => { + const channelKey = row.channel ?? "未知渠道"; + return { + channel: channelKey, + requests: toNumber(row.requests), + totalTokens: toNumber(row.tokens), + inputTokens: toNumber(row.inputTokens), + outputTokens: toNumber(row.outputTokens), + reasoningTokens: toNumber(row.reasoningTokens), + cachedTokens: toNumber(row.cachedTokens), + errorCount: toNumber(row.errorCount), + cost: Number((channelCostMap.get(channelKey) ?? 0).toFixed(4)) + }; + }); + + return NextResponse.json({ channels, days }); + } catch (error) { + console.error("Error fetching channel statistics:", error); + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 } + ); + } +} diff --git a/app/api/sync/route.ts b/app/api/sync/route.ts index 9e12cea..bba3c1c 100644 --- a/app/api/sync/route.ts +++ b/app/api/sync/route.ts @@ -47,6 +47,114 @@ async function isAuthorized(request: Request) { return false; } +async function fetchAuthIndexMapping(): Promise> { + // 从 CLIProxyAPI 的 /auth-files 接口获取 auth_index -> 渠道名的映射 + try { + const baseUrl = config.cliproxy.baseUrl.replace(/\/$/, ""); + const url = baseUrl.endsWith("/v0/management") + ? `${baseUrl}/auth-files` + : `${baseUrl}/v0/management/auth-files`; + + const res = await fetch(url, { + headers: { + Authorization: `Bearer ${config.cliproxy.apiKey}`, + "Content-Type": "application/json" + }, + cache: "no-store" + }); + + const map = new Map(); + if (!res.ok) { + console.warn(`Failed to fetch auth files mapping: ${res.status} ${res.statusText}`); + return map; + } + + const data = (await res.json()) as { files?: Array<{ auth_index?: string; provider?: string; name?: string; email?: string }> }; + for (const file of data.files ?? []) { + if (file.auth_index) { + // 用 "provider/name" 作为可读的渠道名 + const channelName = [file.provider, file.name] + .filter(Boolean) + .join("/"); + map.set(String(file.auth_index), channelName || file.auth_index); + } + } + return map; + } catch (error) { + console.warn("Error fetching auth index mapping:", error); + return new Map(); + } +} + +async function fetchApiKeyChannelMapping(): Promise> { + // 从 4 个 API key 端点获取 api-key -> 渠道名的映射 + // 当 auth_index 无法通过 /auth-files 匹配时,用 source 匹配这些 api-key + try { + const baseUrl = config.cliproxy.baseUrl.replace(/\/$/, ""); + const prefix = baseUrl.endsWith("/v0/management") ? baseUrl : `${baseUrl}/v0/management`; + const headers = { + Authorization: `Bearer ${config.cliproxy.apiKey}`, + "Content-Type": "application/json" + }; + + const endpoints = [ + "openai-compatibility", + "claude-api-key", + "codex-api-key", + "gemini-api-key" + ] as const; + + const results = await Promise.allSettled( + endpoints.map((ep) => + fetch(`${prefix}/${ep}`, { headers, cache: "no-store" }).then((r) => + r.ok ? r.json() : null + ) + ) + ); + + const map = new Map(); + + for (let i = 0; i < endpoints.length; i++) { + const result = results[i]; + if (result.status !== "fulfilled" || !result.value) continue; + const data = result.value; + const ep = endpoints[i]; + + if (ep === "openai-compatibility") { + // 结构: { "openai-compatibility": [{ name, api-key-entries: [{ api-key }] }] } + const entries = data["openai-compatibility"] as Array<{ + name?: string; + "api-key-entries"?: Array<{ "api-key"?: string }>; + }> | undefined; + for (const entry of entries ?? []) { + const channelName = entry.name || ep; + for (const keyEntry of entry["api-key-entries"] ?? []) { + if (keyEntry["api-key"] && !map.has(keyEntry["api-key"])) { + map.set(keyEntry["api-key"], channelName); + } + } + } + } else { + // 结构: { "": [{ api-key, base-url }] } + const entries = data[ep] as Array<{ + "api-key"?: string; + "base-url"?: string; + }> | undefined; + for (const entry of entries ?? []) { + if (entry["api-key"] && !map.has(entry["api-key"])) { + map.set(entry["api-key"], entry["base-url"] || ep); + } + } + } + } + + return map; + } catch (error) { + console.warn("Error fetching API key channel mapping:", error); + return new Map(); + } +} + async function performSync(request: Request) { if (!config.password && !config.cronSecret && !PASSWORD) return missingPassword(); if (!(await isAuthorized(request))) return unauthorized(); @@ -87,7 +195,13 @@ async function performSync(request: Request) { ); } - const rows = toUsageRecords(payload, pulledAt); + // 获取 auth_index 到渠道名的映射(并行请求) + const [authMap, apiKeyMap] = await Promise.all([ + fetchAuthIndexMapping(), + fetchApiKeyChannelMapping() + ]); + + const rows = toUsageRecords(payload, pulledAt, authMap, apiKeyMap); if (rows.length === 0) { return NextResponse.json({ status: "ok", inserted: 0, message: "No usage data" }); diff --git a/app/channels/page.tsx b/app/channels/page.tsx new file mode 100644 index 0000000..ffcd25e --- /dev/null +++ b/app/channels/page.tsx @@ -0,0 +1,472 @@ +"use client"; + +import { useEffect, useState, useCallback, useMemo } from "react"; +import { formatCurrency, formatNumberWithCommas } from "@/lib/utils"; +import { Activity, RefreshCw, ChevronDown, ChevronRight, Key, Users } from "lucide-react"; + +type ChannelStat = { + channel: string; + requests: number; + totalTokens: number; + inputTokens: number; + outputTokens: number; + reasoningTokens: number; + cachedTokens: number; + errorCount: number; + cost: number; +}; + +type ChannelAPIResponse = { + channels: ChannelStat[]; + days: number; +}; + +type ChannelGroup = { + name: string; + type: "auth" | "apikey"; + channels: ChannelStat[]; + total: Omit; +}; + +function aggregateStats(channels: ChannelStat[]): Omit { + return channels.reduce( + (acc, ch) => ({ + requests: acc.requests + ch.requests, + totalTokens: acc.totalTokens + ch.totalTokens, + inputTokens: acc.inputTokens + ch.inputTokens, + outputTokens: acc.outputTokens + ch.outputTokens, + reasoningTokens: acc.reasoningTokens + ch.reasoningTokens, + cachedTokens: acc.cachedTokens + ch.cachedTokens, + errorCount: acc.errorCount + ch.errorCount, + cost: acc.cost + ch.cost + }), + { requests: 0, totalTokens: 0, inputTokens: 0, outputTokens: 0, reasoningTokens: 0, cachedTokens: 0, errorCount: 0, cost: 0 } + ); +} + +function groupChannels(channels: ChannelStat[]): ChannelGroup[] { + const authGroups = new Map(); + const apiKeyChannels: ChannelStat[] = []; + + for (const ch of channels) { + const name = ch.channel; + const slashIdx = name.indexOf("/"); + const looksLikeUrl = name.startsWith("http://") || name.startsWith("https://"); + const looksLikeHex = /^[0-9a-f]{8,}$/i.test(name); + + if (slashIdx > 0 && !looksLikeUrl && !looksLikeHex) { + const provider = name.slice(0, slashIdx); + const existing = authGroups.get(provider) || []; + existing.push(ch); + authGroups.set(provider, existing); + } else { + apiKeyChannels.push(ch); + } + } + + const groups: ChannelGroup[] = []; + + const authEntries = [...authGroups.entries()] + .map(([name, chs]) => ({ name, channels: chs, total: aggregateStats(chs) })) + .sort((a, b) => b.total.requests - a.total.requests); + + for (const entry of authEntries) { + groups.push({ name: entry.name, type: "auth", channels: entry.channels, total: entry.total }); + } + + for (const ch of apiKeyChannels) { + groups.push({ + name: ch.channel, + type: "apikey", + channels: [ch], + total: { requests: ch.requests, totalTokens: ch.totalTokens, inputTokens: ch.inputTokens, outputTokens: ch.outputTokens, reasoningTokens: ch.reasoningTokens, cachedTokens: ch.cachedTokens, errorCount: ch.errorCount, cost: ch.cost } + }); + } + + return groups; +} + +function fmtRate(requests: number, errorCount: number): string { + if (requests === 0) return "-"; + const rate = ((requests - errorCount) / requests) * 100; + if (rate === 100) return "100%"; + return rate.toFixed(1) + "%"; +} + +function rateColor(requests: number, errorCount: number): string { + if (requests === 0) return "text-slate-500"; + const rate = ((requests - errorCount) / requests) * 100; + if (rate >= 99) return "text-emerald-400"; + if (rate >= 95) return "text-amber-400"; + return "text-red-400"; +} + +function StatCard({ label, value, color }: { label: string; value: string; color?: string }) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} + +function TokenBar({ input, output, reasoning, cached, total }: { input: number; output: number; reasoning: number; cached: number; total: number }) { + if (total === 0) return null; + const segments = [ + { value: input - cached, color: "bg-rose-400", label: "输入" }, + { value: cached, color: "bg-purple-400", label: "缓存" }, + { value: output, color: "bg-emerald-400", label: "输出" }, + { value: reasoning, color: "bg-amber-400", label: "思考" }, + ].filter(s => s.value > 0); + + return ( +
+ {segments.map((seg, i) => ( +
+ ))} +
+ ); +} + +function TokenLegend() { + return ( +
+ 输入 + 缓存 + 输出 + 思考 +
+ ); +} + +/* Fixed column widths for alignment across all rows */ +const COL = { + arrow: "w-5 shrink-0", + icon: "w-9 shrink-0", + requests: "w-[72px] text-right shrink-0", + tokens: "w-[90px] text-right shrink-0", + cost: "w-[80px] text-right shrink-0", + rate: "w-[56px] text-right shrink-0", +}; + +export default function ChannelsPage() { + const [channels, setChannels] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [days, setDays] = useState(14); + const [expandedGroups, setExpandedGroups] = useState>(new Set()); + const [sortBy, setSortBy] = useState<"requests" | "totalTokens" | "cost">("requests"); + + const fetchChannels = useCallback(async () => { + setLoading(true); + setError(null); + try { + const response = await fetch(`/api/channels?days=${days}`); + if (!response.ok) throw new Error(`Failed to fetch: ${response.statusText}`); + const data: ChannelAPIResponse = await response.json(); + setChannels(data.channels || []); + } catch (err) { + setError(err instanceof Error ? err.message : "Unknown error"); + } finally { + setLoading(false); + } + }, [days]); + + useEffect(() => { + fetchChannels(); + }, [fetchChannels]); + + const toggleGroup = (name: string) => { + setExpandedGroups(prev => { + const next = new Set(prev); + if (next.has(name)) next.delete(name); + else next.add(name); + return next; + }); + }; + + const groups = useMemo(() => { + const g = groupChannels(channels); + return g.sort((a, b) => b.total[sortBy] - a.total[sortBy]); + }, [channels, sortBy]); + + const totalStats = useMemo(() => aggregateStats(channels), [channels]); + + return ( +
+ {/* Header */} +
+
+

+ + 渠道统计 +

+

按认证渠道查看用量和费用统计

+
+ +
+ + {/* Time Range */} +
+ 时间范围 + {[1, 7, 14, 30, 90].map((d) => ( + + ))} +
+ + {/* Error */} + {error && ( +
+
+

加载失败

+

{error}

+
+
+ )} + + {/* Stat Cards */} +
+ {loading ? ( + <> + {[...Array(4)].map((_, i) => ( +
+ ))} + + ) : ( + <> + + + +
+
总费用
+
{formatCurrency(totalStats.cost)}
+
+ + )} +
+ + {/* Sort Options + Token Legend */} + {!loading && channels.length > 0 && ( +
+
+ 排序 + {([["requests", "请求数"], ["totalTokens", "Token"], ["cost", "费用"]] as const).map(([key, label]) => ( + + ))} +
+ +
+ )} + + {/* Column Headers */} + {!loading && channels.length > 0 && ( +
+
+
+
+
+
请求
+
Tokens
+
费用
+
成功率
+
+
+ )} + + {/* Channel Groups */} +
+ {loading ? ( +
+ +

加载中...

+
+ ) : channels.length === 0 ? ( +
+

暂无数据

+
+ ) : ( + groups.map((group) => { + const isAuth = group.type === "auth"; + const isExpanded = expandedGroups.has(group.name); + const hasMultiple = group.channels.length > 1; + const canExpand = isAuth && hasMultiple; + + return ( +
+ {/* Group Header */} +
canExpand && toggleGroup(group.name)} + > + {/* Arrow - always occupies space for alignment */} +
+ {canExpand && ( + isExpanded + ? + : + )} +
+ + {/* Icon */} +
+ {isAuth ? : } +
+ + {/* Name & Badge & Token Bar */} +
+
+ {group.name} + {canExpand && ( + + {group.channels.length} 账号 + + )} +
+ +
+ + {/* Stats - Desktop */} +
+
+
{formatNumberWithCommas(group.total.requests)}
+
+
+
{formatNumberWithCommas(group.total.totalTokens)}
+
+
+
{formatCurrency(group.total.cost)}
+
+
+
+ {fmtRate(group.total.requests, group.total.errorCount)} +
+
+
+ + {/* Stats - Mobile */} +
+
{formatNumberWithCommas(group.total.requests)} 次
+
{formatCurrency(group.total.cost)}
+
+
+ + {/* Expanded Sub-channels */} + {canExpand && isExpanded && ( +
+ {group.channels + .sort((a, b) => b.requests - a.requests) + .map((ch, idx) => { + const accountName = ch.channel.includes("/") + ? ch.channel.slice(ch.channel.indexOf("/") + 1) + : ch.channel; + return ( +
+ {/* Arrow placeholder */} +
+ {/* Icon placeholder */} +
+
+ {accountName} + +
+ {/* Stats - Desktop */} +
+
+
{formatNumberWithCommas(ch.requests)}
+
+
+
{formatNumberWithCommas(ch.totalTokens)}
+
+
+
{formatCurrency(ch.cost)}
+
+
+
+ {fmtRate(ch.requests, ch.errorCount)} +
+
+
+ {/* Stats - Mobile */} +
+
{formatNumberWithCommas(ch.requests)} 次
+
{formatCurrency(ch.cost)}
+
+
+ ); + })} +
+ )} +
+ ); + }) + )} +
+
+ ); +} diff --git a/app/components/Sidebar.tsx b/app/components/Sidebar.tsx index 30efa89..e744abb 100644 --- a/app/components/Sidebar.tsx +++ b/app/components/Sidebar.tsx @@ -2,13 +2,14 @@ import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; -import { BarChart3, FileText, Activity, LogOut, Github, ExternalLink, Table } from "lucide-react"; +import { BarChart3, FileText, Activity, LogOut, Github, ExternalLink, Table, Menu, X, Radio } from "lucide-react"; import { useEffect, useState, useCallback } from "react"; import { Modal } from "./Modal"; const links = [ { href: "/", label: "仪表盘", icon: BarChart3 }, { href: "/explore", label: "数据探索", icon: Activity }, + { href: "/channels", label: "渠道统计", icon: Radio }, { href: "/records", label: "调用记录", icon: Table }, { href: "/logs", label: "日志", icon: FileText } ]; diff --git a/drizzle/0002_add_channel_tracking.sql b/drizzle/0002_add_channel_tracking.sql new file mode 100644 index 0000000..3ec4bce --- /dev/null +++ b/drizzle/0002_add_channel_tracking.sql @@ -0,0 +1,3 @@ +ALTER TABLE "usage_records" + ADD COLUMN "auth_index" text, + ADD COLUMN "channel" text; diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..78c1b79 --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,206 @@ +{ + "id": "78a9f2c3-1d5e-4b8a-9c0f-3e7d6f8a1b2c", + "prevId": "9b66308b-2fbe-4a29-b0c3-4c2a6a0d6a18", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.model_prices": { + "name": "model_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_price_per_1m": { + "name": "input_price_per_1m", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": true + }, + "cached_input_price_per_1m": { + "name": "cached_input_price_per_1m", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "output_price_per_1m": { + "name": "output_price_per_1m", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "model_prices_model_unique": { + "name": "model_prices_model_unique", + "nullsNotDistinct": false, + "columns": [ + "model" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_records": { + "name": "usage_records", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "synced_at": { + "name": "synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "route": { + "name": "route", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_index": { + "name": "auth_index", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total_tokens": { + "name": "total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reasoning_tokens": { + "name": "reasoning_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cached_tokens": { + "name": "cached_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_error": { + "name": "is_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "raw": { + "name": "raw", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "usage_records_occurred_route_model_idx": { + "name": "usage_records_occurred_route_model_idx", + "columns": [ + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "route", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "views": {}, + "roles": {}, + "policies": {}, + "functions": {}, + "materializedViews": {}, + "types": {}, + "extensions": {} +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 4c9464f..79af1aa 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1769060000000, "tag": "0001_remove_request_counts", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1738800000000, + "tag": "0002_add_channel_tracking", + "breakpoints": true } ] } \ No newline at end of file diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 17d07fd..f7e0654 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -17,6 +17,8 @@ export const usageRecords = pgTable( syncedAt: timestamp("synced_at", { withTimezone: true }).defaultNow().notNull(), route: text("route").notNull(), model: text("model").notNull(), + authIndex: text("auth_index"), + channel: text("channel"), totalTokens: integer("total_tokens").notNull(), inputTokens: integer("input_tokens").notNull(), outputTokens: integer("output_tokens").notNull(), diff --git a/lib/usage.ts b/lib/usage.ts index af488b3..d5a03ef 100644 --- a/lib/usage.ts +++ b/lib/usage.ts @@ -13,13 +13,12 @@ const tokensSchema = z.object({ const detailSchema = z.object({ timestamp: z.string().optional(), source: z.string().optional(), - // auth_index may arrive as non-numeric string; drop invalid values instead of failing parse + // auth_index is a hex string (16 characters from SHA-256 hash) auth_index: z .preprocess((value) => { if (value === undefined || value === null) return undefined; - const num = Number(value); - return Number.isNaN(num) ? undefined : num; - }, z.number().optional()), + return String(value); + }, z.string().optional()), tokens: tokensSchema.optional(), failed: z.boolean().optional(), // 兼容旧格式 @@ -87,7 +86,12 @@ export function parseUsagePayload(json: unknown): UsageResponse { return responseSchema.parse(json); } -export function toUsageRecords(payload: UsageResponse, pulledAt: Date = new Date()): UsageRecordInsert[] { +export function toUsageRecords( + payload: UsageResponse, + pulledAt: Date = new Date(), + authMap?: Map, + apiKeyMap?: Map +): UsageRecordInsert[] { const apis = payload.usage?.apis as Record | undefined; if (!apis) return []; @@ -104,12 +108,25 @@ export function toUsageRecords(payload: UsageResponse, pulledAt: Date = new Date const tokenSlice = parseDetailTokens(detail); const occurredAt = parseDetailTimestamp(detail, pulledAt); const success = isDetailSuccess(detail); + const authIdx = detail.auth_index ?? undefined; + const source = detail.source ?? undefined; + let channel: string | undefined = authIdx && authMap?.get(authIdx) + ? authMap.get(authIdx) + : undefined; + if (!channel && source && apiKeyMap?.get(source)) { + channel = apiKeyMap.get(source); + } + if (!channel) { + channel = authIdx; + } rows.push({ occurredAt, syncedAt: pulledAt, route, model, + authIndex: authIdx ?? null, + channel: channel ?? null, totalTokens: tokenSlice.totalTokens, inputTokens: tokenSlice.inputTokens, outputTokens: tokenSlice.outputTokens, diff --git a/next-env.d.ts b/next-env.d.ts index c4b7818..9edff1c 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/scripts/migrate.mjs b/scripts/migrate.mjs index 33e93fe..f960334 100644 --- a/scripts/migrate.mjs +++ b/scripts/migrate.mjs @@ -1,10 +1,26 @@ #!/usr/bin/env node import { createHash } from "node:crypto"; -import { readFileSync } from "node:fs"; +import { readFileSync, existsSync } from "node:fs"; import { drizzle } from "drizzle-orm/vercel-postgres"; import { createPool } from "@vercel/postgres"; import { migrate } from "drizzle-orm/vercel-postgres/migrator"; +// 加载 .env.local(Next.js 不会为独立 Node 脚本加载) +for (const envFile of [".env.local", ".env"]) { + if (existsSync(envFile)) { + for (const line of readFileSync(envFile, "utf8").split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const idx = trimmed.indexOf("="); + if (idx === -1) continue; + const key = trimmed.slice(0, idx).trim(); + const val = trimmed.slice(idx + 1).trim(); + if (!process.env[key]) process.env[key] = val; + } + break; + } +} + const pool = createPool({ connectionString: process.env.DATABASE_URL || process.env.POSTGRES_URL }); @@ -54,15 +70,34 @@ async function runMigrations() { if (tableExists.rows.length > 0) { // 找出 0000 迁移 const initialMigration = allMigrations.find((m) => m.tag.startsWith("0000_")); - + if (initialMigration && !existingHashes.has(initialMigration.hash)) { console.log("检测到表已存在但迁移未标记,正在标记..."); await pool.query( "INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES ($1, $2)", [initialMigration.hash, initialMigration.createdAt] ); + existingHashes.add(initialMigration.hash); console.log("✓ 已标记 0000 迁移"); } + + // 检查 0001 迁移(DROP total_requests/success_count/failure_count) + // 如果这些列已不存在,说明 0001 已在结构上生效,需要标记 + const migration0001 = allMigrations.find((m) => m.tag.startsWith("0001_")); + if (migration0001 && !existingHashes.has(migration0001.hash)) { + const columnsResult = await pool.query( + "SELECT column_name FROM information_schema.columns WHERE table_name = 'usage_records' AND column_name IN ('total_requests', 'success_count', 'failure_count')" + ); + if (columnsResult.rows.length === 0) { + console.log("检测到 0001 迁移已在结构上生效但未标记,正在标记..."); + await pool.query( + "INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES ($1, $2)", + [migration0001.hash, migration0001.createdAt] + ); + existingHashes.add(migration0001.hash); + console.log("✓ 已标记 0001 迁移"); + } + } } console.log("执行数据库迁移..."); From 513f2c9e7756fffa39fb36d1439fc3f18f31292b Mon Sep 17 00:00:00 2001 From: Zic-Wang <1281265929@qq.com> Date: Sat, 7 Feb 2026 19:44:03 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=E8=B0=83=E7=94=A8=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E9=A1=B5=E9=9D=A2=E5=A2=9E=E5=8A=A0=E6=B8=A0=E9=81=93?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E4=BB=A5=E6=94=AF=E6=8C=81=E6=B8=A0=E9=81=93?= =?UTF-8?q?=E8=BF=87=E6=BB=A4=E5=92=8C=E7=BB=9F=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/records/route.ts | 2 ++ app/records/page.tsx | 54 ++++++++++++++++++++++++++++++++++++---- lib/queries/records.ts | 29 ++++++++++++++++++--- 3 files changed, 77 insertions(+), 8 deletions(-) diff --git a/app/api/records/route.ts b/app/api/records/route.ts index 8b12d72..15e5923 100644 --- a/app/api/records/route.ts +++ b/app/api/records/route.ts @@ -30,6 +30,7 @@ export async function GET(request: Request) { const cursor = searchParams.get("cursor"); const model = searchParams.get("model"); const route = searchParams.get("route"); + const channel = searchParams.get("channel"); const start = searchParams.get("start"); const end = searchParams.get("end"); const includeFilters = searchParams.get("includeFilters") === "1"; @@ -43,6 +44,7 @@ export async function GET(request: Request) { cursor, model: model || undefined, route: route || undefined, + channel: channel || undefined, start, end, includeFilters diff --git a/app/records/page.tsx b/app/records/page.tsx index f55064b..4c74460 100644 --- a/app/records/page.tsx +++ b/app/records/page.tsx @@ -11,6 +11,7 @@ type UsageRecord = { id: number; occurredAt: string; route: string; + channel: string | null; model: string; totalTokens: number; inputTokens: number; @@ -24,13 +25,14 @@ type UsageRecord = { type RecordsResponse = { items: UsageRecord[]; nextCursor: string | null; - filters?: { models: string[]; routes: string[] }; + filters?: { models: string[]; routes: string[]; channels: string[] }; }; type SortField = | "occurredAt" | "model" | "route" + | "channel" | "totalTokens" | "inputTokens" | "outputTokens" @@ -138,8 +140,10 @@ export default function RecordsPage() { const [models, setModels] = useState([]); const [routes, setRoutes] = useState([]); + const [channels, setChannels] = useState([]); const [modelInput, setModelInput] = useState(""); const [routeInput, setRouteInput] = useState(""); + const [channelInput, setChannelInput] = useState(""); const [startInput, setStartInput] = useState(""); const [endInput, setEndInput] = useState(""); const [rangePickerOpen, setRangePickerOpen] = useState(false); @@ -151,6 +155,7 @@ export default function RecordsPage() { const [appliedModel, setAppliedModel] = useState(""); const [appliedRoute, setAppliedRoute] = useState(""); + const [appliedChannel, setAppliedChannel] = useState(""); const [appliedStart, setAppliedStart] = useState(""); const [appliedEnd, setAppliedEnd] = useState(""); @@ -172,12 +177,13 @@ export default function RecordsPage() { if (cursorValue) params.set("cursor", cursorValue); if (appliedModel) params.set("model", appliedModel); if (appliedRoute) params.set("route", appliedRoute); + if (appliedChannel) params.set("channel", appliedChannel); if (appliedStart) params.set("start", new Date(appliedStart).toISOString()); if (appliedEnd) params.set("end", new Date(appliedEnd).toISOString()); if (includeFilters) params.set("includeFilters", "1"); return params; }, - [sortField, sortOrder, appliedModel, appliedRoute, appliedStart, appliedEnd] + [sortField, sortOrder, appliedModel, appliedRoute, appliedChannel, appliedStart, appliedEnd] ); const fetchRecords = useCallback( @@ -201,6 +207,9 @@ export default function RecordsPage() { if (data.filters?.routes?.length) { setRoutes(data.filters.routes); } + if (data.filters?.channels?.length) { + setChannels(data.filters.channels); + } } catch (err) { setError((err as Error).message || "加载失败"); } finally { @@ -315,13 +324,15 @@ export default function RecordsPage() { resetAndFetch(false); }, [sortField, sortOrder, resetAndFetch]); - const applyFilters = (overrides?: { model?: string; route?: string; start?: string; end?: string }) => { + const applyFilters = (overrides?: { model?: string; route?: string; channel?: string; start?: string; end?: string }) => { const nextModel = (overrides?.model ?? modelInput).trim(); const nextRoute = (overrides?.route ?? routeInput).trim(); + const nextChannel = (overrides?.channel ?? channelInput).trim(); const nextStart = overrides?.start ?? startInput; const nextEnd = overrides?.end ?? endInput; setAppliedModel(nextModel); setAppliedRoute(nextRoute); + setAppliedChannel(nextChannel); setAppliedStart(nextStart); setAppliedEnd(nextEnd); }; @@ -336,9 +347,14 @@ export default function RecordsPage() { setAppliedRoute(val.trim()); }; + const applyChannelOption = (val: string) => { + setChannelInput(val); + setAppliedChannel(val.trim()); + }; + useEffect(() => { resetAndFetch(false); - }, [appliedModel, appliedRoute, appliedStart, appliedEnd, resetAndFetch]); + }, [appliedModel, appliedRoute, appliedChannel, appliedStart, appliedEnd, resetAndFetch]); const costTone = useCallback((cost: number) => { if (cost >= 5) return "bg-red-500/20 text-red-300 ring-1 ring-red-500/40"; @@ -357,13 +373,14 @@ export default function RecordsPage() { const parts: string[] = []; if (appliedModel) parts.push(`模型: ${appliedModel}`); if (appliedRoute) parts.push(`密钥: ${appliedRoute}`); + if (appliedChannel) parts.push(`渠道: ${appliedChannel}`); if (appliedStart || appliedEnd) { const startLabel = appliedStart ? formatDateTimeDisplay(appliedStart) : "-"; const endLabel = appliedEnd ? formatDateTimeDisplay(appliedEnd) : "-"; parts.push(`时间: ${startLabel} ~ ${endLabel}`); } return parts.length ? parts.join(" / ") : "暂无筛选"; - }, [appliedModel, appliedRoute, appliedStart, appliedEnd]); + }, [appliedModel, appliedRoute, appliedChannel, appliedStart, appliedEnd]); const rangeLabel = useMemo(() => { if (!startInput && !endInput) return "选择时间范围"; @@ -481,6 +498,18 @@ export default function RecordsPage() { setAppliedRoute(""); }} /> + { + setChannelInput(""); + setAppliedChannel(""); + }} + />