import { NextRequest, NextResponse } from "next/server"; import { db } from "@/lib/db"; import { generatedImage, user, aiModel, aiProvider } from "@/lib/db/schema"; import { eq, sql, desc, and, gte, lt } from "drizzle-orm"; import { auth } from "@/app/(auth)/auth"; type RuntimeModelRegistryRow = { id: string; modelId: string; displayName: string; providerName: string | null; providerType: string | null; isEnabled: boolean; isHidden: boolean; isDefault: boolean; isSubModel: boolean; sortOrder: number; updatedAt: Date; createdAt: Date; }; type RuntimeModelMeta = { canonicalModelId: string | null; modelName: string | null; providerName: string | null; providerType: string | null; }; const ANALYTICS_SIZE_BUCKETS = ["4K", "3K", "2K", "1K"] as const; function normalizeAnalyticsImageSizeBucket(value: string | null | undefined) { const normalized = String(value || "") .trim() .toUpperCase(); if (!normalized) return "2K"; if (normalized.includes("4K")) return "4K"; if (normalized.includes("3K")) return "3K"; if (normalized.includes("2K")) return "2K"; if (normalized.includes("1K")) return "1K"; const sizeMatch = normalized.match(/(\d+(?:\.\d+)?)\s*K/); if (sizeMatch) { const sizeValue = Number(sizeMatch[1]); if (sizeValue >= 3.5) return "4K"; if (sizeValue >= 2.5) return "3K"; if (sizeValue >= 1.5) return "2K"; return "1K"; } const dimensionMatch = normalized.match(/(\d{3,5})\s*[X×*]\s*(\d{3,5})/); if (dimensionMatch) { const maxEdge = Math.max( Number(dimensionMatch[1]), Number(dimensionMatch[2]) ); if (maxEdge >= 3500) return "4K"; if (maxEdge >= 2500) return "3K"; if (maxEdge >= 1500) return "2K"; return "1K"; } const numericMatch = normalized.match(/(\d{3,5})/); if (numericMatch) { const numericValue = Number(numericMatch[1]); if (numericValue >= 3500) return "4K"; if (numericValue >= 2500) return "3K"; if (numericValue >= 1500) return "2K"; } return "1K"; } function scoreRuntimeModelCandidate(row: RuntimeModelRegistryRow) { return ( (row.isEnabled ? 100 : 0) + (!row.isHidden ? 20 : 0) + (!row.isSubModel ? 10 : 0) + (row.isDefault ? 8 : 0) + Math.max(0, 1000 - row.sortOrder) + (row.updatedAt?.getTime() || row.createdAt?.getTime() || 0) / 1_000_000_000_000 ); } function buildRuntimeModelRegistry(rows: RuntimeModelRegistryRow[]) { const registry = new Map(); for (const row of rows) { const score = scoreRuntimeModelCandidate(row); const meta = { score, canonicalModelId: row.modelId, modelName: row.displayName || null, providerName: row.providerName || null, providerType: row.providerType || null, }; for (const key of [row.id, row.modelId]) { const current = registry.get(key); if (current && current.score >= score) continue; registry.set(key, meta); } } return registry; } function resolveRuntimeModelMeta( registry: Map, modelId: string | null ): RuntimeModelMeta { if (!modelId) { return { canonicalModelId: null, modelName: null, providerName: null, providerType: null, }; } const resolved = registry.get(modelId); return { canonicalModelId: resolved?.canonicalModelId || null, modelName: resolved?.modelName || null, providerName: resolved?.providerName || null, providerType: resolved?.providerType || null, }; } function buildCanonicalModelUsage( rows: Array<{ modelId: string | null; userId: string; count: number }>, registry: Map ) { const usageMap = new Map< string, RuntimeModelMeta & { modelId: string | null; count: number; userIds: Set; } >(); for (const row of rows) { const meta = resolveRuntimeModelMeta(registry, row.modelId); const canonicalModelId = meta.canonicalModelId || row.modelId; const key = canonicalModelId || row.modelId || "__unknown__"; const current = usageMap.get(key) || { modelId: canonicalModelId, canonicalModelId, modelName: meta.modelName, providerName: meta.providerName, providerType: meta.providerType, count: 0, userIds: new Set(), }; current.count += row.count; current.userIds.add(row.userId); if (!current.modelName && meta.modelName) current.modelName = meta.modelName; if (!current.providerName && meta.providerName) { current.providerName = meta.providerName; } if (!current.providerType && meta.providerType) { current.providerType = meta.providerType; } usageMap.set(key, current); } return Array.from(usageMap.values()) .map(({ userIds, canonicalModelId, ...item }) => ({ ...item, modelId: item.modelId || canonicalModelId, userCount: userIds.size, })) .sort((left, right) => right.count - left.count); } function buildCanonicalModelTrend( rows: Array<{ date: string; modelId: string | null; count: number }>, registry: Map ) { const trendMap = new Map< string, RuntimeModelMeta & { date: string; modelId: string | null; count: number; } >(); for (const row of rows) { const meta = resolveRuntimeModelMeta(registry, row.modelId); const canonicalModelId = meta.canonicalModelId || row.modelId; const key = `${row.date}::${canonicalModelId || row.modelId || "__unknown__"}`; const current = trendMap.get(key) || { date: row.date, modelId: canonicalModelId, canonicalModelId, modelName: meta.modelName, providerName: meta.providerName, providerType: meta.providerType, count: 0, }; current.count += row.count; if (!current.modelName && meta.modelName) current.modelName = meta.modelName; if (!current.providerName && meta.providerName) { current.providerName = meta.providerName; } if (!current.providerType && meta.providerType) { current.providerType = meta.providerType; } trendMap.set(key, current); } return Array.from(trendMap.values()) .map(({ canonicalModelId, ...item }) => ({ ...item, modelId: item.modelId || canonicalModelId, })) .sort( (left, right) => left.date.localeCompare(right.date) || right.count - left.count ); } export async function GET(request: NextRequest) { try { const session = await auth(); if (!session?.user?.id) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } // 检查是否为管理员 const currentUser = await db.query.user.findFirst({ where: eq(user.id, session.user.id), }); if (currentUser?.role !== "admin") { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } // 获取查询参数 const searchParams = request.nextUrl.searchParams; const month = searchParams.get("month"); // 格式: YYYY-MM const selectedDate = searchParams.get("date"); // 格式: YYYY-MM-DD,用于高峰时段筛选 // 1. 总体统计 const totalStats = await db .select({ totalImages: sql`count(*)::int`, totalUsers: sql`count(distinct ${generatedImage.userId})::int`, }) .from(generatedImage); // 2. 按用户统计(Top 20) const userStats = await db .select({ userId: generatedImage.userId, userName: user.name, userEmail: user.email, imageCount: sql`count(*)::int`, }) .from(generatedImage) .leftJoin(user, eq(generatedImage.userId, user.id)) .groupBy(generatedImage.userId, user.name, user.email) .orderBy(desc(sql`count(*)`)) .limit(20); // 3. 按日期统计(最近30天) const dailyStats = await db .select({ date: sql`date(${generatedImage.createdAt})::text`, count: sql`count(*)::int`, }) .from(generatedImage) .where(sql`${generatedImage.createdAt} >= now() - interval '30 days'`) .groupBy(sql`date(${generatedImage.createdAt})`) .orderBy(sql`date(${generatedImage.createdAt})`); // 4. 按图片尺寸统计 const rawSizeStats = await db .select({ imageSize: generatedImage.imageSize, count: sql`count(*)::int`, }) .from(generatedImage) .groupBy(generatedImage.imageSize) .orderBy(desc(sql`count(*)`)); const sizeBucketMap = new Map(); for (const stat of rawSizeStats) { const bucket = normalizeAnalyticsImageSizeBucket(stat.imageSize); sizeBucketMap.set(bucket, (sizeBucketMap.get(bucket) || 0) + stat.count); } const sizeStats = ANALYTICS_SIZE_BUCKETS.map((bucket) => ({ imageSize: bucket, count: sizeBucketMap.get(bucket) || 0, })); const runtimeModelRows = await db .select({ id: aiModel.id, modelId: aiModel.modelId, displayName: aiModel.displayName, providerName: aiProvider.name, providerType: aiProvider.type, isEnabled: aiModel.isEnabled, isHidden: aiModel.isHidden, isDefault: aiModel.isDefault, isSubModel: aiModel.isSubModel, sortOrder: aiModel.sortOrder, updatedAt: aiModel.updatedAt, createdAt: aiModel.createdAt, }) .from(aiModel) .leftJoin(aiProvider, eq(aiModel.providerId, aiProvider.id)); const runtimeModelRegistry = buildRuntimeModelRegistry(runtimeModelRows); // 5. 按模型统计 const rawModelUsageRows = await db .select({ modelId: generatedImage.modelId, userId: generatedImage.userId, count: sql`count(*)::int`, }) .from(generatedImage) .groupBy(generatedImage.modelId, generatedImage.userId); const canonicalModelStats = buildCanonicalModelUsage( rawModelUsageRows, runtimeModelRegistry ); const modelStats = canonicalModelStats.slice(0, 10).map((stat) => ({ modelId: stat.modelId, count: stat.count, })); const modelDetailStats = canonicalModelStats.slice(0, 20).map((stat) => ({ modelId: stat.modelId, count: stat.count, userCount: stat.userCount, modelName: stat.modelName, providerName: stat.providerName, providerType: stat.providerType, })); // 5.2 按提供商类型统计 const providerTypeStatsMap = new Map< string, { providerType: string | null; providerName: string | null; count: number; } >(); for (const stat of canonicalModelStats) { const key = `${stat.providerType || "unknown"}::${stat.providerName || "unknown"}`; const current = providerTypeStatsMap.get(key) || { providerType: stat.providerType, providerName: stat.providerName, count: 0, }; current.count += stat.count; providerTypeStatsMap.set(key, current); } const providerTypeStats = Array.from(providerTypeStatsMap.values()).sort( (left, right) => right.count - left.count ); // 5.3 模型使用趋势(最近7天,按模型分组) const rawModelTrendStats = await db .select({ date: sql`date(${generatedImage.createdAt})::text`, modelId: generatedImage.modelId, count: sql`count(*)::int`, }) .from(generatedImage) .where(sql`${generatedImage.createdAt} >= now() - interval '7 days'`) .groupBy(sql`date(${generatedImage.createdAt})`, generatedImage.modelId) .orderBy(sql`date(${generatedImage.createdAt})`); const modelTrendStats = buildCanonicalModelTrend( rawModelTrendStats, runtimeModelRegistry ); // 6. 按月份统计 const monthlyStats = await db .select({ month: sql`to_char(${generatedImage.createdAt}, 'YYYY-MM')`, count: sql`count(*)::int`, }) .from(generatedImage) .groupBy(sql`to_char(${generatedImage.createdAt}, 'YYYY-MM')`) .orderBy(sql`to_char(${generatedImage.createdAt}, 'YYYY-MM')`); // 7. 按小时统计(用于分析高峰时段,支持按日期筛选) let hourlyWhereClause = undefined; if (selectedDate) { const dateStart = new Date(selectedDate); const dateEnd = new Date(selectedDate); dateEnd.setDate(dateEnd.getDate() + 1); hourlyWhereClause = and( gte(generatedImage.createdAt, dateStart), lt(generatedImage.createdAt, dateEnd) ); } const hourlyStats = await db .select({ hour: sql`extract(hour from ${generatedImage.createdAt})::int`, count: sql`count(*)::int`, }) .from(generatedImage) .where(hourlyWhereClause) .groupBy(sql`extract(hour from ${generatedImage.createdAt})`) .orderBy(sql`extract(hour from ${generatedImage.createdAt})`); // 8. 今日统计 const todayStats = await db .select({ count: sql`count(*)::int`, users: sql`count(distinct ${generatedImage.userId})::int`, }) .from(generatedImage) .where(sql`date(${generatedImage.createdAt}) = current_date`); // 9. 本周统计 const weekStats = await db .select({ count: sql`count(*)::int`, users: sql`count(distinct ${generatedImage.userId})::int`, }) .from(generatedImage) .where(sql`${generatedImage.createdAt} >= date_trunc('week', current_date)`); // 10. 指定月份统计(支持选择月份) let monthStart: Date; let monthEnd: Date; if (month) { // 解析指定月份 const [year, mon] = month.split("-").map(Number); monthStart = new Date(year, mon - 1, 1); monthEnd = new Date(year, mon, 1); // 下个月第一天 } else { // 默认当前月 const now = new Date(); monthStart = new Date(now.getFullYear(), now.getMonth(), 1); monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 1); } const monthStats = await db .select({ count: sql`count(*)::int`, users: sql`count(distinct ${generatedImage.userId})::int`, }) .from(generatedImage) .where(and( gte(generatedImage.createdAt, monthStart), lt(generatedImage.createdAt, monthEnd) )); return NextResponse.json({ overview: { totalImages: totalStats[0]?.totalImages || 0, totalUsers: totalStats[0]?.totalUsers || 0, todayImages: todayStats[0]?.count || 0, todayUsers: todayStats[0]?.users || 0, weekImages: weekStats[0]?.count || 0, weekUsers: weekStats[0]?.users || 0, monthImages: monthStats[0]?.count || 0, monthUsers: monthStats[0]?.users || 0, selectedMonth: month || `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, "0")}`, selectedDate: selectedDate || null, }, userStats, dailyStats, sizeStats, modelStats, modelDetailStats, providerTypeStats, modelTrendStats, monthlyStats, hourlyStats, }); } catch (error) { console.error("[Admin Images Analytics API] Error:", error); return NextResponse.json({ error: "Failed to fetch analytics" }, { status: 500 }); } }