fix: 定时任务修复+管理后台Task7支持 (Task4外键修复, Task7管理员邮箱动态查询, Task5限流防护, Task7加入SCRIPT_MAP)

This commit is contained in:
ZhuiGuangAI Dev
2026-05-30 18:03:48 +08:00
parent 4d5e7266b5
commit 10d1acb305
56 changed files with 2779 additions and 135 deletions
+440
View File
@@ -0,0 +1,440 @@
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=10&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 FORUM_SLUGS = [
"ecommerce", "ai-tech", "real-estate", "finance", "food",
"content", "local-life", "health", "education", "cross-border",
];
const FORUM_NAMES = {
ecommerce: "电商零售",
"ai-tech": "AI智能科技",
"real-estate": "房地产",
finance: "金融投资",
food: "餐饮食品",
content: "自媒体内容",
"local-life": "本地生活",
health: "大健康",
education: "教育培训",
"cross-border": "外贸跨境",
};
const SUB_CATEGORY_MAP = {
ecommerce: ["ec-platform", "ec-livestream", "ec-private", "ec-group"],
"ai-tech": ["ai-tools", "ai-startup", "ai-saas", "ai-hardware"],
"real-estate": ["re-residential", "re-commercial", "re-renovation", "re-property"],
finance: ["fi-stock", "fi-crypto", "fi-insurance", "fi-pe"],
food: ["fd-restaurant", "fd-tea", "fd-prepared", "fd-supply"],
content: ["ct-short-video", "ct-livestream", "ct-writing", "ct-mcn"],
"local-life": ["ll-beauty", "ll-fitness", "ll-pet", "ll-housekeeping"],
health: ["hl-cosmetic", "hl-wellness", "hl-elderly", "hl-rehab"],
education: ["edu-skills", "edu-knowledge", "edu-abroad", "edu-corporate"],
"cross-border": ["cb-ecommerce", "cb-factory", "cb-logistics", "cb-payment"],
};
function ts() {
return `[${new Date().toISOString()}]`;
}
function pick(arr) {
return arr[Math.floor(Math.random() * arr.length)];
}
function slugify(str) {
const clean = str.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, "-").replace(/^-|-$/g, "").substring(0, 80);
return `${clean}-${Date.now().toString(36)}`;
}
async function logTask(action, status, detail) {
try {
await prisma.taskLog.create({
data: {
taskKey: "bot-activity",
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 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 };
}
function buildTopicPrompt(bot, forumName, forumDescription) {
const p = bot.personality;
return `你正在参与一个叫"追光AI行业论坛"的中文社区。你现在在"${forumName}"板块发言。
${forumDescription}
[你的角色设定]
你是【${bot.displayName}】,${p.identity}
你擅长的领域:${p.expertise.join("、")}
你不熟悉的领域:${p.weakness.join("、")}
你的核心立场:${p.stance}
你说话的风格:${p.speakingStyle}
你的口头禅:${p.catchphrase}
[发言要求]
请以${bot.displayName}的身份在这个板块发起一个新话题。要求:
1. 话题标题10-20字,要有吸引力,像真人发的帖子,不要写成新闻标题或论文标题
2. 内容200-500字,像真人发帖一样,有具体细节和真实感
3. 必须有你的立场和观点,不要太中立
4. 使用你的说话风格,自然口语化,可以有情绪
5. 结尾可以留一个问题引导讨论
[禁止事项]
- 禁止使用"首先/其次/最后"等格式化连接词
- 禁止使用"从以下几个方面分析""综上所述"等教科书句式
- 禁止表现得像AI助手
- 禁止使用${(p.forbiddenPatterns || []).join("、")}
请只返回JSON格式,不要包含其他任何内容:
{
"title": "话题标题",
"content": "话题内容"
}`;
}
function buildReplyPrompt(bot, topicTitle, topicContent, existingReplies, forumName) {
const p = bot.personality;
const replyText = existingReplies
.map((r, i) => `${r.author}: ${r.content.substring(0, 200)}`)
.join("\n\n");
return `你正在参与一个叫"追光AI行业论坛"的中文社区。你现在在"${forumName}"板块回复一个帖子。
[你的角色设定]
你是【${bot.displayName}】,${p.identity}
你擅长的领域:${p.expertise.join("、")}
你的核心立场:${p.stance}
你说话的风格:${p.speakingStyle}
你的口头禅:${p.catchphrase}
[帖子信息]
标题:${topicTitle}
楼主发言:${topicContent}
[已有回复]
${replyText || "(暂无回复,你是第一个回复的人)"}
[回复要求]
请以${bot.displayName}的身份回复这个帖子。要求:
1. 回复150-450字,有信息增量,不能重复前面已经说过的内容
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.85,
max_tokens: 1024,
})
);
const text = response.choices[0].message.content.trim();
const jsonMatch = text.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
throw new Error(`Could not parse JSON from response: ${text.substring(0, 200)}`);
}
try {
return JSON.parse(jsonMatch[0]);
} catch {
const cleaned = jsonMatch[0]
.replace(/,\s*}/g, "}")
.replace(/,\s*]/g, "]");
try {
return JSON.parse(cleaned);
} catch {
throw new Error(`JSON parse failed after cleanup: ${jsonMatch[0].substring(0, 200)}`);
}
}
}
async function getBotWithConfig(botKey) {
const config = await prisma.botConfig.findFirst({
where: { user: { email: `bot_${botKey}@zhuiguang.ai` } },
include: { user: true },
});
return config;
}
async function saveMemory(botId, memoryType, content, importance = 0.5) {
try {
await prisma.botMemory.create({
data: { botId, memoryType, content, importance },
});
} catch (e) {
console.error(`${ts()} Memory save error:`, e.message);
}
}
async function getRecentMemories(botId, limit = 3) {
try {
return await prisma.botMemory.findMany({
where: { botId },
orderBy: { createdAt: "desc" },
take: limit,
});
} catch {
return [];
}
}
function buildMemoryContext(memories, forumName) {
if (memories.length === 0) return "";
const lines = memories.map(m => {
const c = m.content;
if (m.memoryType === "experience") {
return `• 你最近在"${c.forum}"板块发起了话题"${c.title}"`;
}
return `• 你最近在"${c.forum}"板块回复了关于"${c.topicTitle}"的讨论`;
});
return `\n[你最近的讨论记忆]\n${lines.join("\n")}\n`;
}
async function getCategoryBySlug(slug) {
return prisma.forumCategory.findUnique({ where: { slug } });
}
async function getRandomSubCategory(forumSlug) {
const subs = SUB_CATEGORY_MAP[forumSlug] || [];
if (subs.length === 0) return null;
const slug = pick(subs);
return prisma.forumCategory.findUnique({ where: { slug } });
}
async function getRecentTopicsInForum(forumSlug, limit = 5) {
const cat = await getCategoryBySlug(forumSlug);
if (!cat) return [];
return prisma.forumTopic.findMany({
where: { categoryId: cat.id },
include: {
user: { select: { id: true, name: true } },
posts: {
include: { user: { select: { id: true, name: true } } },
orderBy: { createdAt: "asc" },
},
},
orderBy: { createdAt: "desc" },
take: limit,
});
}
async function createTopic(categoryId, userId, title, content) {
return prisma.forumTopic.create({
data: {
categoryId,
userId,
title,
slug: slugify(title),
content,
},
});
}
async function createPost(topicId, userId, content) {
return prisma.forumPost.create({
data: { topicId, userId, content },
});
}
async function processForum(forumSlug, botCharacters, forceNewTopic = false) {
const category = await getCategoryBySlug(forumSlug);
if (!category) {
console.log(`${ts()} Forum "${forumSlug}" not found, skipping.`);
return;
}
console.log(`${ts()} Processing forum: ${category.name}`);
const forumBots = botCharacters.filter(
c => c.primaryForums && c.primaryForums.includes(forumSlug)
);
if (forumBots.length === 0) {
console.log(`${ts()} No bots assigned to this forum.`);
return;
}
const existingTopics = await getRecentTopicsInForum(forumSlug, 10);
const needsNewTopic = forceNewTopic || existingTopics.length < 3 ||
existingTopics[0] && (Date.now() - existingTopics[0].createdAt.getTime()) > 24 * 60 * 60 * 1000;
if (needsNewTopic) {
const topicAuthor = pick(forumBots.filter(c => c.activityLevel === "high" || c.activityLevel === "medium"));
const botConf = await getBotWithConfig(topicAuthor.key);
if (!botConf) {
console.log(`${ts()} Bot "${topicAuthor.key}" not found in DB.`);
return;
}
console.log(`${ts()} ${topicAuthor.displayName} creating new topic...`);
try {
const recentMemories = await getRecentMemories(botConf.id, 2);
const memoryCtx = buildMemoryContext(recentMemories, category.name);
let prompt = buildTopicPrompt(topicAuthor, category.name, category.description);
if (memoryCtx) {
prompt = prompt.replace("[你的角色设定]", memoryCtx + "[你的角色设定]");
} else {
prompt = prompt.replace("[你的角色设定]", "\n[你的角色设定]");
}
const result = await callDeepSeek(prompt);
const subCat = await getRandomSubCategory(forumSlug);
const catId = subCat ? subCat.id : category.id;
const topic = await createTopic(catId, botConf.userId, result.title, result.content);
console.log(`${ts()} Created topic: "${result.title}"`);
await saveMemory(botConf.id, "experience", {
action: "create_topic",
forum: category.name,
title: result.title,
summary: result.content.substring(0, 100),
}, 0.6);
await logTask("create_topic", "success",
`${topicAuthor.displayName} created topic in ${category.name}: ${result.title}`);
} catch (err) {
console.error(`${ts()} Error creating topic:`, err.message);
await logTask("create_topic", "error", err.message);
}
}
if (existingTopics.length > 0) {
const repliers = forumBots
.filter(() => Math.random() < 0.5)
.slice(0, 3);
for (const replierBot of repliers) {
const botConf = await getBotWithConfig(replierBot.key);
if (!botConf) continue;
const targetTopic = pick(existingTopics);
const authorName = targetTopic.user.name;
const topicContent = targetTopic.content;
const replies = targetTopic.posts.map(p => ({
author: p.user.name,
content: p.content,
}));
const alreadyReplied = targetTopic.posts.some(p => p.userId === botConf.userId);
if (alreadyReplied) {
console.log(`${ts()} ${replierBot.displayName} already replied to "${targetTopic.title}", skipping.`);
continue;
}
console.log(`${ts()} ${replierBot.displayName} replying to "${targetTopic.title}"...`);
try {
const recentMemories = await getRecentMemories(botConf.id, 3);
const memoryCtx = buildMemoryContext(recentMemories, category.name);
let prompt = buildReplyPrompt(replierBot, targetTopic.title, topicContent, replies, category.name);
if (memoryCtx) {
prompt = prompt.replace("[你的角色设定]", memoryCtx + "[你的角色设定]");
} else {
prompt = prompt.replace("[你的角色设定]", "\n[你的角色设定]");
}
const result = await callDeepSeek(prompt);
const post = await createPost(targetTopic.id, botConf.userId, result.content);
console.log(`${ts()} Reply saved (${result.content.length} chars).`);
await saveMemory(botConf.id, "interaction", {
action: "reply",
forum: category.name,
topicTitle: targetTopic.title,
repliedTo: authorName,
summary: result.content.substring(0, 100),
}, 0.5);
await logTask("create_reply", "success",
`${replierBot.displayName} replied to ${authorName}'s topic in ${category.name}`);
} catch (err) {
console.error(`${ts()} Error creating reply:`, err.message);
await logTask("create_reply", "error", err.message);
}
}
}
}
async function main() {
console.log(`${ts()} Bot Activity Engine starting...`);
const { characters: botCharacters } = await loadBotCharacters();
console.log(`${ts()} Loaded ${botCharacters.length} bot characters.`);
const activeForums = FORUM_SLUGS.sort(() => Math.random() - 0.5).slice(0, 2);
console.log(`${ts()} Processing ${activeForums.length} forums: ${activeForums.join(", ")}`);
for (const forumSlug of activeForums) {
await processForum(forumSlug, botCharacters);
}
console.log(`${ts()} Bot Activity Engine done.`);
}
main()
.catch((e) => {
console.error(`${ts()} Fatal error:`, e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});