Files
zhuiguang-ai/scripts/backfill-bot-daily-stats.mjs
T

225 lines
8.5 KiB
JavaScript
Raw 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.
// 回填历史 BotDailyStat 数据
// 从 forumTopic / forumPost / 点赞表中,按本地日期(UTC+8)聚合每个 bot 的每日指标
// 用法:node scripts/backfill-bot-daily-stats.mjs [--days=14] [--dry-run]
import { PrismaClient } from "@prisma/client";
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import "dotenv/config";
const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://");
const sep = base.includes("?") ? "&" : "?";
const connectionString = `${base}${sep}connection_limit=5&pool_timeout=15`;
const adapter = new PrismaMariaDb(connectionString);
const prisma = new PrismaClient({ adapter });
// 把任意 Date 转成"本地日"的 key(YYYY-MM-DD)
// 因为 Prisma @db.Date 在中国时区会被 UTC 截到前一天,所以聚合也按本地日
function toLocalDateKey(d) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function toUTCDateOfLocalDay(key) {
// "YYYY-MM-DD" -> UTC 午夜 Date 对象
const [y, m, d] = key.split("-").map(Number);
return new Date(Date.UTC(y, m - 1, d));
}
async function main() {
const args = process.argv.slice(2);
const dryRun = args.includes("--dry-run");
const daysArg = args.find((a) => a.startsWith("--days="));
const days = daysArg ? parseInt(daysArg.split("=")[1], 10) : 30;
console.log(`========== 回填 BotDailyStat(${days} 天, ${dryRun ? "DRY-RUN" : "实际写入"})==========\n`);
// 1. 拿到所有 bot 用户
const botUsers = await prisma.user.findMany({
where: { isBot: true },
select: { id: true, name: true },
});
if (botUsers.length === 0) {
console.log("没有 bot 用户,退出。");
await prisma.$disconnect();
return;
}
console.log(`共 ${botUsers.length} 个 bot`);
// 2. BotConfig 映射(userId -> configId)
const configs = await prisma.botConfig.findMany({
where: { userId: { in: botUsers.map((u) => u.id) } },
select: { id: true, userId: true },
});
const configIdByUserId = new Map(configs.map((c) => [c.userId, c.id]));
const botUserIds = botUsers.map((u) => u.id).filter((uid) => configIdByUserId.has(uid));
console.log(`共 ${configs.length} 个 BotConfig(${botUsers.length - configs.length} 个 bot 还没创建 BotConfig)`);
// 3. 时间窗
const since = new Date(Date.now() - days * 24 * 3600 * 1000);
console.log(`时间窗: ${toLocalDateKey(since)} ~ 至今`);
// 4. 拉 bot 的所有话题(只取 createdAt)
const topics = await prisma.forumTopic.findMany({
where: { userId: { in: botUserIds }, createdAt: { gte: since } },
select: { id: true, userId: true, createdAt: true, categoryId: true, likeCount: true },
});
console.log(`拉取到 ${topics.length} 个 bot 话题`);
// 5. 拉 bot 的所有回复
const posts = await prisma.forumPost.findMany({
where: { userId: { in: botUserIds }, createdAt: { gte: since } },
select: { id: true, userId: true, createdAt: true, topicId: true, likeCount: true },
});
console.log(`拉取到 ${posts.length} 个 bot 回复`);
// 6. 拉 bot 话题的点赞(区分是不是 bot 自己)
const topicIds = topics.map((t) => t.id);
const topicLikes = topicIds.length > 0 ? await prisma.forumTopicLike.findMany({
where: { topicId: { in: topicIds } },
select: { topicId: true, userId: true, createdAt: true },
}) : [];
console.log(`拉取到 ${topicLikes.length} 条话题点赞`);
// 7. 拉 bot 回复的点赞
const postIds = posts.map((p) => p.id);
const postLikes = postIds.length > 0 ? await prisma.forumPostLike.findMany({
where: { postId: { in: postIds } },
select: { postId: true, userId: true, createdAt: true },
}) : [];
console.log(`拉取到 ${postLikes.length} 条回复点赞`);
// 8. 拉 bot 话题/回复下的所有回复(用于 human interactions:bot 收到真人回复)
const topicsSet = new Set(topics.map((t) => t.id));
const postsForBotTopics = topicsSet.size > 0 ? await prisma.forumPost.findMany({
where: { topicId: { in: [...topicsSet] }, createdAt: { gte: since } },
select: { id: true, userId: true, createdAt: true, topicId: true },
}) : [];
const botSet = new Set(botUserIds);
const allUserIds = Array.from(new Set([
...topicLikes.map((l) => l.userId),
...postLikes.map((l) => l.userId),
...postsForBotTopics.map((p) => p.userId),
]));
const humanIds = new Set();
if (allUserIds.length > 0) {
const humans = await prisma.user.findMany({
where: { id: { in: allUserIds }, isBot: false },
select: { id: true },
});
humans.forEach((h) => humanIds.add(h.id));
}
console.log(`识别出 ${humanIds.size} 个真人用户`);
// 9. 按 botConfigId + 本地日期 key 聚合
// row: { topicCreated, replySent, repliesReceived, likesReceived, humanInteractions }
const agg = new Map(); // key: `${cfgId}|${dateKey}` -> stats
function bump(cfgId, dateKey, field, by = 1) {
const key = `${cfgId}|${dateKey}`;
if (!agg.has(key)) {
agg.set(key, { botId: cfgId, dateKey, topicCreated: 0, replySent: 0, repliesReceived: 0, likesReceived: 0, humanInteractions: 0, followUpSent: 0, feedbackProcessed: 0 });
}
agg.get(key)[field] += by;
}
// 9a. 话题创建
for (const t of topics) {
const cfgId = configIdByUserId.get(t.userId);
if (!cfgId) continue;
bump(cfgId, toLocalDateKey(t.createdAt), "topicCreated", 1);
}
// 9b. 回复
for (const p of posts) {
const cfgId = configIdByUserId.get(p.userId);
if (!cfgId) continue;
bump(cfgId, toLocalDateKey(p.createdAt), "replySent", 1);
}
// 9c. bot 话题收到的回复(repliesReceived = 该 bot 的所有话题下其他人的回复数)
for (const p of postsForBotTopics) {
// 找这个 topic 是哪个 bot 的
const topic = topics.find((t) => t.id === p.topicId);
if (!topic) continue;
const ownerCfgId = configIdByUserId.get(topic.userId);
if (!ownerCfgId) continue;
// 跳过 bot 自己的回复
if (botSet.has(p.userId)) continue;
bump(ownerCfgId, toLocalDateKey(p.createdAt), "repliesReceived", 1);
// 真人互动:bot 收到真人回复
if (humanIds.has(p.userId)) {
bump(ownerCfgId, toLocalDateKey(p.createdAt), "humanInteractions", 1);
}
}
// 9d. 话题点赞
for (const l of topicLikes) {
const topic = topics.find((t) => t.id === l.topicId);
if (!topic) continue;
const ownerCfgId = configIdByUserId.get(topic.userId);
if (!ownerCfgId) continue;
bump(ownerCfgId, toLocalDateKey(l.createdAt), "likesReceived", 1);
if (humanIds.has(l.userId)) {
bump(ownerCfgId, toLocalDateKey(l.createdAt), "humanInteractions", 1);
}
}
// 9e. 回复点赞
for (const l of postLikes) {
const post = posts.find((p) => p.id === l.postId);
if (!post) continue;
const ownerCfgId = configIdByUserId.get(post.userId);
if (!ownerCfgId) continue;
bump(ownerCfgId, toLocalDateKey(l.createdAt), "likesReceived", 1);
if (humanIds.has(l.userId)) {
bump(ownerCfgId, toLocalDateKey(l.createdAt), "humanInteractions", 1);
}
}
console.log(`\n聚合出 ${agg.size} 条记录\n`);
// 10. 写入
let writeCount = 0;
for (const row of agg.values()) {
const dateObj = toUTCDateOfLocalDay(row.dateKey);
if (dryRun) {
if (writeCount < 5) {
console.log(` [DRY] ${row.botId} ${row.dateKey} T=${row.topicCreated} R=${row.replySent} RR=${row.repliesReceived} L=${row.likesReceived} H=${row.humanInteractions}`);
}
writeCount++;
continue;
}
await prisma.botDailyStat.upsert({
where: { botId_date: { botId: row.botId, date: dateObj } },
create: {
botId: row.botId,
date: dateObj,
topicCreated: row.topicCreated,
replySent: row.replySent,
followUpSent: row.followUpSent,
repliesReceived: row.repliesReceived,
likesReceived: row.likesReceived,
humanInteractions: row.humanInteractions,
feedbackProcessed: 0,
},
update: {
topicCreated: { increment: row.topicCreated },
replySent: { increment: row.replySent },
repliesReceived: { increment: row.repliesReceived },
likesReceived: { increment: row.likesReceived },
humanInteractions: { increment: row.humanInteractions },
},
});
writeCount++;
}
console.log(`${dryRun ? "[DRY-RUN] 即将写入" : "已写入"} ${writeCount} 条 BotDailyStat 记录`);
await prisma.$disconnect();
}
main().catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});