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
+1 -1
View File
@@ -4,7 +4,7 @@ import { Octokit } from "octokit";
import OpenAI from "openai";
import "dotenv/config";
const base = process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4";
const base = (process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4").replace("mysql://", "mariadb://");
const sep = base.includes("?") ? "&" : "?";
const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`;
const adapter = new PrismaMariaDb(connectionString);
+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();
});
+14
View File
@@ -0,0 +1,14 @@
const { PrismaClient } = require('@prisma/client');
const { PrismaMariaDb } = require('@prisma/adapter-mariadb');
const url = (process.env.DATABASE_URL || 'mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4').replace('mysql://', 'mariadb://');
const prisma = new PrismaClient({ adapter: new PrismaMariaDb(url) });
async function main() {
const cats = await prisma.forumCategory.findMany({ select: { id: true, name: true, slug: true } });
console.log('=== Forum Categories ===');
cats.forEach(c => console.log(` id=${c.id} slug="${c.slug}" name="${c.name}"`));
console.log('Total: ' + cats.length);
await prisma.$disconnect();
}
main().catch(e => { console.error(e); process.exit(1); });
+30
View File
@@ -0,0 +1,30 @@
import { PrismaClient } from "@prisma/client";
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import "dotenv/config";
const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://");
const connectionString = `${base}&connection_limit=2`;
const adapter = new PrismaMariaDb(connectionString);
const prisma = new PrismaClient({ adapter });
const count = await prisma.botMemory.count();
const groups = await prisma.botMemory.groupBy({
by: ["memoryType"],
_count: true,
});
console.log(`Total memories: ${count}`);
console.log(groups.map((r) => `${r.memoryType}: ${r._count}`).join(", "));
const recent = await prisma.botMemory.findMany({
orderBy: { createdAt: "desc" },
take: 5,
include: { bot: { select: { displayName: true } } },
});
for (const r of recent) {
const c = r.content;
console.log(
` ${r.bot.displayName} | ${r.memoryType} | ${c.title || c.topicTitle || ""}`
);
}
await prisma.$disconnect();
+1 -1
View File
@@ -2,7 +2,7 @@ import { PrismaClient } from "@prisma/client";
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import "dotenv/config";
const base = process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4";
const base = (process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4").replace("mysql://", "mariadb://");
const sep = base.includes("?") ? "&" : "?";
const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`;
const adapter = new PrismaMariaDb(connectionString);
+40
View File
@@ -0,0 +1,40 @@
#!/bin/bash
set -e
TASK_NAME="$1"
SCRIPT_PATH="$2"
PROJECT_DIR="/home/ubuntu/zhuiguang-ai"
LOG_DIR="$PROJECT_DIR/logs"
ENV_FILE="$PROJECT_DIR/.env"
if [ -z "$TASK_NAME" ] || [ -z "$SCRIPT_PATH" ]; then
echo "用法: $0 <TASK_NAME> <SCRIPT_PATH>"
exit 1
fi
mkdir -p "$LOG_DIR"
LOG_FILE="$LOG_DIR/$TASK_NAME.log"
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
echo "========== $TIMESTAMP 开始执行: $TASK_NAME ==========" >> "$LOG_FILE"
if [ -f "$ENV_FILE" ]; then
set -a
source "$ENV_FILE"
set +a
fi
export PATH="/usr/local/bin:/usr/bin:/bin:$PATH"
cd "$PROJECT_DIR"
if node "$SCRIPT_PATH" >> "$LOG_FILE" 2>&1; then
echo "$(date '+%Y-%m-%d %H:%M:%S') $TASK_NAME 执行成功" >> "$LOG_FILE"
else
EXIT_CODE=$?
echo "$(date '+%Y-%m-%d %H:%M:%S') $TASK_NAME 执行失败 (exit code: $EXIT_CODE)" >> "$LOG_FILE"
exit $EXIT_CODE
fi
echo "" >> "$LOG_FILE"
+2 -2
View File
@@ -5,7 +5,7 @@ import OpenAI from "openai";
import "dotenv/config";
import { withRetry } from "./lib/retry.mjs";
const base = process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4";
const base = (process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4").replace("mysql://", "mariadb://");
const sep = base.includes("?") ? "&" : "?";
const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`;
const adapter = new PrismaMariaDb(connectionString);
@@ -260,7 +260,7 @@ async function main() {
console.log(` ✅ PendingSkill #${pendingSkill.id}`);
await prisma.reviewGenerationLog.create({
data: { skillId: pendingSkill.id, status: "SUCCESS", prompt: `task4-discover: ${repo.full_name}`, response: reviewData ? JSON.stringify(reviewData) : null },
data: { skillId: 0, status: "SUCCESS", prompt: `task4-discover: ${repo.full_name}`, response: reviewData ? JSON.stringify(reviewData) : null },
});
discovered++;
} catch (e) {
+1 -1
View File
@@ -4,7 +4,7 @@ import OpenAI from "openai";
import "dotenv/config";
import { withRetry } from "./lib/retry.mjs";
const base = process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4";
const base = (process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4").replace("mysql://", "mariadb://");
const sep = base.includes("?") ? "&" : "?";
const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`;
const adapter = new PrismaMariaDb(connectionString);
+1 -1
View File
@@ -2,7 +2,7 @@ import { PrismaClient } from "@prisma/client";
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import "dotenv/config";
const base = process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4";
const base = (process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4").replace("mysql://", "mariadb://");
const sep = base.includes("?") ? "&" : "?";
const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`;
const adapter = new PrismaMariaDb(connectionString);
+36
View File
@@ -0,0 +1,36 @@
const fs = require('fs');
const path = require('path');
const scriptsDir = '/home/ubuntu/zhuiguang-ai/scripts';
const files = fs.readdirSync(scriptsDir).filter(f => f.endsWith('.mjs'));
const oldLine = 'const base = process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4";';
const newLine = 'const base = (process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4").replace("mysql://", "mariadb://");';
let fixed = 0;
let skipped = 0;
for (const file of files) {
const filePath = path.join(scriptsDir, file);
let content = fs.readFileSync(filePath, 'utf8');
if (content.includes('PrismaMariaDb') && content.includes(oldLine)) {
content = content.replace(oldLine, newLine);
fs.writeFileSync(filePath, content, 'utf8');
console.log('Fixed: ' + file);
fixed++;
} else if (content.includes('PrismaMariaDb')) {
console.log('Skipped (already fixed or different pattern): ' + file);
skipped++;
}
}
console.log('\nTotal fixed: ' + fixed + ', skipped: ' + skipped);
console.log('\n=== Verification ===');
// Show the fixed lines
const verifyFiles = ['task5-update-stars.mjs', 'task1-discover-tools.mjs', 'daily-news.mjs', 'task7-news-to-community.mjs'];
for (const f of verifyFiles) {
const content = fs.readFileSync(path.join(scriptsDir, f), 'utf8');
const match = content.match(/const base = .*/);
if (match) console.log(f + ': ' + match[0]);
}
+16
View File
@@ -0,0 +1,16 @@
#!/bin/bash
cd /home/ubuntu/zhuiguang-ai/scripts
for f in *.mjs; do
if grep -q 'PrismaMariaDb' "$f" 2>/dev/null; then
OLD="const base = process.env.DATABASE_URL || \"mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4\";"
NEW="const base = (process.env.DATABASE_URL || \"mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4\").replace('mysql://', 'mariadb://');"
if grep -qF "$OLD" "$f" 2>/dev/null; then
sed -i "s|const base = process.env.DATABASE_URL || \"mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4\";|const base = (process.env.DATABASE_URL || \"mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4\").replace('mysql://', 'mariadb://');|g" "$f"
echo "Fixed: $f"
else
echo "Skipped (pattern not matched): $f"
fi
fi
done
echo "=== Verification ==="
grep -n 'const base' task5-update-stars.mjs task1-discover-tools.mjs task3-check-tools.mjs task6-review-hot.mjs task7-news-to-community.mjs daily-discover.mjs daily-news.mjs 2>/dev/null
+73
View File
@@ -0,0 +1,73 @@
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=5&pool_timeout=10`;
const adapter = new PrismaMariaDb(connectionString);
const prisma = new PrismaClient({ adapter });
async function main() {
console.log("=== 修复 PendingTool categoryId 为 NULL 的记录 ===\n");
const categories = await prisma.category.findMany({ select: { id: true, name: true }, orderBy: { id: "asc" } });
if (categories.length === 0) {
console.error("错误: 数据库中没有分类数据");
await prisma.$disconnect();
return;
}
const defaultCategoryId = categories[0].id;
console.log(`默认分类: id=${defaultCategoryId} "${categories[0].name}"`);
const nullCatTools = await prisma.pendingTool.findMany({
where: { categoryId: null, status: "pending" },
select: { id: true, name: true, websiteUrl: true },
});
const catIds = new Set(categories.map(c => c.id));
const invalidCatTools = await prisma.pendingTool.findMany({
where: { status: "pending", categoryId: { not: null } },
select: { id: true, name: true, categoryId: true },
});
const invalidCat = invalidCatTools.filter(t => !catIds.has(t.categoryId));
console.log(`\nPendingTool 统计:`);
console.log(` categoryId 为 NULL: ${nullCatTools.length} 条`);
console.log(` categoryId 指向无效分类: ${invalidCat.length} 条`);
let totalFixed = 0;
if (nullCatTools.length > 0) {
console.log(`\n修复 categoryId 为 NULL 的记录:`);
for (const t of nullCatTools) {
console.log(` id=${t.id} "${t.name}" → categoryId: ${defaultCategoryId}`);
await prisma.pendingTool.update({ where: { id: t.id }, data: { categoryId: defaultCategoryId } });
totalFixed++;
}
}
if (invalidCat.length > 0) {
console.log(`\n修复 categoryId 指向无效分类的记录:`);
for (const t of invalidCat) {
console.log(` id=${t.id} "${t.name}" (categoryId=${t.categoryId}) → ${defaultCategoryId}`);
await prisma.pendingTool.update({ where: { id: t.id }, data: { categoryId: defaultCategoryId } });
totalFixed++;
}
}
if (totalFixed === 0) {
console.log("\n无需修复,所有 PendingTool 记录的 categoryId 均有效。");
} else {
console.log(`\n✅ 共修复 ${totalFixed} 条记录`);
}
console.log("\n建议后续操作:");
console.log(" 1. 重新部署 task3-check-tools.mjs 到服务器 scripts/ 目录");
console.log(" 2. 重启定时任务: pm2 restart all 或重启 cron-wrapper.sh");
console.log(" 3. 验证: 等待下次 task3 执行确认无 FK 错误");
}
main()
.catch((e) => { console.error(e); process.exit(1); })
.finally(async () => { await prisma.$disconnect(); });
+45
View File
@@ -0,0 +1,45 @@
const fs = require('fs');
const path = require('path');
const scriptsDir = '/home/ubuntu/zhuiguang-ai/scripts';
const files = fs.readdirSync(scriptsDir).filter(f => f.endsWith('.mjs'));
// Patterns to remove:
// 1. `const name: Type = ` -> `const name = `
// 2. `let name: Type = ` -> `let name = `
// 3. `: Type` after function parameters (tricky - skip for now)
// 4. `: Type` after variable declarations
let totalFixed = 0;
for (const file of files) {
const filePath = path.join(scriptsDir, file);
let content = fs.readFileSync(filePath, 'utf8');
const original = content;
// Remove type annotations from const/let/var declarations
// Match: `const x: Type =` or `let x: Type =`
content = content.replace(/(const|let|var)\s+(\w+)\s*:\s*[^=]+?(\s*=\s*)/g, '$1 $2 $3');
// Remove type annotations from function return types like `): Type {`
content = content.replace(/\):\s*(Promise<[^>]+>|string|number|boolean|void|any|Record<[^>]+>|Map<[^>]+>|Set<[^>]+>)\s*\{/g, ') {');
if (content !== original) {
fs.writeFileSync(filePath, content, 'utf8');
console.log('Fixed: ' + file);
totalFixed++;
}
}
console.log('\nTotal files fixed: ' + totalFixed);
// Verify task1
const task1 = fs.readFileSync(path.join(scriptsDir, 'task1-discover-tools.mjs'), 'utf8');
const tsLines = task1.split('\n').filter((line, i) => {
const lineNum = i + 1;
return line.match(/(const|let|var)\s+\w+\s*:\s*[^=]/) || line.match(/:\s*(Record|Map|Set|Promise)<[^>]+>\s*[{(]/);
});
if (tsLines.length > 0) {
console.log('\n=== Remaining TS in task1 ===');
tsLines.forEach(l => console.log(l));
}
+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();
});
+1 -1
View File
@@ -3,7 +3,7 @@ import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import OpenAI from "openai";
import "dotenv/config";
const base = process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4";
const base = (process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4").replace("mysql://", "mariadb://");
const sep = base.includes("?") ? "&" : "?";
const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`;
const adapter = new PrismaMariaDb(connectionString);
+67
View File
@@ -0,0 +1,67 @@
#!/bin/bash
# 追光AI 定时任务安装/修复脚本
# 用法: bash install-cron.sh
PROJECT_DIR="/home/ubuntu/zhuiguang-ai"
echo "========================================"
echo " 追光AI 定时任务 - 安装/修复"
echo "========================================"
mkdir -p "$PROJECT_DIR/logs"
echo "✅ 日志目录已创建: $PROJECT_DIR/logs/"
chmod +x "$PROJECT_DIR/scripts/cron-wrapper.sh"
echo "✅ cron-wrapper.sh 已加执行权限"
# 写入 crontab
crontab -l 2>/dev/null | grep -v "zhuiguang" > /tmp/cron-temp 2>/dev/null || true
cat >> /tmp/cron-temp << 'CRONEOF'
# ========== 追光AI 定时任务 ==========
# 使用 cron-wrapper.sh 统一入口(自动加载 .env)
WRAPPER="/home/ubuntu/zhuiguang-ai/scripts/cron-wrapper.sh"
PROJECT="/home/ubuntu/zhuiguang-ai"
# Task5 每天凌晨1:00 更新技能星值
0 1 * * * bash $WRAPPER task5 $PROJECT/scripts/task5-update-stars.mjs
# Task3 每天凌晨3:00 工具巡检
0 3 * * * bash $WRAPPER task3 $PROJECT/scripts/task3-check-tools.mjs
# Task6 每天凌晨4:00 评测热门技能
0 4 * * * bash $WRAPPER task6 $PROJECT/scripts/task6-review-hot.mjs
# Task4 每天凌晨5:00 发现AI技能
0 5 * * * bash $WRAPPER task4 $PROJECT/scripts/daily-discover.mjs
# Task1 每天早上6:00 发现AI工具
0 6 * * * bash $WRAPPER task1 $PROJECT/scripts/task1-discover-tools.mjs
# 每天早上7:30 生成AI日报
30 7 * * * bash $WRAPPER daily-news $PROJECT/scripts/daily-news.mjs
# 每天早上8:00 新闻推送到社区版块
0 8 * * * bash $WRAPPER task7 $PROJECT/scripts/task7-news-to-community.mjs
CRONEOF
crontab /tmp/cron-temp
rm -f /tmp/cron-temp
echo "✅ crontab 已更新(7个定时任务)"
echo ""
echo "当前 crontab 内容:"
crontab -l
echo ""
echo "========================================"
echo " 安装完成!每天自动执行时间:"
echo " 01:00 - Task5 更新技能星值"
echo " 03:00 - Task3 工具巡检"
echo " 04:00 - Task6 评测热门技能"
echo " 05:00 - Task4 发现AI技能"
echo " 06:00 - Task1 发现AI工具"
echo " 07:30 - 生成AI日报"
echo " 08:00 - Task7 新闻→社区"
echo "========================================"
+1 -1
View File
@@ -2,7 +2,7 @@ import { PrismaClient } from "@prisma/client";
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import "dotenv/config";
const base = process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4";
const base = (process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4").replace("mysql://", "mariadb://");
const sep = base.includes("?") ? "&" : "?";
const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`;
const adapter = new PrismaMariaDb(connectionString);
+1 -1
View File
@@ -2,7 +2,7 @@ import { PrismaClient } from "@prisma/client";
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import "dotenv/config";
const base = process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4";
const base = (process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4").replace("mysql://", "mariadb://");
const sep = base.includes("?") ? "&" : "?";
const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`;
const adapter = new PrismaMariaDb(connectionString);
+287
View File
@@ -0,0 +1,287 @@
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 "dotenv/config";
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=10`;
const adapter = new PrismaMariaDb(connectionString);
const prisma = new PrismaClient({ adapter });
const BOT_DATA_PATH = resolve(__dirname, "..", "data", "bot-characters.json");
const INDUSTRY_CATEGORIES = [
{
name: "电商零售", slug: "ecommerce", icon: "🛒", sortOrder: 1,
description: "电商平台运营、选品策略、流量玩法与变现",
children: [
{ name: "平台电商", slug: "ec-platform", icon: "🏪", description: "淘宝/天猫/京东/拼多多/唯品会" },
{ name: "直播带货", slug: "ec-livestream", icon: "📱", description: "抖音/快手/视频号直播运营" },
{ name: "私域电商", slug: "ec-private", icon: "🔗", description: "微信生态/社群/小程序电商" },
{ name: "社区团购", slug: "ec-group", icon: "🏘️", description: "社区团购/买菜/本地配送" },
],
},
{
name: "AI智能科技", slug: "ai-tech", icon: "🤖", sortOrder: 2,
description: "AI技术应用、创业机会与产品出海",
children: [
{ name: "AI工具应用", slug: "ai-tools", icon: "🔧", description: "AI工具推荐、评测与变现" },
{ name: "AI创业", slug: "ai-startup", icon: "💡", description: "AI创业方向与商业模式" },
{ name: "SaaS出海", slug: "ai-saas", icon: "🚀", description: "SaaS产品开发与全球化" },
{ name: "智能硬件", slug: "ai-hardware", icon: "🔌", description: "AI硬件产品与IoT创业" },
],
},
{
name: "房地产", slug: "real-estate", icon: "🏠", sortOrder: 3,
description: "房产买卖、投资分析、装修与物业管理",
children: [
{ name: "住宅买卖", slug: "re-residential", icon: "🏡", description: "刚需/改善/学区房交易" },
{ name: "商业地产", slug: "re-commercial", icon: "🏢", description: "商铺/写字楼/产业园投资" },
{ name: "装修家居", slug: "re-renovation", icon: "🛋️", description: "装修设计/建材/智能家居" },
{ name: "物业运营", slug: "re-property", icon: "🏗️", description: "物业管理/租赁运营/资产托管" },
],
},
{
name: "金融投资", slug: "finance", icon: "📈", sortOrder: 4,
description: "股票基金、加密货币、保险理财与股权投资",
children: [
{ name: "股票基金", slug: "fi-stock", icon: "📊", description: "A股/港股/美股/基金定投" },
{ name: "加密货币", slug: "fi-crypto", icon: "₿", description: "比特币/以太坊/Web3" },
{ name: "保险理财", slug: "fi-insurance", icon: "🛡️", description: "保险配置/理财规划" },
{ name: "私募股权", slug: "fi-pe", icon: "💼", description: "PE/VC/天使投资" },
],
},
{
name: "餐饮食品", slug: "food", icon: "🍜", sortOrder: 5,
description: "餐饮创业、茶饮咖啡、预制菜与食品供应链",
children: [
{ name: "餐饮开店", slug: "fd-restaurant", icon: "🍽️", description: "餐厅选址/运营/连锁加盟" },
{ name: "茶饮咖啡", slug: "fd-tea", icon: "🧋", description: "新茶饮/咖啡/饮品创业" },
{ name: "预制菜", slug: "fd-prepared", icon: "📦", description: "预制菜/半成品/食品加工" },
{ name: "食品供应链", slug: "fd-supply", icon: "🚚", description: "食材采购/冷链/中央厨房" },
],
},
{
name: "自媒体内容", slug: "content", icon: "🎬", sortOrder: 6,
description: "短视频、直播、图文创作与MCN机构运营",
children: [
{ name: "短视频创作", slug: "ct-short-video", icon: "📱", description: "抖音/快手/B站内容创作" },
{ name: "直播运营", slug: "ct-livestream", icon: "🎥", description: "带货直播/娱乐直播/知识直播" },
{ name: "图文内容", slug: "ct-writing", icon: "✍️", description: "公众号/小红书/知乎/头条" },
{ name: "MCN机构", slug: "ct-mcn", icon: "🏢", description: "MCN运营/达人孵化/商务变现" },
],
},
{
name: "本地生活", slug: "local-life", icon: "🏪", sortOrder: 7,
description: "美业、健身、宠物、家政等本地服务创业",
children: [
{ name: "美容美发", slug: "ll-beauty", icon: "💇", description: "美发/美甲/美容/医美" },
{ name: "健身运动", slug: "ll-fitness", icon: "🏋️", description: "健身房/瑜伽/私教/运动康复" },
{ name: "宠物经济", slug: "ll-pet", icon: "🐾", description: "宠物店/宠物食品/宠物医疗" },
{ name: "家政服务", slug: "ll-housekeeping", icon: "🏠", description: "保洁/月嫂/搬家/维修" },
],
},
{
name: "大健康", slug: "health", icon: "💊", sortOrder: 8,
description: "医疗美容、健康管理、养老服务与运动康复",
children: [
{ name: "医疗美容", slug: "hl-cosmetic", icon: "💉", description: "轻医美/微整形/皮肤管理" },
{ name: "健康管理", slug: "hl-wellness", icon: "🫀", description: "体检/慢病管理/保健品" },
{ name: "养老服务", slug: "hl-elderly", icon: "👴", description: "养老院/居家养老/适老化改造" },
{ name: "运动康复", slug: "hl-rehab", icon: "🩹", description: "运动损伤/产后康复/中医理疗" },
],
},
{
name: "教育培训", slug: "education", icon: "📚", sortOrder: 9,
description: "技能培训、知识付费、留学与企业培训",
children: [
{ name: "技能培训", slug: "edu-skills", icon: "🛠️", description: "编程/设计/运营等职业技能" },
{ name: "知识付费", slug: "edu-knowledge", icon: "💎", description: "课程制作/社群运营/个人IP" },
{ name: "留学服务", slug: "edu-abroad", icon: "✈️", description: "留学申请/语言培训/海外游学" },
{ name: "企业培训", slug: "edu-corporate", icon: "🏢", description: "企业内训/管理咨询/团队建设" },
],
},
{
name: "外贸跨境", slug: "cross-border", icon: "🌏", sortOrder: 10,
description: "跨境电商、外贸工厂、海外仓与跨境支付",
children: [
{ name: "跨境电商", slug: "cb-ecommerce", icon: "🛒", description: "Amazon/Temu/TikTok Shop/Shopee" },
{ name: "外贸工厂", slug: "cb-factory", icon: "🏭", description: "代工转品牌/工贸一体/B2B" },
{ name: "海外仓物流", slug: "cb-logistics", icon: "📦", description: "海外仓/FBA/跨境物流" },
{ name: "跨境支付", slug: "cb-payment", icon: "💳", description: "收付款/汇率/资金合规" },
],
},
];
async function createIndustryCategories() {
console.log("Creating industry forum categories...");
const parent = await prisma.forumCategory.upsert({
where: { slug: "industry" },
update: {},
create: {
name: "行业论坛",
slug: "industry",
icon: "🏭",
sortOrder: 10,
description: "各行业交流讨论,AI驱动的行业智囊团",
},
});
for (const cat of INDUSTRY_CATEGORIES) {
let childCat = await prisma.forumCategory.findUnique({
where: { slug: cat.slug },
include: { children: true },
});
if (!childCat) {
childCat = await prisma.forumCategory.create({
data: {
name: cat.name,
slug: cat.slug,
icon: cat.icon,
description: cat.description,
sortOrder: cat.sortOrder,
parentId: parent.id,
},
include: { children: true },
});
console.log(` + ${cat.name}`);
} else {
if (!childCat.parentId) {
await prisma.forumCategory.update({
where: { id: childCat.id },
data: { parentId: parent.id },
});
}
console.log(` ✓ ${cat.name} (exists)`);
}
const existingSlugs = new Set(childCat.children.map(c => c.slug));
for (const sub of cat.children) {
if (existingSlugs.has(sub.slug)) {
console.log(` - ${sub.name} (exists)`);
} else {
const conflictSlug = await prisma.forumCategory.findUnique({
where: { slug: sub.slug },
});
if (conflictSlug) {
await prisma.forumCategory.update({
where: { slug: sub.slug },
data: {
name: sub.name,
icon: sub.icon,
description: sub.description,
parentId: childCat.id,
},
});
console.log(` ↻ ${sub.name} (moved under ${cat.name})`);
} else {
await prisma.forumCategory.create({
data: { ...sub, parentId: childCat.id },
});
console.log(` + ${sub.name}`);
}
}
}
}
console.log("Industry categories created.");
}
async function createBotUsers() {
console.log("Creating/updating bot users...");
const raw = readFileSync(BOT_DATA_PATH, "utf-8");
const { characters } = JSON.parse(raw);
let created = 0;
let updated = 0;
let skipped = 0;
for (const char of characters) {
const existingUser = await prisma.user.findUnique({
where: { email: char.email },
include: { botConfig: true },
});
if (existingUser && existingUser.botConfig) {
await prisma.botConfig.update({
where: { userId: existingUser.id },
data: {
personality: char.personality,
primaryForums: char.primaryForums,
activeHours: char.activeHours,
},
});
console.log(` ↻ ${char.displayName} (updated primaryForums)`);
updated++;
continue;
}
if (existingUser && !existingUser.botConfig) {
await prisma.botConfig.create({
data: {
userId: existingUser.id,
displayName: char.displayName,
personality: char.personality,
primaryForums: char.primaryForums,
activeHours: char.activeHours,
},
});
console.log(` + ${char.displayName} (botConfig created)`);
updated++;
continue;
}
const user = await prisma.user.create({
data: {
email: char.email,
name: char.displayName,
oauthProvider: "bot",
oauthId: `bot_${char.key}`,
role: "user",
isBot: true,
},
});
await prisma.botConfig.create({
data: {
userId: user.id,
displayName: char.displayName,
personality: char.personality,
primaryForums: char.primaryForums,
activeHours: char.activeHours,
},
});
console.log(` + ${char.displayName} (${char.key})`);
created++;
}
console.log(`Bot users: ${created} created, ${updated} updated, ${skipped} skipped.`);
}
async function main() {
console.log("=== Seed Bot Forum System ===\n");
await createIndustryCategories();
console.log("");
await createBotUsers();
console.log("");
console.log("=== Done ===");
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+1 -1
View File
@@ -2,7 +2,7 @@ import { PrismaClient } from "@prisma/client";
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import "dotenv/config";
const base = process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4";
const base = (process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4").replace("mysql://", "mariadb://");
const sep = base.includes("?") ? "&" : "?";
const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`;
const adapter = new PrismaMariaDb(connectionString);
+9 -1
View File
@@ -2,7 +2,7 @@ import { PrismaClient } from "@prisma/client";
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import "dotenv/config";
const base = process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4";
const base = (process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4").replace("mysql://", "mariadb://");
const sep = base.includes("?") ? "&" : "?";
const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`;
const adapter = new PrismaMariaDb(connectionString);
@@ -57,6 +57,14 @@ const TASK_DEFS = [
description: "每天早上7:30从多源RSS搜索真实AI新闻,由DeepSeek整理格式生成当日日报",
config: { sources: ["36kr", "IT之家", "开源中国", "GoogleNews", "HackerNews"], model: "deepseek-v4-pro" },
},
{
taskKey: "bot-activity",
taskName: "Bot活跃度调度",
enabled: true,
cronExpr: "0 8,12,16,20 * * *",
description: "每天4次(8点/12点/16点/20点)调度行业论坛Bot发言:选2个论坛,发起话题或回复",
config: { forumsPerRun: 2, model: "deepseek-chat" },
},
];
async function main() {
+2 -2
View File
@@ -4,7 +4,7 @@ import OpenAI from "openai";
import "dotenv/config";
import { withRetry } from "./lib/retry.mjs";
const base = process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4";
const base = (process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4").replace("mysql://", "mariadb://");
const sep = base.includes("?") ? "&" : "?";
const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`;
const adapter = new PrismaMariaDb(connectionString);
@@ -94,7 +94,7 @@ async function main() {
let totalNews = 0;
let foundTools = 0;
let importedTools = 0;
const errors: string[] = [];
const errors = [];
const existingNames = new Set([
...(await prisma.tool.findMany({ select: { name: true } })).map((t) => t.name.toLowerCase()),
+5 -3
View File
@@ -3,7 +3,7 @@ import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import "dotenv/config";
import { withRetry } from "./lib/retry.mjs";
const base = process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4";
const base = (process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4").replace("mysql://", "mariadb://");
const sep = base.includes("?") ? "&" : "?";
const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`;
const adapter = new PrismaMariaDb(connectionString);
@@ -53,8 +53,10 @@ async function main() {
});
const pendingTools = await prisma.pendingTool.findMany({
where: { status: "pending" },
select: { id: true, name: true, websiteUrl: true, logoUrl: true, checkWebsiteOk: true, checkLogoOk: true },
select: { id: true, name: true, websiteUrl: true, logoUrl: true, checkWebsiteOk: true, checkLogoOk: true, categoryId: true },
});
const firstCategory = await prisma.category.findFirst({ select: { id: true } });
const defaultCategoryId = firstCategory?.id || 1;
console.log(` 已发布工具: ${tools.length}, 待审核: ${pendingTools.length}`);
@@ -107,7 +109,7 @@ async function main() {
data: {
name: pt.name,
slug,
categoryId: pt.categoryId || 1,
categoryId: pt.categoryId || defaultCategoryId,
description: pt.description || "",
logoUrl: pt.logoUrl,
websiteUrl: pt.websiteUrl || "",
+2 -5
View File
@@ -3,7 +3,7 @@ import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import { Octokit } from "octokit";
import "dotenv/config";
const base = process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4";
const base = (process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4").replace("mysql://", "mariadb://");
const sep = base.includes("?") ? "&" : "?";
const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`;
const adapter = new PrismaMariaDb(connectionString);
@@ -47,6 +47,7 @@ async function main() {
}
try {
await new Promise((r) => setTimeout(r, 300));
const { data } = await octokit.rest.repos.get({ owner: parsed.owner, repo: parsed.repo });
const currentStars = data.stargazers_count;
@@ -92,10 +93,6 @@ async function main() {
errors.push(`${skill.name}: ${e.message}`);
console.log(` ❌ ${skill.name}: ${e.message}`);
}
if (updated % 5 === 0) {
await new Promise((r) => setTimeout(r, 1000));
}
}
const endTime = new Date();
+1 -2
View File
@@ -5,7 +5,7 @@ import OpenAI from "openai";
import "dotenv/config";
import { withRetry } from "./lib/retry.mjs";
const base = process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4";
const base = (process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4").replace("mysql://", "mariadb://");
const sep = base.includes("?") ? "&" : "?";
const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`;
const adapter = new PrismaMariaDb(connectionString);
@@ -165,7 +165,6 @@ async function main() {
performance: parsed_review.performance || 3.0,
overall,
reviews: parsed_review.reviews || {},
tags: parsed_review.tags || [],
status: "PUBLISHED",
reviewedAt: new Date(),
},
+225
View File
@@ -0,0 +1,225 @@
import { PrismaClient } from "@prisma/client";
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import "dotenv/config";
const base = (process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4").replace("mysql://", "mariadb://");
const sep = base.includes("?") ? "&" : "?";
const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`;
const adapter = new PrismaMariaDb(connectionString);
const prisma = new PrismaClient({ adapter });
function ts() {
return `[${new Date().toISOString()}]`;
}
function generateSlug(title) {
return title
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, "-")
.replace(/^-|-$/g, "");
}
function ensureUniqueSlug(title) {
const baseSlug = generateSlug(title) || "topic";
const clean = baseSlug.substring(0, 80);
const suffix = Date.now().toString(36);
return `${clean}-${suffix}`;
}
const CATEGORY_MAP = {
"产品发布": "ai-tools",
"product": "ai-tools",
"融资投资": "ai-news",
"business": "ai-news",
"技术突破": "tech",
"model": "tech",
"政策法规": "ai-news",
"policy": "ai-news",
"开源项目": "tech",
"opensource": "tech",
"行业事件": "ai-news",
"insight": "discuss",
};
async function main() {
if (!process.env.DATABASE_URL) throw new Error("缺少环境变量: DATABASE_URL");
const startTime = new Date();
const now = new Date();
const cnOffset = 8 * 60 * 60 * 1000;
const cnTime = new Date(now.getTime() + cnOffset);
const today = cnTime.toISOString().split("T")[0];
const yesterdayDate = new Date(cnTime.getTime() - 24 * 60 * 60 * 1000);
const yesterday = yesterdayDate.toISOString().split("T")[0];
console.log(`${ts()} 📰 [Task7] 开始将AI日报分发到社区板块 ${today}`);
let report = await prisma.dailyReport.findFirst({
where: { date: new Date(today), status: "published" },
include: { items: { orderBy: { sortOrder: "asc" } } },
});
if (!report) {
console.log(`${ts()} ⚠ 今日(${today})无日报,尝试昨天(${yesterday})...`);
report = await prisma.dailyReport.findFirst({
where: { date: new Date(yesterday), status: "published" },
include: { items: { orderBy: { sortOrder: "asc" } } },
});
}
if (!report || report.items.length === 0) {
console.log(`${ts()} ⚠ 没有可用的日报条目,跳过`);
await prisma.taskLog.create({
data: {
taskKey: "task7-news-to-community",
taskName: "AI日报分发到社区",
status: "completed",
startedAt: startTime,
finishedAt: new Date(),
duration: new Date().getTime() - startTime.getTime(),
result: { created: 0, skipped: 0, message: "没有可用的日报条目" },
},
});
await prisma.$disconnect();
return;
}
console.log(`${ts()} 📋 日报ID=${report.id}, 共${report.items.length}条新闻`);
const forumCategories = await prisma.forumCategory.findMany({
where: {
slug: { in: ["ai-tools", "ai-news", "tech", "discuss"] },
},
});
const categoryBySlug = {};
for (const c of forumCategories) {
categoryBySlug[c.slug] = c;
}
console.log(`${ts()} 📂 已加载${forumCategories.length}个社区板块: ${Object.keys(categoryBySlug).join(", ")}`);
const requiredSlugs = ["ai-tools", "ai-news", "tech", "discuss"];
for (const slug of requiredSlugs) {
if (!categoryBySlug[slug]) {
throw new Error(`社区板块不存在: ${slug},请先在管理后台创建社区分类`);
}
}
const adminUser = await prisma.user.findFirst({
where: { role: "ADMIN" },
orderBy: { createdAt: "asc" },
});
if (!adminUser) {
throw new Error("管理员用户不存在,请先创建管理员账号");
}
console.log(`${ts()} 👤 管理员用户: ${adminUser.name || adminUser.email} (ID:${adminUser.id})`);
const topicCount = await prisma.forumTopic.count();
console.log(`${ts()} 📊 社区现有话题数: ${topicCount}`);
let created = 0;
let skipped = 0;
const errors = [];
for (const item of report.items) {
const forumSlug = CATEGORY_MAP[item.category];
if (!forumSlug) {
skipped++;
console.log(`${ts()} ⏭ 跳过(未映射分类): [${item.category}] ${item.title}`);
continue;
}
const forumCategory = categoryBySlug[forumSlug];
if (!forumCategory) {
skipped++;
console.log(`${ts()} ⏭ 跳过(板块不存在): [${item.category}] ${item.title} → ${forumSlug}`);
continue;
}
const existing = await prisma.forumTopic.findFirst({
where: { title: item.title },
});
if (existing) {
skipped++;
console.log(`${ts()} ⏭ 跳过(已存在): ${item.title}`);
continue;
}
const slug = ensureUniqueSlug(item.title);
const content = item.summary
? `${item.summary}\n\n${item.detail || ""}\n\n来源: ${item.sourceUrl || "AI日报"}`
: `${item.detail || ""}\n\n来源: ${item.sourceUrl || "AI日报"}`;
try {
const topic = await prisma.forumTopic.create({
data: {
categoryId: forumCategory.id,
userId: adminUser.id,
title: item.title,
slug,
content: content.trim(),
},
});
await prisma.forumCategory.update({
where: { id: forumCategory.id },
data: { topicCount: { increment: 1 } },
});
created++;
console.log(`${ts()} ✅ 已创建话题: [${forumCategory.name}] ${item.title} (ID:${topic.id})`);
} catch (e) {
errors.push(`${item.title}: ${e.message}`);
console.error(`${ts()} ❌ 创建话题失败: ${item.title} - ${e.message}`);
}
}
const endTime = new Date();
const duration = endTime.getTime() - startTime.getTime();
const result = {
reportId: report.id,
reportDate: report.date.toISOString().split("T")[0],
totalItems: report.items.length,
created,
skipped,
duration: `${(duration / 1000).toFixed(1)}s`,
};
console.log(`\n${ts()} 🏁 [Task7] 完成: 创建${created}个话题, 跳过${skipped}个, 耗时${result.duration}`);
await prisma.taskLog.create({
data: {
taskKey: "task7-news-to-community",
taskName: "AI日报分发到社区",
status: errors.length > 0 && created === 0 ? "failed" : "completed",
startedAt: startTime,
finishedAt: endTime,
duration,
result,
error: errors.length > 0 ? errors.slice(0, 10).join("\n") : null,
},
});
await prisma.$disconnect();
}
main().catch(async (e) => {
console.error(`${ts()} ❌ 任务七失败:`, e.message);
if (prisma) {
try {
await prisma.taskLog.create({
data: {
taskKey: "task7-news-to-community",
taskName: "AI日报分发到社区",
status: "failed",
startedAt: new Date(),
finishedAt: new Date(),
duration: 0,
error: e.message,
},
});
} catch {}
await prisma.$disconnect();
}
process.exit(1);
});
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const packageJsonPath = path.join(__dirname, '../package.json');
function readVersion() {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
return packageJson.version;
}
function parseVersion(versionStr) {
const match = versionStr.match(/^v?(\d+)\.(\d+)\.(\d+)$/i);
if (!match) {
throw new Error(`无效的版本号格式: ${versionStr},应为 V{major}.{minor}.{patch}`);
}
return {
major: parseInt(match[1], 10),
minor: parseInt(match[2], 10),
patch: parseInt(match[3], 10)
};
}
function incrementVersion(currentVersion) {
const { major, minor, patch } = parseVersion(currentVersion);
if (patch >= 20) {
return {
major,
minor: minor + 1,
patch: 0
};
}
return {
major,
minor,
patch: patch + 1
};
}
function formatVersion({ major, minor, patch }) {
return `V${major}.${minor}.${patch}`;
}
function updatePackageJson(newVersion) {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
packageJson.version = newVersion;
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n', 'utf8');
}
function main() {
try {
const currentVersion = readVersion();
console.log(`当前版本: ${currentVersion}`);
const newVersionObj = incrementVersion(currentVersion);
const newVersion = formatVersion(newVersionObj);
console.log(`新版本号: ${newVersion}`);
updatePackageJson(newVersion);
console.log(`package.json 已更新`);
console.log(`\n如需创建git tag,请执行:`);
console.log(`git tag ${newVersion.toLowerCase()}`);
console.log(`git push origin ${newVersion.toLowerCase()}`);
} catch (error) {
console.error(`错误: ${error.message}`);
process.exit(1);
}
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
main();
}
export {
readVersion,
parseVersion,
incrementVersion,
formatVersion,
updatePackageJson
};