Files
zhuiguang-ai/src/app/api/admin/bots/stats/route.ts
T

230 lines
7.9 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
// Bot 表现统计:拉近 N 天的每日统计 + 总览指标
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const days = Math.min(Math.max(parseInt(searchParams.get("days") || "14", 10), 1), 60);
const botParam = searchParams.get("botUserId");
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
const sinceDate = new Date(since.getFullYear(), since.getMonth(), since.getDate());
// 1. 全部 bot 基础信息
const botUsers = await prisma.user.findMany({
where: { isBot: true },
select: { id: true, name: true, email: true, avatarUrl: true, points: true, level: true, createdAt: true },
});
if (botUsers.length === 0) {
return NextResponse.json({ stats: [], summary: {}, trends: [] });
}
const botById = new Map(botUsers.map((u) => [u.id, u]));
const botIds = botUsers.map((u) => u.id);
// 2. BotConfig 拿到 botKey 和 primaryForums
const botConfigs = await prisma.botConfig.findMany({
where: { userId: { in: botIds } },
select: { id: true, userId: true, primaryForums: true, displayName: true },
});
const configByUserId = new Map(botConfigs.map((c) => [c.userId, c]));
// 3. 周期内 BotDailyStat 聚合
const dailyStats = await prisma.botDailyStat.groupBy({
by: ["botId"],
where: { date: { gte: sinceDate } },
_sum: {
topicCreated: true,
replySent: true,
followUpSent: true,
repliesReceived: true,
likesReceived: true,
humanInteractions: true,
feedbackProcessed: true,
},
});
const statMap = new Map(
dailyStats.map((s) => [
s.botId,
{
topicCreated: s._sum.topicCreated || 0,
replySent: s._sum.replySent || 0,
followUpSent: s._sum.followUpSent || 0,
repliesReceived: s._sum.repliesReceived || 0,
likesReceived: s._sum.likesReceived || 0,
humanInteractions: s._sum.humanInteractions || 0,
feedbackProcessed: s._sum.feedbackProcessed || 0,
},
])
);
// 4. Memory 数量
const memCounts = await prisma.botMemory.groupBy({
by: ["botId"],
_count: { _all: true },
_avg: { importance: true },
});
const memMap = new Map(
memCounts.map((m) => [
m.botId,
{ count: m._count._all, avgImportance: m._avg.importance || 0 },
])
);
// 5. Persona + 跨板块亲和度
const personas = await prisma.botPersona.findMany({
select: {
botId: true,
avgReplyLength: true,
questionRatio: true,
postSampleCount: true,
stanceKeywords: true,
},
});
const personaMap = new Map(personas.map((p) => [p.botId, p]));
const affinities = await prisma.botCrossForumAffinity.findMany({
select: { botId: true, forumSlug: true, affinity: true, sampleCount: true },
});
const affinityMap = new Map<number, typeof affinities>();
for (const a of affinities) {
if (!affinityMap.has(a.botId)) affinityMap.set(a.botId, []);
affinityMap.get(a.botId)!.push(a);
}
// 6. 计算每个 bot 的"健康分"
const stats = botUsers.map((u) => {
const config = configByUserId.get(u.id);
const cfgId = config?.id ?? 0;
const d = statMap.get(cfgId) || {
topicCreated: 0,
replySent: 0,
followUpSent: 0,
repliesReceived: 0,
likesReceived: 0,
humanInteractions: 0,
feedbackProcessed: 0,
};
const mem = memMap.get(cfgId) || { count: 0, avgImportance: 0 };
const persona = personaMap.get(cfgId);
const affs = affinityMap.get(cfgId) || [];
// 综合健康分:发帖×3 + 回复×1 + 收赞×2 + 真人互动×5
const healthScore =
d.topicCreated * 3 +
d.replySent * 1 +
d.likesReceived * 2 +
d.humanInteractions * 5;
// 平均亲和度
const avgAffinity = affs.length > 0
? affs.reduce((s, a) => s + a.affinity, 0) / affs.length
: 0;
// 状态判定
let status: "active" | "warming" | "fresh" | "idle" = "idle";
if (healthScore >= 50) status = "active";
else if (healthScore >= 10) status = "warming";
else if (d.humanInteractions > 0 || d.likesReceived > 0) status = "fresh";
else status = "idle";
return {
userId: u.id,
name: u.name,
avatarUrl: u.avatarUrl,
points: u.points,
level: u.level,
botConfigId: config?.id || null,
primaryForums: (config?.primaryForums as string[]) || [],
topicCreated: d.topicCreated,
replySent: d.replySent,
followUpSent: d.followUpSent,
repliesReceived: d.repliesReceived,
likesReceived: d.likesReceived,
humanInteractions: d.humanInteractions,
feedbackProcessed: d.feedbackProcessed,
memoryCount: mem.count,
avgImportance: mem.avgImportance,
personaQuestionRatio: persona?.questionRatio || 0,
personaSampleCount: persona?.postSampleCount || 0,
affinityCount: affs.length,
avgAffinity: Math.round(avgAffinity * 100) / 100,
healthScore,
status,
};
});
// 按健康分排序
stats.sort((a, b) => b.healthScore - a.healthScore);
// 7. 时间趋势(按天聚合)
const trendRaw = await prisma.botDailyStat.groupBy({
by: ["date"],
where: { date: { gte: sinceDate } },
_sum: {
topicCreated: true,
replySent: true,
likesReceived: true,
humanInteractions: true,
},
orderBy: { date: "asc" },
});
const trends = trendRaw.map((t) => ({
date: t.date.toISOString().split("T")[0],
topicCreated: t._sum.topicCreated || 0,
replySent: t._sum.replySent || 0,
likesReceived: t._sum.likesReceived || 0,
humanInteractions: t._sum.humanInteractions || 0,
}));
// 7b. 如果指定了 botUserId,额外返回该 bot 的每日趋势
let perBotTrend: typeof trends = [];
if (botParam) {
const botUserId = Number(botParam);
if (!Number.isNaN(botUserId)) {
const botConfig = configByUserId.get(botUserId);
if (botConfig) {
const perBot = await prisma.botDailyStat.findMany({
where: { botId: botConfig.id, date: { gte: sinceDate } },
orderBy: { date: "asc" },
});
perBotTrend = perBot.map((d) => ({
date: d.date.toISOString().split("T")[0],
topicCreated: d.topicCreated,
replySent: d.replySent,
likesReceived: d.likesReceived,
humanInteractions: d.humanInteractions,
}));
}
}
}
// 8. 全局汇总
const summary = {
totalBots: stats.length,
activeBots: stats.filter((s) => s.status === "active").length,
warmingBots: stats.filter((s) => s.status === "warming").length,
idleBots: stats.filter((s) => s.status === "idle").length,
totalTopics: stats.reduce((s, b) => s + b.topicCreated, 0),
totalReplies: stats.reduce((s, b) => s + b.replySent, 0),
totalLikes: stats.reduce((s, b) => s + b.likesReceived, 0),
totalHumanInteractions: stats.reduce((s, b) => s + b.humanInteractions, 0),
totalMemories: stats.reduce((s, b) => s + b.memoryCount, 0),
avgHealthScore: stats.length > 0
? Math.round(stats.reduce((s, b) => s + b.healthScore, 0) / stats.length)
: 0,
days,
};
return NextResponse.json({
stats: stats.slice(0, 50), // 最多 50 个 bot(已排序)
summary,
trends,
perBotTrend,
botById: Object.fromEntries(botUsers.map((u) => [u.id, u.name])),
});
} catch (error: unknown) {
const err = error as { message?: string };
return NextResponse.json({ error: err.message || "Bot 统计失败" }, { status: 500 });
}
}