全项目扫描修复: Docker数据卷修复+安全requireAdmin+12页SEO+API白名单+脚本超时+常量提取+假数据删除
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
// Bot 画像 (BotPersona) 共享模块
|
||||
// 从 bot-affinity-update.mjs 抽出,bot-activity.mjs 也能复用
|
||||
// 用法:
|
||||
// import { updatePersona, schedulePersonaRefresh } from "./lib/bot-persona.mjs";
|
||||
// await updatePersona(botUser, botConfig.id);
|
||||
// schedulePersonaRefresh(botUserId, botConfig.id); // 异步、防抖
|
||||
|
||||
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=2&pool_timeout=10`;
|
||||
|
||||
// 共享一个 Prisma 客户端(连接池小,只为后台异步刷新服务)
|
||||
let _prisma = null;
|
||||
function getPrisma() {
|
||||
if (!_prisma) {
|
||||
const adapter = new PrismaMariaDb(connectionString);
|
||||
_prisma = new PrismaClient({ adapter });
|
||||
}
|
||||
return _prisma;
|
||||
}
|
||||
|
||||
const PERSONA_LOOKBACK_DAYS = 30;
|
||||
const TOP_KEYWORDS = 12;
|
||||
const TOP_HUMAN_USERS = 5;
|
||||
const TOP_TOPIC_TYPES = 5;
|
||||
const LAST_TITLES_COUNT = 5;
|
||||
|
||||
const STOP_WORDS = new Set([
|
||||
"的", "了", "是", "在", "和", "与", "或", "也", "都", "就", "不", "没", "要", "我", "你", "他", "她", "它", "们",
|
||||
"这", "那", "有", "为", "到", "对", "及", "等", "把", "被", "从", "向", "以", "其", "之", "于", "上", "下",
|
||||
"里", "外", "中", "一个", "一些", "我们", "你们", "他们", "什么", "怎么", "为什么", "啊", "吗", "呢", "吧",
|
||||
"嗯", "哦", "哈", "啦", "嘿", "唉", "这个", "那个", "一个", "真的", "应该", "可能", "觉得", "认为",
|
||||
]);
|
||||
|
||||
function tokenize(text) {
|
||||
if (!text) return [];
|
||||
return text
|
||||
.replace(/[,。!?、;:""''【】《》()()\[\]·—\.\,\!\?\;\:\'\"\\\/]/g, " ")
|
||||
.split(/\s+/)
|
||||
.filter((w) => w.length >= 2 && !STOP_WORDS.has(w));
|
||||
}
|
||||
|
||||
function pickTopWithCount(arr, topN) {
|
||||
const counts = new Map();
|
||||
for (const item of arr) {
|
||||
if (!item) continue;
|
||||
counts.set(item, (counts.get(item) || 0) + 1);
|
||||
}
|
||||
return Array.from(counts.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, topN)
|
||||
.map(([k, v]) => ({ key: k, count: v }));
|
||||
}
|
||||
|
||||
function ts() {
|
||||
return `[${new Date().toISOString()}]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取过去 N 天该 bot 的所有发帖/回复,统计画像数据
|
||||
* @returns persona object (or empty persona when no posts)
|
||||
*/
|
||||
export async function computePersona(botUser, botConfigId) {
|
||||
const prisma = getPrisma();
|
||||
const since = new Date(Date.now() - PERSONA_LOOKBACK_DAYS * 24 * 60 * 60 * 1000);
|
||||
const topics = await prisma.forumTopic.findMany({
|
||||
where: { userId: botUser.id, createdAt: { gt: since } },
|
||||
include: {
|
||||
category: { select: { name: true, slug: true } },
|
||||
posts: {
|
||||
include: { user: { select: { id: true, name: true, isBot: true } } },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
if (topics.length === 0) {
|
||||
return {
|
||||
avgReplyLength: 0,
|
||||
questionRatio: 0,
|
||||
exclamationCount: 0,
|
||||
avgTopicLength: 0,
|
||||
stanceKeywords: [],
|
||||
topHumanUsers: [],
|
||||
topTopicTypes: [],
|
||||
lastTopicTitles: [],
|
||||
postSampleCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const allPosts = [];
|
||||
for (const t of topics) {
|
||||
allPosts.push({
|
||||
content: t.content,
|
||||
isTopic: true,
|
||||
createdAt: t.createdAt,
|
||||
forumSlug: t.category.slug,
|
||||
authorId: t.userId,
|
||||
});
|
||||
for (const p of t.posts) {
|
||||
allPosts.push({
|
||||
content: p.content,
|
||||
isTopic: false,
|
||||
createdAt: p.createdAt,
|
||||
forumSlug: t.category.slug,
|
||||
authorId: p.userId,
|
||||
humanAuthor: !p.user.isBot ? p.user : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const myPosts = allPosts.filter((p) => p.authorId === botUser.id);
|
||||
const replyLengths = myPosts.filter((p) => !p.isTopic).map((p) => p.content.length);
|
||||
const topicLengths = myPosts.filter((p) => p.isTopic).map((p) => p.content.length);
|
||||
|
||||
const fullText = myPosts.map((p) => p.content).join(" ");
|
||||
const questionMarks = (fullText.match(/[??]/g) || []).length;
|
||||
const exclamationMarks = (fullText.match(/[!!]/g) || []).length;
|
||||
const questionRatio = myPosts.length > 0 ? questionMarks / myPosts.length : 0;
|
||||
|
||||
const keywords = tokenize(fullText);
|
||||
const stanceKeywords = pickTopWithCount(keywords, TOP_KEYWORDS);
|
||||
|
||||
const humanRepliers = allPosts
|
||||
.filter((p) => p.humanAuthor)
|
||||
.map((p) => ({ id: p.humanAuthor.id, name: p.humanAuthor.name || "匿名" }));
|
||||
const userCount = new Map();
|
||||
for (const h of humanRepliers) {
|
||||
const k = `${h.id}|${h.name}`;
|
||||
userCount.set(k, (userCount.get(k) || 0) + 1);
|
||||
}
|
||||
const topHumanUsers = Array.from(userCount.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, TOP_HUMAN_USERS)
|
||||
.map(([k, c]) => {
|
||||
const [id, name] = k.split("|");
|
||||
return { id: Number(id), name, count: c };
|
||||
});
|
||||
|
||||
const forumCount = new Map();
|
||||
for (const t of topics) {
|
||||
const k = `${t.category.slug}|${t.category.name}`;
|
||||
forumCount.set(k, (forumCount.get(k) || 0) + 1);
|
||||
}
|
||||
const topTopicTypes = Array.from(forumCount.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, TOP_TOPIC_TYPES)
|
||||
.map(([k, c]) => {
|
||||
const [slug, name] = k.split("|");
|
||||
return { slug, name, count: c };
|
||||
});
|
||||
|
||||
const lastTopicTitles = topics
|
||||
.slice(0, LAST_TITLES_COUNT)
|
||||
.map((t) => t.title);
|
||||
|
||||
return {
|
||||
avgReplyLength: replyLengths.length
|
||||
? Math.round(replyLengths.reduce((s, n) => s + n, 0) / replyLengths.length)
|
||||
: 0,
|
||||
questionRatio: Math.round(questionRatio * 100) / 100,
|
||||
exclamationCount: exclamationMarks,
|
||||
avgTopicLength: topicLengths.length
|
||||
? Math.round(topicLengths.reduce((s, n) => s + n, 0) / topicLengths.length)
|
||||
: 0,
|
||||
stanceKeywords,
|
||||
topHumanUsers,
|
||||
topTopicTypes,
|
||||
lastTopicTitles,
|
||||
postSampleCount: myPosts.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算并写回 BotPersona(同步版本,返回是否成功)
|
||||
* @param botUser - bot User 实体(含 id)
|
||||
* @param botConfigId - BotConfig.id
|
||||
* @returns { success: boolean, postSampleCount: number }
|
||||
*/
|
||||
export async function updatePersona(botUser, botConfigId) {
|
||||
const prisma = getPrisma();
|
||||
const persona = await computePersona(botUser, botConfigId);
|
||||
if (persona.postSampleCount === 0) {
|
||||
return { success: false, postSampleCount: 0 };
|
||||
}
|
||||
await prisma.botPersona.upsert({
|
||||
where: { botId: botConfigId },
|
||||
create: { botId: botConfigId, ...persona },
|
||||
update: persona,
|
||||
});
|
||||
return { success: true, postSampleCount: persona.postSampleCount };
|
||||
}
|
||||
|
||||
// ============ 异步防抖调度 ============
|
||||
// 每次发帖/回复后调用 schedulePersonaRefresh(botUserId, botConfigId):
|
||||
// - 同一 bot 在 COOLDOWN_MS 内多次触发,只跑一次(防抖)
|
||||
// - 失败时静默记录,不影响主流程
|
||||
const COOLDOWN_MS = 5 * 60 * 1000; // 5 分钟防抖
|
||||
const lastRunMap = new Map(); // botConfigId -> timestamp(ms)
|
||||
const inFlightSet = new Set(); // 正在跑 refresh 的 botConfigId
|
||||
|
||||
/**
|
||||
* 异步 + 防抖触发 persona 刷新
|
||||
* - 5 分钟内同一 bot 只跑一次
|
||||
* - 不阻塞调用方,立即返回
|
||||
* - 失败时 console.warn,不抛出
|
||||
* @returns true 表示本次触发了真实刷新,false 表示被防抖跳过
|
||||
*/
|
||||
export function schedulePersonaRefresh(botUserId, botConfigId, options = {}) {
|
||||
const cooldown = options.cooldownMs ?? COOLDOWN_MS;
|
||||
const now = Date.now();
|
||||
const last = lastRunMap.get(botConfigId) || 0;
|
||||
if (now - last < cooldown) {
|
||||
return false;
|
||||
}
|
||||
if (inFlightSet.has(botConfigId)) {
|
||||
return false;
|
||||
}
|
||||
inFlightSet.add(botConfigId);
|
||||
lastRunMap.set(botConfigId, now);
|
||||
|
||||
// 推迟到下一个 tick,避免阻塞发帖主流程
|
||||
setImmediate(async () => {
|
||||
try {
|
||||
const prisma = getPrisma();
|
||||
const botUser = await prisma.user.findUnique({ where: { id: botUserId } });
|
||||
if (!botUser) return;
|
||||
const result = await updatePersona(botUser, botConfigId);
|
||||
if (result.success) {
|
||||
console.log(
|
||||
`${ts()} 🪞 persona 异步刷新: bot#${botConfigId} (sample=${result.postSampleCount})`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`${ts()} ⚠️ persona 异步刷新失败 bot#${botConfigId}:`,
|
||||
err.message
|
||||
);
|
||||
} finally {
|
||||
inFlightSet.delete(botConfigId);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制立即刷新(绕开防抖),用于定时任务兜底
|
||||
*/
|
||||
export async function forcePersonaRefresh(botUserId, botConfigId) {
|
||||
const prisma = getPrisma();
|
||||
const botUser = await prisma.user.findUnique({ where: { id: botUserId } });
|
||||
if (!botUser) return { success: false, postSampleCount: 0 };
|
||||
return updatePersona(botUser, botConfigId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空防抖状态(测试 / 主流程退出时用)
|
||||
*/
|
||||
export function resetPersonaDebounce() {
|
||||
lastRunMap.clear();
|
||||
inFlightSet.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 优雅关闭 prisma 客户端(主进程退出前调用)
|
||||
*/
|
||||
export async function disconnectBotPersona() {
|
||||
if (_prisma) {
|
||||
await _prisma.$disconnect();
|
||||
_prisma = null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user