// 回填历史 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); });