全项目扫描修复: 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
+421
View File
@@ -0,0 +1,421 @@
import { PrismaClient } from "@prisma/client";
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import { readFileSync } from "fs";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
import OpenAI from "openai";
import "dotenv/config";
import { withRetry } from "./lib/retry.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://");
const sep = base.includes("?") ? "&" : "?";
const connectionString = `${base}${sep}connection_limit=5&pool_timeout=30`;
const adapter = new PrismaMariaDb(connectionString);
const prisma = new PrismaClient({ adapter });
const openai = new OpenAI({
apiKey: process.env.DEEPSEEK_API_KEY,
baseURL: "https://api.deepseek.com/v1",
});
const BOT_DATA_PATH = resolve(__dirname, "..", "data", "bot-characters.json");
const MODEL = process.env.DEEPSEEK_MODEL || "deepseek-chat";
const LAYER = {
SESSION: "SESSION",
WORKING: "WORKING",
LONGTERM: "LONGTERM",
};
const MEMORY_TYPE = {
EXPERIENCE: "experience",
INTERACTION: "interaction",
FEEDBACK: "feedback",
SKILL_USAGE: "skill_usage",
REFLECTION: "reflection",
USER_MODEL: "user_model",
};
const QUOTA = {
high: { followUpPerDay: 6 },
medium: { followUpPerDay: 3 },
low: { followUpPerDay: 1 },
};
const FEEDBACK_LOOKBACK_HOURS = 24;
const FOLLOW_UP_PROBABILITY = 0.6;
const MIN_FEEDBACK_TO_FOLLOWUP = 1;
const MAX_FOLLOWUP_PER_RUN_PER_BOT = 3;
function ts() {
return `[${new Date().toISOString()}]`;
}
function pick(arr) {
return arr[Math.floor(Math.random() * arr.length)];
}
function startOfToday() {
// 用 UTC 午夜避开 Prisma @db.Date 写入时的时区截断
const d = new Date();
return new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
}
async function loadBotCharacters() {
const raw = readFileSync(BOT_DATA_PATH, "utf-8");
const { characters } = JSON.parse(raw);
const map = {};
for (const c of characters) {
map[c.key] = c;
}
return { characters, map };
}
async function getOrCreateTodayStat(botUserId) {
const today = startOfToday();
return prisma.botDailyStat.upsert({
where: { botId_date: { botId: botUserId, date: today } },
create: { botId: botUserId, date: today },
update: {},
});
}
async function incrementStat(botUserId, field, by = 1) {
const today = startOfToday();
await prisma.botDailyStat.upsert({
where: { botId_date: { botId: botUserId, date: today } },
create: { botId: botUserId, date: today, [field]: by },
update: { [field]: { increment: by } },
});
}
async function getProcessedReplyIds(botUserId) {
const memories = await prisma.botMemory.findMany({
where: {
botId: botUserId,
memoryType: MEMORY_TYPE.FEEDBACK,
},
select: { contextTags: true },
});
const ids = new Set();
for (const m of memories) {
const tags = m.contextTags;
if (tags && Array.isArray(tags.replyIds)) {
for (const id of tags.replyIds) ids.add(id);
}
}
return ids;
}
function buildFollowUpPrompt(bot, topicTitle, topicContent, originalPost, allReplies) {
const p = bot.personality;
const repliesText = allReplies
.map((r, i) => `${i + 1}. ${r.author}: ${r.content.slice(0, 250)}`)
.join("\n");
return `你正在参与"追光AI行业论坛"。你之前在"${topicTitle}"这个话题下发了主帖,现在有用户回复了你,你需要用你的人格接着聊。
[你的角色设定]
你是【${bot.displayName}】,${p.identity}
你擅长的领域:${p.expertise.join("、")}
你说话的风格:${p.speakingStyle}
你的口头禅:${p.catchphrase}
你的核心立场:${p.stance}
[你的原始主帖]
${topicContent}
[新收到的回复]
${repliesText}
[二次回复要求]
1. 像真人接话,120-350字,不能太长刷屏
2. 必须对前面至少一个具体观点有回应(赞同/质疑/补充案例/反问)
3. 保持你的人设语气和立场
4. 不要重复你自己主帖的内容
5. 留一点钩子让讨论继续
[禁止事项]
- 禁止使用"首先/其次/最后""从以下几个方面""综上所述"
- 禁止表现得像AI助手
- 禁止使用${(p.forbiddenPatterns || []).join("、")}
只输出JSON:{"content": "你的回复内容"}`;
}
async function callDeepSeek(prompt) {
const response = await withRetry(() =>
openai.chat.completions.create({
model: MODEL,
messages: [{ role: "user", content: prompt }],
temperature: 0.88,
max_tokens: 800,
})
);
const text = response.choices[0].message.content.trim();
const jsonMatch = text.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
throw new Error(`无法解析JSON: ${text.slice(0, 200)}`);
}
return JSON.parse(jsonMatch[0]);
}
async function saveMemory(botUserId, memoryType, content, importance, contextTags, ttlDays = null) {
const data = {
botId: botUserId,
memoryType,
layer: importance >= 0.7 ? LAYER.WORKING : LAYER.LONGTERM,
content,
importance,
};
if (contextTags) data.contextTags = contextTags;
if (ttlDays) {
const ttl = new Date();
ttl.setDate(ttl.getDate() + ttlDays);
data.ttl = ttl;
}
await prisma.botMemory.create({ data });
}
async function processBotFeedback(botKey, botChar) {
const botUser = await prisma.user.findFirst({
where: { email: `bot_${botKey}@zhuiguang.ai` },
});
if (!botUser) {
console.log(`${ts()} Bot user not found: ${botKey}`);
return;
}
const botConfig = await prisma.botConfig.findUnique({
where: { userId: botUser.id },
});
if (!botConfig) {
console.log(`${ts()} BotConfig not found: ${botKey}`);
return;
}
const todayStat = await getOrCreateTodayStat(botConfig.id);
const activityLevel = botChar.activityLevel || "medium";
const quota = QUOTA[activityLevel] || QUOTA.medium;
if (todayStat.followUpSent >= quota.followUpPerDay) {
console.log(`${ts()} ${botChar.displayName} 今日跟帖配额已用完 (${todayStat.followUpSent}/${quota.followUpPerDay})`);
return;
}
const since = new Date(Date.now() - FEEDBACK_LOOKBACK_HOURS * 60 * 60 * 1000);
const processedIds = await getProcessedReplyIds(botConfig.id);
const myTopics = await prisma.forumTopic.findMany({
where: {
userId: botUser.id,
createdAt: { gt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) },
isLocked: false,
},
include: {
category: { select: { name: true, slug: true } },
posts: {
where: { createdAt: { gt: since } },
include: { user: { select: { id: true, name: true, isBot: true } } },
orderBy: { createdAt: "asc" },
},
},
orderBy: { lastReplyAt: "desc" },
take: 10,
});
let totalNewReplies = 0;
let humanReplies = 0;
let followUpsSent = 0;
for (const topic of myTopics) {
const newReplies = topic.posts.filter(p => p.userId !== botUser.id && !processedIds.has(p.id));
if (newReplies.length === 0) continue;
const replyIds = newReplies.map(p => p.id);
const humanCount = newReplies.filter(p => !p.user.isBot).length;
const humanRepliers = newReplies.filter(p => !p.user.isBot).map(p => ({
id: p.userId,
name: p.user.name || "匿名",
}));
await saveMemory(
botConfig.id,
MEMORY_TYPE.FEEDBACK,
{
topicTitle: topic.title,
topicId: topic.id,
forum: topic.category.name,
replyCount: newReplies.length,
humanCount,
repliers: newReplies.map(p => ({
name: p.user.name || "匿名",
isBot: p.user.isBot,
excerpt: p.content.slice(0, 120),
})),
},
humanCount > 0 ? 0.8 : 0.4,
{
topicId: topic.id,
forumSlug: topic.category.slug,
replyIds,
humanRepliers,
source: "feedback_loop",
},
30
);
totalNewReplies += newReplies.length;
humanReplies += humanCount;
await incrementStat(botConfig.id, "feedbackProcessed", 1);
await incrementStat(botConfig.id, "repliesReceived", newReplies.length);
if (humanCount > 0) {
await incrementStat(botConfig.id, "humanInteractions", humanCount);
}
if (
newReplies.length >= MIN_FEEDBACK_TO_FOLLOWUP &&
Math.random() < FOLLOW_UP_PROBABILITY &&
followUpsSent < MAX_FOLLOWUP_PER_RUN_PER_BOT &&
todayStat.followUpSent + followUpsSent < quota.followUpPerDay
) {
try {
const repliesForPrompt = newReplies.map(p => ({
author: p.user.name || "匿名",
content: p.content,
}));
const prompt = buildFollowUpPrompt(
botChar,
topic.title,
topic.content,
null,
repliesForPrompt
);
const result = await callDeepSeek(prompt);
const followUpContent = (result.content || "").trim();
if (!followUpContent || followUpContent.length < 30) {
console.log(`${ts()} ${botChar.displayName} 二次回复内容过短,跳过`);
continue;
}
const post = await prisma.forumPost.create({
data: {
topicId: topic.id,
userId: botUser.id,
content: followUpContent.slice(0, 5000),
},
});
await prisma.forumTopic.update({
where: { id: topic.id },
data: {
replyCount: { increment: 1 },
lastReplyAt: new Date(),
updatedAt: new Date(),
},
});
await saveMemory(
botConfig.id,
MEMORY_TYPE.INTERACTION,
{
action: "follow_up_reply",
forum: topic.category.name,
topicTitle: topic.title,
topicId: topic.id,
triggeredBy: "feedback_loop",
summary: followUpContent.slice(0, 100),
},
0.6,
{
topicId: topic.id,
forumSlug: topic.category.slug,
postId: post.id,
source: "feedback_loop",
},
14
);
followUpsSent++;
await incrementStat(botConfig.id, "followUpSent", 1);
await incrementStat(botConfig.id, "replySent", 1);
console.log(`${ts()} ${botChar.displayName} 二次回复: "${followUpContent.slice(0, 40)}..."`);
} catch (err) {
console.error(`${ts()} ${botChar.displayName} 二次回复失败:`, err.message);
}
}
}
if (totalNewReplies > 0) {
console.log(`${ts()} ${botChar.displayName}: 处理 ${totalNewReplies} 条新回复 (${humanReplies} 真人) → 跟帖 ${followUpsSent} 条`);
}
}
async function logTask(action, status, detail) {
try {
await prisma.taskLog.create({
data: {
taskKey: "bot-feedback-loop",
taskName: "Bot反馈循环",
status,
startedAt: new Date(),
finishedAt: new Date(),
triggerBy: "cron",
result: { action, detail },
},
});
} catch (e) {
console.error(`${ts()} TaskLog error:`, e.message);
}
}
async function main() {
console.log(`${ts()} Bot Feedback Loop starting...`);
if (!process.env.DEEPSEEK_API_KEY) {
throw new Error("DEEPSEEK_API_KEY 未配置");
}
if (!process.env.DATABASE_URL) {
throw new Error("DATABASE_URL 未配置");
}
const { characters } = await loadBotCharacters();
const botChars = characters.filter(c => !c.role || c.role !== "passerby");
console.log(`${ts()} Loaded ${botChars.length} expert bots.`);
let processed = 0;
for (const botChar of botChars) {
try {
await processBotFeedback(botChar.key, botChar);
processed++;
} catch (err) {
console.error(`${ts()} Error processing ${botChar.key}:`, err.message);
}
}
await logTask("feedback_loop", "success", `处理 ${processed} 个数字人`);
console.log(`${ts()} Bot Feedback Loop done.`);
}
main()
.catch(async (e) => {
console.error(`${ts()} Fatal error:`, e);
// 写一条失败日志方便后台追溯
try {
await prisma.taskLog.create({
data: {
taskKey: "bot-feedback-loop",
taskName: "Bot反馈循环",
status: "failed",
startedAt: new Date(),
finishedAt: new Date(),
triggerBy: "cron",
error: (e && e.message ? e.message : String(e)).slice(0, 1000),
},
});
} catch {}
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});