494 lines
15 KiB
TypeScript
494 lines
15 KiB
TypeScript
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 });
|
||
}
|
||
}
|