2026-05-29 16:09:06 +08:00

494 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, RuntimeModelMeta & { score: number }>();
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<string, RuntimeModelMeta & { score: number }>,
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<string, RuntimeModelMeta & { score: number }>
) {
const usageMap = new Map<
string,
RuntimeModelMeta & {
modelId: string | null;
count: number;
userIds: Set<string>;
}
>();
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<string>(),
};
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<string, RuntimeModelMeta & { score: number }>
) {
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<number>`count(*)::int`,
totalUsers: sql<number>`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<number>`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<string>`date(${generatedImage.createdAt})::text`,
count: sql<number>`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<number>`count(*)::int`,
})
.from(generatedImage)
.groupBy(generatedImage.imageSize)
.orderBy(desc(sql`count(*)`));
const sizeBucketMap = new Map<string, number>();
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<number>`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<string>`date(${generatedImage.createdAt})::text`,
modelId: generatedImage.modelId,
count: sql<number>`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<string>`to_char(${generatedImage.createdAt}, 'YYYY-MM')`,
count: sql<number>`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<number>`extract(hour from ${generatedImage.createdAt})::int`,
count: sql<number>`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<number>`count(*)::int`,
users: sql<number>`count(distinct ${generatedImage.userId})::int`,
})
.from(generatedImage)
.where(sql`date(${generatedImage.createdAt}) = current_date`);
// 9. 本周统计
const weekStats = await db
.select({
count: sql<number>`count(*)::int`,
users: sql<number>`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<number>`count(*)::int`,
users: sql<number>`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 });
}
}