全项目扫描修复: Docker数据卷修复+安全requireAdmin+12页SEO+API白名单+脚本超时+常量提取+假数据删除
This commit is contained in:
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user