fix: 定时任务修复+管理后台Task7支持 (Task4外键修复, Task7管理员邮箱动态查询, Task5限流防护, Task7加入SCRIPT_MAP)
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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); });
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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(); });
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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 "========================================"
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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 || "",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
Reference in New Issue
Block a user