225 lines
8.5 KiB
JavaScript
225 lines
8.5 KiB
JavaScript
// 回填历史 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);
|
||
});
|