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
+250
View File
@@ -0,0 +1,250 @@
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 = [
"money", "ecommerce", "ai-tech", "career", "cross-border",
"real-estate", "content", "offline-biz", "education", "fire-life",
];
const FORUM_NAMES = {
money: "赚钱与副业", ecommerce: "电商运营", "ai-tech": "AI科技",
career: "职场创业", "cross-border": "跨境电商", "real-estate": "房产投资",
content: "自媒体内容", "offline-biz": "实体生意", education: "教育成长",
"fire-life": "FIRE与生活",
};
const SUB_CATEGORY_MAP = {
money: ["money-projects", "money-pitfalls", "money-models"],
ecommerce: ["ec-platform", "ec-livestream", "ec-product"],
"ai-tech": ["ai-tools-forum", "ai-startup", "ai-frontier"],
career: ["career-grow", "career-startup", "career-35"],
"cross-border": ["cb-platform", "cb-logistics", "cb-local"],
"real-estate": ["re-buy", "re-invest", "re-market"],
content: ["ct-video", "ct-writing", "ct-money"],
"offline-biz": ["ob-shop", "ob-food", "ob-factory"],
education: ["edu-skills", "edu-mindset", "edu-books"],
"fire-life": ["fl-fire", "fl-invest", "fl-lifestyle"],
};
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)}`;
}
function randomDate(daysAgo) {
const d = new Date();
d.setDate(d.getDate() - Math.floor(Math.random() * daysAgo));
d.setHours(Math.floor(Math.random() * 12) + 8, Math.floor(Math.random() * 60), 0, 0);
return d;
}
async function loadBotCharacters() {
const raw = readFileSync(BOT_DATA_PATH, "utf-8");
const { characters } = JSON.parse(raw);
return characters;
}
function buildContentPrompt(bot, forumName, isTopic, existingContent) {
const p = bot.personality;
if (isTopic) {
return `你正在模拟一个中文行业论坛的帖子。现在是2026年,这个论坛叫"追光AI行业论坛",板块是"${forumName}"。
[角色]
你是【${bot.displayName}】,${p.identity}
擅长:${p.expertise.join("、")}
立场:${p.stance}
风格:${p.speakingStyle}
[任务]
以${bot.displayName}的身份发起一个新话题。这是"预生成历史内容",用来让新论坛看起来有一定基础。话题可以基于${bot.displayName}的"经历"来写,让他看起来已经在这个论坛活跃了一段时间。
要求:
1. 标题10-20字,像真人在论坛发的帖子
2. 内容200-500字,口语化,有具体细节
3. 不要用"首先/其次/最后""综上所述"等格式
4. 可以有情绪,有立场
只输出JSON:{"title":"标题","content":"内容"}`;
}
return `你正在模拟一个中文行业论坛的回复。板块是"${forumName}"。
[角色]
你是【${bot.displayName}】,${p.identity}
立场:${p.stance}
风格:${p.speakingStyle}
[上下文]
楼主发言:${existingContent}
[任务]
以${bot.displayName}的身份回复。可以赞同、质疑、补充案例或提问。
要求:
1. 150-400字
2. 口语化自然
3. 不重复楼主的内容
只输出JSON:{"content":"回复内容"}`;
}
async function callDeepSeek(prompt) {
const response = await withRetry(() =>
openai.chat.completions.create({
model: MODEL,
messages: [{ role: "user", content: prompt }],
temperature: 0.9,
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: ${text.substring(0, 200)}`);
}
return JSON.parse(jsonMatch[0]);
}
async function getBotUser(botKey) {
const config = await prisma.botConfig.findFirst({
where: { user: { email: `bot_${botKey}@zhuiguang.ai` } },
include: { user: true },
});
return config;
}
async function getSubCategory(forumSlug) {
const subs = SUB_CATEGORY_MAP[forumSlug] || [];
const slug = pick(subs);
return prisma.forumCategory.findUnique({ where: { slug } });
}
async function main() {
console.log(`${ts()} Pre-generating historical forum content...`);
const allBots = await loadBotCharacters();
console.log(`${ts()} Loaded ${allBots.length} bot definitions.`);
let totalTopics = 0;
let totalReplies = 0;
for (const forumSlug of FORUM_SLUGS) {
const forumName = FORUM_NAMES[forumSlug];
const forumBots = allBots.filter(c => c.primaryForums && c.primaryForums.includes(forumSlug));
if (forumBots.length === 0) continue;
const subCat = await getSubCategory(forumSlug);
if (!subCat) {
console.log(`${ts()} No sub-category found for ${forumSlug}, skipping.`);
continue;
}
const topicCount = Math.floor(Math.random() * 2) + 2;
console.log(`${ts()} ${forumName}: generating ${topicCount} topics...`);
for (let i = 0; i < topicCount; i++) {
const topicBot = pick(forumBots);
const botConf = await getBotUser(topicBot.key);
if (!botConf) continue;
const topicDate = randomDate(90);
try {
const prompt = buildContentPrompt(topicBot, forumName, true, "");
const result = await callDeepSeek(prompt);
await prisma.forumTopic.create({
data: {
categoryId: subCat.id,
userId: botConf.userId,
title: result.title,
slug: slugify(result.title),
content: result.content,
createdAt: topicDate,
updatedAt: topicDate,
},
});
totalTopics++;
console.log(`${ts()} + Topic: "${result.title}" by ${topicBot.displayName}`);
const replyCount = Math.floor(Math.random() * 2) + 1;
const replierCandidates = forumBots.filter(b => b.key !== topicBot.key);
const selectedRepliers = replierCandidates.sort(() => Math.random() - 0.5).slice(0, replyCount);
for (const repBot of selectedRepliers) {
const repConf = await getBotUser(repBot.key);
if (!repConf) continue;
const replyDate = new Date(topicDate.getTime() + Math.floor(Math.random() * 12 * 60 * 60 * 1000));
try {
const replyPrompt = buildContentPrompt(repBot, forumName, false, result.content);
const replyResult = await callDeepSeek(replyPrompt);
await prisma.forumPost.create({
data: {
topicId: (await prisma.forumTopic.findFirst({
where: { title: result.title, userId: botConf.userId },
orderBy: { createdAt: "desc" },
})).id,
userId: repConf.userId,
content: replyResult.content,
createdAt: replyDate,
updatedAt: replyDate,
},
});
totalReplies++;
console.log(`${ts()} + Reply by ${repBot.displayName}`);
} catch (err) {
console.error(`${ts()} Reply error:`, err.message);
}
}
} catch (err) {
console.error(`${ts()} Topic error:`, err.message);
}
}
}
console.log(`${ts()} Done! Created ${totalTopics} topics and ${totalReplies} replies.`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});