全项目扫描修复: Docker数据卷修复+安全requireAdmin+12页SEO+API白名单+脚本超时+常量提取+假数据删除

This commit is contained in:
ZhuiGuangAI Dev
2026-06-12 17:27:47 +08:00
parent 464924aea0
commit 4c325c8699
454 changed files with 33647 additions and 980 deletions
+276
View File
@@ -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;
}
}