feat: 首次推送到Gitea - 完整项目代码 + 安全加固 + 知识库
This commit is contained in:
@@ -36,12 +36,12 @@ function getOpenAI() {
|
||||
if (!process.env.DEEPSEEK_API_KEY) return null;
|
||||
_openai = new OpenAI({
|
||||
apiKey: process.env.DEEPSEEK_API_KEY,
|
||||
baseURL: "https://api.deepseek.com/v1",
|
||||
baseURL: "https://api.deepseek.com",
|
||||
});
|
||||
return _openai;
|
||||
}
|
||||
|
||||
const MODEL = process.env.DEEPSEEK_MODEL || "deepseek-chat";
|
||||
const MODEL = process.env.DEEPSEEK_MODEL || "deepseek-v4-pro";
|
||||
|
||||
function ts() {
|
||||
return `[${new Date().toISOString()}]`;
|
||||
@@ -219,7 +219,8 @@ async function extractInsightWithLLM(botChar, topic) {
|
||||
model: MODEL,
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
temperature: 0.6,
|
||||
max_tokens: 800,
|
||||
// DeepSeek V4 推理 token 计入 max_tokens,预算不足会导致正文截断/为空
|
||||
max_tokens: 4096,
|
||||
})
|
||||
);
|
||||
const text = response.choices?.[0]?.message?.content?.trim() || "";
|
||||
|
||||
@@ -82,7 +82,8 @@ export const PALETTE = [
|
||||
];
|
||||
|
||||
export function pickPalette(key) {
|
||||
return PALETTE[pickFromKey(key, PALETTE.length)];
|
||||
// 改为真正的随机,而不是基于key的哈希
|
||||
return PALETTE[Math.floor(Math.random() * PALETTE.length)];
|
||||
}
|
||||
|
||||
// ---------- Lunaris 调用 ----------
|
||||
@@ -121,35 +122,80 @@ export async function isLunarisDefault(buffer) {
|
||||
* 根据 bot persona 生成 512x512 SVG 头像
|
||||
* 设计要素:
|
||||
* - 渐变背景(palette.from → palette.to)
|
||||
* - 4 个装饰几何形(位置/大小/旋转由 key 决定)
|
||||
* - 中央大字 initial(displayName 首字符)
|
||||
* - 右下角 🤖 标识(用 SVG path 画,不依赖 emoji 字体)
|
||||
* - 独特的几何图案(基于 key 哈希生成)
|
||||
* - 中央标识(使用 displayName 的完整首字或独特符号)
|
||||
* - 装饰元素多样化(圆形、方形、三角形、线条等)
|
||||
*/
|
||||
export function generateSvgAvatar({ key, displayName, persona }) {
|
||||
const palette = pickPalette(key);
|
||||
const [b1, b2, b3, b4] = keyToBytes(key);
|
||||
const hash = hashKey(key);
|
||||
const initial = (displayName || key).charAt(0).toUpperCase();
|
||||
const isPasserby = persona?.role === "passerby";
|
||||
|
||||
// 4 个装饰形:圆/三角/方/菱形,位置基于 key 哈希
|
||||
const shapes = [
|
||||
{ type: "circle", cx: 40 + b1 * 0.7, cy: 60 + b2 * 0.4, r: 30 + (b3 % 40), opacity: 0.18 },
|
||||
{ type: "rect", x: 320 + b2 * 0.3, y: 30 + b3 * 0.2, w: 60 + (b4 % 50), h: 60 + (b1 % 50), rot: b1 % 90, opacity: 0.14 },
|
||||
{ type: "circle", cx: 380 + b3 * 0.2, cy: 320 + b4 * 0.4, r: 40 + (b2 % 60), opacity: 0.12 },
|
||||
{ type: "polygon", points: `60,${380 + b1 * 0.2} ${120 + b2 * 0.2},${440 + b3 * 0.15} ${20 + b4 * 0.3},${460}`, opacity: 0.16 },
|
||||
];
|
||||
// 基于 hash 生成多样化的设计元素
|
||||
const designType = hash % 5; // 5种不同的设计风格
|
||||
const rotation = (hash % 360);
|
||||
const scale = 0.8 + (hash % 40) / 100; // 0.8-1.2
|
||||
|
||||
const shapeSvg = shapes
|
||||
.map((s) => {
|
||||
if (s.type === "circle") {
|
||||
return `<circle cx="${s.cx}" cy="${s.cy}" r="${s.r}" fill="white" opacity="${s.opacity}"/>`;
|
||||
let shapeSvg = "";
|
||||
|
||||
if (designType === 0) {
|
||||
// 风格0:放射状圆形
|
||||
const circles = [];
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const angle = (i * 45) * Math.PI / 180;
|
||||
const cx = 256 + Math.cos(angle) * 150;
|
||||
const cy = 256 + Math.sin(angle) * 150;
|
||||
const r = 30 + (hash % 30);
|
||||
circles.push(`<circle cx="${cx}" cy="${cy}" r="${r}" fill="white" opacity="0.15"/>`);
|
||||
}
|
||||
shapeSvg = circles.join("\n ");
|
||||
} else if (designType === 1) {
|
||||
// 风格1:网格方块
|
||||
const rects = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
for (let j = 0; j < 6; j++) {
|
||||
if ((i + j + hash) % 3 === 0) {
|
||||
const x = 50 + i * 70;
|
||||
const y = 50 + j * 70;
|
||||
rects.push(`<rect x="${x}" y="${y}" width="50" height="50" fill="white" opacity="0.12" transform="rotate(${rotation} ${x + 25} ${y + 25})"/>`);
|
||||
}
|
||||
}
|
||||
if (s.type === "rect") {
|
||||
return `<rect x="${s.x}" y="${s.y}" width="${s.w}" height="${s.h}" transform="rotate(${s.rot} ${s.x + s.w / 2} ${s.y + s.h / 2})" fill="white" opacity="${s.opacity}"/>`;
|
||||
}
|
||||
return `<polygon points="${s.points}" fill="white" opacity="${s.opacity}"/>`;
|
||||
})
|
||||
.join("\n ");
|
||||
}
|
||||
shapeSvg = rects.join("\n ");
|
||||
} else if (designType === 2) {
|
||||
// 风格2:波浪线条
|
||||
const paths = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const y = 80 + i * 80;
|
||||
const amplitude = 40 + (hash % 30);
|
||||
paths.push(`<path d="M 0 ${y} Q 128 ${y - amplitude} 256 ${y} T 512 ${y}" stroke="white" stroke-width="3" fill="none" opacity="0.2"/>`);
|
||||
}
|
||||
shapeSvg = paths.join("\n ");
|
||||
} else if (designType === 3) {
|
||||
// 风格3:三角形阵列
|
||||
const triangles = [];
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const cx = (hash * (i + 1)) % 450 + 30;
|
||||
const cy = (hash * (i + 2)) % 450 + 30;
|
||||
const size = 20 + (hash % 25);
|
||||
const rot = (hash * i) % 360;
|
||||
triangles.push(`<polygon points="${cx},${cy - size} ${cx - size * 0.866},${cy + size * 0.5} ${cx + size * 0.866},${cy + size * 0.5}" fill="white" opacity="0.15" transform="rotate(${rot} ${cx} ${cy})"/>`);
|
||||
}
|
||||
shapeSvg = triangles.join("\n ");
|
||||
} else {
|
||||
// 风格4:螺旋圆点
|
||||
const dots = [];
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const angle = i * 0.5;
|
||||
const radius = 50 + i * 10;
|
||||
const cx = 256 + Math.cos(angle) * radius;
|
||||
const cy = 256 + Math.sin(angle) * radius;
|
||||
const r = 8 + (i % 5) * 2;
|
||||
dots.push(`<circle cx="${cx}" cy="${cy}" r="${r}" fill="white" opacity="${0.3 - i * 0.01}"/>`);
|
||||
}
|
||||
shapeSvg = dots.join("\n ");
|
||||
}
|
||||
|
||||
// 角色副标识
|
||||
const roleBadge = isPasserby
|
||||
@@ -158,6 +204,10 @@ export function generateSvgAvatar({ key, displayName, persona }) {
|
||||
: `<rect x="20" y="20" width="92" height="28" rx="14" fill="rgba(255,255,255,0.25)"/>
|
||||
<text x="66" y="38" text-anchor="middle" fill="white" font-size="13" font-weight="600" font-family="system-ui">行业专家</text>`;
|
||||
|
||||
// 中央文字:使用完整的首字,并添加阴影效果
|
||||
const textShadow = `<text x="258" y="322" text-anchor="middle" fill="rgba(0,0,0,0.3)" font-size="280" font-weight="800" font-family="system-ui,-apple-system,sans-serif">${escapeXml(initial)}</text>`;
|
||||
const textMain = `<text x="256" y="320" text-anchor="middle" fill="white" font-size="280" font-weight="800" font-family="system-ui,-apple-system,sans-serif">${escapeXml(initial)}</text>`;
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<defs>
|
||||
@@ -165,12 +215,6 @@ export function generateSvgAvatar({ key, displayName, persona }) {
|
||||
<stop offset="0%" stop-color="${palette.from}"/>
|
||||
<stop offset="100%" stop-color="${palette.to}"/>
|
||||
</linearGradient>
|
||||
<filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="6"/>
|
||||
<feOffset dx="0" dy="4" result="offsetblur"/>
|
||||
<feComponentTransfer><feFuncA type="linear" slope="0.35"/></feComponentTransfer>
|
||||
<feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<!-- 背景渐变 -->
|
||||
<rect width="512" height="512" fill="url(#bg)"/>
|
||||
@@ -178,9 +222,10 @@ export function generateSvgAvatar({ key, displayName, persona }) {
|
||||
${shapeSvg}
|
||||
<!-- 角色徽章 -->
|
||||
${roleBadge}
|
||||
<!-- 中央大字母 -->
|
||||
<text x="256" y="320" text-anchor="middle" fill="white" font-size="280" font-weight="800" font-family="system-ui,-apple-system,sans-serif" filter="url(#shadow)">${escapeXml(initial)}</text>
|
||||
<!-- 右下角 🤖 标识(用 path 画,不依赖 emoji 字体) -->
|
||||
<!-- 中央大字母(带阴影) -->
|
||||
${textShadow}
|
||||
${textMain}
|
||||
<!-- 右下角 🤖 标识 -->
|
||||
<g transform="translate(440 440)">
|
||||
<circle cx="0" cy="0" r="32" fill="white" opacity="0.95"/>
|
||||
<path d="M-12 -4 L-12 8 Q-12 14 -6 14 L6 14 Q12 14 12 8 L12 -4 Q12 -10 6 -10 L-6 -10 Q-12 -10 -12 -4 Z" fill="${palette.from}"/>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import "dotenv/config";
|
||||
import { tokenize } from "./chinese-text.mjs";
|
||||
|
||||
const base = (process.env.DATABASE_URL || "").replace("mysql://", "mariadb://");
|
||||
const sep = base.includes("?") ? "&" : "?";
|
||||
@@ -29,21 +30,6 @@ const TOP_HUMAN_USERS = 5;
|
||||
const TOP_TOPIC_TYPES = 5;
|
||||
const LAST_TITLES_COUNT = 5;
|
||||
|
||||
const STOP_WORDS = new Set([
|
||||
"的", "了", "是", "在", "和", "与", "或", "也", "都", "就", "不", "没", "要", "我", "你", "他", "她", "它", "们",
|
||||
"这", "那", "有", "为", "到", "对", "及", "等", "把", "被", "从", "向", "以", "其", "之", "于", "上", "下",
|
||||
"里", "外", "中", "一个", "一些", "我们", "你们", "他们", "什么", "怎么", "为什么", "啊", "吗", "呢", "吧",
|
||||
"嗯", "哦", "哈", "啦", "嘿", "唉", "这个", "那个", "一个", "真的", "应该", "可能", "觉得", "认为",
|
||||
]);
|
||||
|
||||
function tokenize(text) {
|
||||
if (!text) return [];
|
||||
return text
|
||||
.replace(/[,。!?、;:""''【】《》()()\[\]·—\.\,\!\?\;\:\'\"\\\/]/g, " ")
|
||||
.split(/\s+/)
|
||||
.filter((w) => w.length >= 2 && !STOP_WORDS.has(w));
|
||||
}
|
||||
|
||||
function pickTopWithCount(arr, topN) {
|
||||
const counts = new Map();
|
||||
for (const item of arr) {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// =============================================================================
|
||||
// 中文轻量分词工具(不依赖第三方分词库)
|
||||
// 用「字符 bigram」做特征提取,兼顾关键词统计与相似度计算
|
||||
// 复用方:reply-quality.mjs(关联度)、bot-persona.mjs(立场关键词)
|
||||
// =============================================================================
|
||||
|
||||
// 高频虚词/停用字(单字)。bigram 两端都是停用字时视为无意义片段。
|
||||
const STOP_CHARS = new Set(
|
||||
(
|
||||
"的了是在和与或也都就不没要我你他她它们这那有为到对及等把被从向以其之于上下里外中" +
|
||||
"啊吗呢吧嗯哦哈啦嘿唉真应该可能觉得认为什么怎么为什么个些这个那个"
|
||||
).split("")
|
||||
);
|
||||
|
||||
function isMeaningfulGram(gram) {
|
||||
const [a, b] = gram;
|
||||
return !(STOP_CHARS.has(a) && STOP_CHARS.has(b));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将中文文本切分为 bigram 词元列表。
|
||||
* 英文/数字保留原样参与切分,标点与空白被剔除。
|
||||
* @param {string} text
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function tokenize(text) {
|
||||
if (!text) return [];
|
||||
const cleaned = String(text).replace(/[^\u4e00-\u9fa5a-zA-Z0-9]+/g, "");
|
||||
const grams = [];
|
||||
for (let i = 0; i < cleaned.length - 1; i++) {
|
||||
const gram = cleaned.slice(i, i + 2);
|
||||
if (isMeaningfulGram(gram)) grams.push(gram);
|
||||
}
|
||||
return grams;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Structured JSON logger with traceId support
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
|
||||
const CURRENT_LEVEL = LOG_LEVELS[process.env.LOG_LEVEL || "info"] ?? LOG_LEVELS.info;
|
||||
|
||||
function formatLog(level, message, data = {}) {
|
||||
const entry = {
|
||||
ts: new Date().toISOString(),
|
||||
level,
|
||||
msg: message,
|
||||
...data,
|
||||
};
|
||||
if (!entry.traceId) {
|
||||
entry.traceId = randomUUID().slice(0, 8);
|
||||
}
|
||||
return JSON.stringify(entry);
|
||||
}
|
||||
|
||||
function createLogger(module) {
|
||||
function log(level, message, data = {}) {
|
||||
if (LOG_LEVELS[level] < CURRENT_LEVEL) return;
|
||||
const line = formatLog(level, message, { module, ...data });
|
||||
if (level === "error") {
|
||||
console.error(line);
|
||||
} else if (level === "warn") {
|
||||
console.warn(line);
|
||||
} else {
|
||||
console.log(line);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
debug: (msg, data) => log("debug", msg, data),
|
||||
info: (msg, data) => log("info", msg, data),
|
||||
warn: (msg, data) => log("warn", msg, data),
|
||||
error: (msg, data) => log("error", msg, data),
|
||||
child: (extra) => createLogger(`${module}:${extra.sub || "child"}`),
|
||||
};
|
||||
}
|
||||
|
||||
export { createLogger };
|
||||
@@ -8,7 +8,7 @@ function getRedis() {
|
||||
host: process.env.REDIS_HOST || "127.0.0.1",
|
||||
port: parseInt(process.env.REDIS_PORT || "6379"),
|
||||
password: process.env.REDIS_PASSWORD || undefined,
|
||||
db: parseInt(process.env.REDIS_DB || "1"),
|
||||
db: parseInt(process.env.REDIS_SCRIPT_DB || "1"),
|
||||
lazyConnect: true,
|
||||
maxRetriesPerRequest: 2,
|
||||
retryStrategy: (times) => Math.min(times * 200, 3000),
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// =============================================================================
|
||||
// Reply Quality Utilities
|
||||
// 共享工具:Bot 回复质量检查、反模式检测、个性化风味
|
||||
// =============================================================================
|
||||
|
||||
import { tokenize } from "./chinese-text.mjs";
|
||||
|
||||
// 最小上下文长度(低于此长度不做关联度评分)
|
||||
export const MIN_CONTEXT_LENGTH = 20;
|
||||
|
||||
// 检查回复与上下文的相关度
|
||||
// 返回 { valid, score } – valid 为 true 表示关联度达标,score 为 0-100 的量化评分
|
||||
export function validateContextRelevance(reply, context) {
|
||||
if (!context || context.length < MIN_CONTEXT_LENGTH) {
|
||||
return { valid: true, score: 70 };
|
||||
}
|
||||
|
||||
const replyGrams = tokenize(reply);
|
||||
if (replyGrams.length === 0) {
|
||||
return { valid: false, score: 0 };
|
||||
}
|
||||
|
||||
const contextSet = new Set(tokenize(context));
|
||||
const matched = replyGrams.filter(g => contextSet.has(g)).length;
|
||||
const relevance = matched / replyGrams.length;
|
||||
|
||||
return {
|
||||
valid: relevance >= 0.05,
|
||||
score: Math.min(100, Math.round(relevance * 100)),
|
||||
};
|
||||
}
|
||||
|
||||
// 反模式检测 – 识别过于通用/模板化的 AI 回复
|
||||
export function detectBadPatterns(text) {
|
||||
const patterns = [
|
||||
/很高兴[能为可以]您/,
|
||||
/必须[地得]说/,
|
||||
/值得[一关]注/,
|
||||
/这是个好(问题|话题)/,
|
||||
/希望[能对]你[有们]帮助/,
|
||||
/感谢[您的你]分享/,
|
||||
/非常有[见解启]发/,
|
||||
];
|
||||
|
||||
const matches = patterns.filter(p => p.test(text));
|
||||
|
||||
return {
|
||||
isGeneric: matches.length >= 3,
|
||||
matchCount: matches.length,
|
||||
patterns: patterns.filter(p => p.test(text)),
|
||||
};
|
||||
}
|
||||
|
||||
// 人格风味前缀 – 给回复添加个性化开场白
|
||||
const PERSONALITY_PREFIXES = {
|
||||
pragmatic: ["实际测试下来", "根据我的经验", "试过之后发现"],
|
||||
enthusiastic: ["太棒了!", "超赞!", "强烈推荐!"],
|
||||
analytical: ["从数据来看", "分析之后发现", "对比了一下数据"],
|
||||
cautious: ["客观来说", "理性分析一下", "需要说明的是"],
|
||||
};
|
||||
|
||||
export function addPersonalityFlavor(text, personaType = "pragmatic") {
|
||||
const prefixes =
|
||||
PERSONALITY_PREFIXES[personaType] || PERSONALITY_PREFIXES.pragmatic;
|
||||
|
||||
// 检查是否已包含任何前缀风味,避免重复添加
|
||||
const allPrefixes = Object.values(PERSONALITY_PREFIXES).flat();
|
||||
if (allPrefixes.some(p => text.startsWith(p))) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const prefix = prefixes[Math.floor(Math.random() * prefixes.length)];
|
||||
return `${prefix},${text}`;
|
||||
}
|
||||
|
||||
// 回复多样性指数 – 检测回复中是否包含重复短语
|
||||
export function checkDiversityScore(text) {
|
||||
if (text.length < 50) return 0.8;
|
||||
|
||||
// 将文本分成句子
|
||||
const sentences = text.split(/[。!?\n]/).filter(Boolean);
|
||||
if (sentences.length < 2) return 0.6;
|
||||
|
||||
// 检查开头词的多样性
|
||||
const sentenceStarts = sentences
|
||||
.map(s => s.trim().substring(0, 2))
|
||||
.filter(Boolean);
|
||||
const uniqueStarts = new Set(sentenceStarts);
|
||||
const startVariety = uniqueStarts.size / sentenceStarts.length;
|
||||
|
||||
// 检查句长分布
|
||||
const lengths = sentences.map(s => s.length);
|
||||
const avgLen = lengths.reduce((a, b) => a + b, 0) / lengths.length;
|
||||
const variance =
|
||||
lengths.reduce((sum, l) => sum + (l - avgLen) ** 2, 0) / lengths.length;
|
||||
|
||||
// 句子开头变化率 + 句长变化率
|
||||
const lengthScore = Math.min(1, variance / 1000);
|
||||
return Math.round((startVariety * 0.6 + lengthScore * 0.4) * 100) / 100;
|
||||
}
|
||||
|
||||
// 获取质量摘要统计
|
||||
export function getQualityMetrics(reply, context, style) {
|
||||
const { score: relevance } = validateContextRelevance(reply, context);
|
||||
const { isGeneric, matchCount } = detectBadPatterns(reply);
|
||||
const diversity = checkDiversityScore(reply);
|
||||
|
||||
return {
|
||||
relevance,
|
||||
isGeneric,
|
||||
patternMatches: matchCount,
|
||||
diversity,
|
||||
style,
|
||||
};
|
||||
}
|
||||
+25
-12
@@ -1,24 +1,37 @@
|
||||
// 简易熔断器:连续失败 N 次后,在冷却期内直接抛错
|
||||
const circuitState = { failures: 0, openUntil: 0 };
|
||||
// 简易熔断器:按 label 分桶,连续失败 N 次后,在冷却期内直接抛错
|
||||
// 不同 label(如 deepseek / github / http)各自独立熔断,互不影响
|
||||
const circuits = new Map(); // label -> { failures, openUntil }
|
||||
const CIRCUIT_THRESHOLD = 5; // 连续失败 5 次触发熔断
|
||||
const CIRCUIT_COOLDOWN_MS = 60000; // 熔断冷却 60 秒
|
||||
|
||||
function getCircuit(label) {
|
||||
let c = circuits.get(label);
|
||||
if (!c) {
|
||||
c = { failures: 0, openUntil: 0 };
|
||||
circuits.set(label, c);
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
function checkCircuit(label) {
|
||||
if (Date.now() < circuitState.openUntil) {
|
||||
throw new Error(`[circuit-breaker] ${label} 熔断中,${Math.ceil((circuitState.openUntil - Date.now()) / 1000)}s 后恢复`);
|
||||
const c = getCircuit(label);
|
||||
if (Date.now() < c.openUntil) {
|
||||
throw new Error(`[circuit-breaker] ${label} 熔断中,${Math.ceil((c.openUntil - Date.now()) / 1000)}s 后恢复`);
|
||||
}
|
||||
}
|
||||
|
||||
function recordSuccess() {
|
||||
circuitState.failures = 0;
|
||||
circuitState.openUntil = 0;
|
||||
function recordSuccess(label) {
|
||||
const c = getCircuit(label);
|
||||
c.failures = 0;
|
||||
c.openUntil = 0;
|
||||
}
|
||||
|
||||
function recordFailure(label) {
|
||||
circuitState.failures++;
|
||||
if (circuitState.failures >= CIRCUIT_THRESHOLD) {
|
||||
circuitState.openUntil = Date.now() + CIRCUIT_COOLDOWN_MS;
|
||||
console.warn(` 🔌 [circuit-breaker] ${label} 连续失败 ${circuitState.failures} 次,熔断 ${CIRCUIT_COOLDOWN_MS / 1000}s`);
|
||||
const c = getCircuit(label);
|
||||
c.failures++;
|
||||
if (c.failures >= CIRCUIT_THRESHOLD) {
|
||||
c.openUntil = Date.now() + CIRCUIT_COOLDOWN_MS;
|
||||
console.warn(` 🔌 [circuit-breaker] ${label} 连续失败 ${c.failures} 次,熔断 ${CIRCUIT_COOLDOWN_MS / 1000}s`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +41,7 @@ async function withRetry(fn, { maxRetries = 3, baseDelay = 1000, label = "operat
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const result = await fn();
|
||||
recordSuccess();
|
||||
recordSuccess(label);
|
||||
return result;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// Task runner with retry, timeout, and structured logging
|
||||
import { createLogger } from "./logger.mjs";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
|
||||
const logger = createLogger("task-runner");
|
||||
|
||||
function createPrisma() {
|
||||
const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://");
|
||||
const sep = base.includes("?") ? "&" : "?";
|
||||
const connectionString = `${base}${sep}connection_limit=3&pool_timeout=30`;
|
||||
const adapter = new PrismaMariaDb(connectionString);
|
||||
return new PrismaClient({ adapter });
|
||||
}
|
||||
|
||||
async function runTask(taskKey, taskName, fn, { timeoutMs = 30 * 60 * 1000, maxRetries = 2 } = {}) {
|
||||
const prisma = createPrisma();
|
||||
const startedAt = new Date();
|
||||
let attempt = 0;
|
||||
let lastError;
|
||||
|
||||
logger.info(`Starting task: ${taskName}`, { taskKey });
|
||||
|
||||
while (attempt <= maxRetries) {
|
||||
if (attempt > 0) {
|
||||
const delay = Math.min(5000 * Math.pow(2, attempt - 1), 30000);
|
||||
logger.warn(`Retry ${attempt}/${maxRetries} for ${taskName} after ${delay}ms`, { taskKey });
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
}
|
||||
|
||||
try {
|
||||
// Timeout wrapper
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error(`Task timed out after ${timeoutMs / 60000} minutes`)), timeoutMs)
|
||||
);
|
||||
|
||||
const result = await Promise.race([fn(prisma), timeoutPromise]);
|
||||
|
||||
// Log success
|
||||
await prisma.taskLog.create({
|
||||
data: {
|
||||
taskKey,
|
||||
taskName,
|
||||
status: "success",
|
||||
startedAt,
|
||||
finishedAt: new Date(),
|
||||
duration: Date.now() - startedAt.getTime(),
|
||||
triggerBy: "cron",
|
||||
result: result ? JSON.parse(JSON.stringify(result).slice(0, 2000)) : undefined,
|
||||
},
|
||||
}).catch(() => {});
|
||||
|
||||
logger.info(`Task completed: ${taskName}`, {
|
||||
taskKey,
|
||||
durationMs: Date.now() - startedAt.getTime(),
|
||||
attempt,
|
||||
});
|
||||
|
||||
await prisma.$disconnect();
|
||||
return result;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
attempt++;
|
||||
logger.error(`Task attempt failed: ${taskName}`, {
|
||||
taskKey,
|
||||
attempt,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// All retries exhausted
|
||||
const errorMsg = (lastError?.message || String(lastError)).slice(0, 1000);
|
||||
await prisma.taskLog.create({
|
||||
data: {
|
||||
taskKey,
|
||||
taskName,
|
||||
status: "failed",
|
||||
startedAt,
|
||||
finishedAt: new Date(),
|
||||
duration: Date.now() - startedAt.getTime(),
|
||||
triggerBy: "cron",
|
||||
error: errorMsg,
|
||||
},
|
||||
}).catch(() => {});
|
||||
|
||||
logger.error(`Task failed after ${maxRetries + 1} attempts: ${taskName}`, {
|
||||
taskKey,
|
||||
error: errorMsg,
|
||||
});
|
||||
|
||||
await prisma.$disconnect();
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
export { runTask, createLogger };
|
||||
@@ -0,0 +1,83 @@
|
||||
// =============================================================================
|
||||
// Topic Diversity Utilities
|
||||
// 共享工具:为所有 Bot 脚本提供多样化的内容生成模板和风格
|
||||
// =============================================================================
|
||||
|
||||
// 话题模板 – 避免话题标题千篇一律
|
||||
export const TOPIC_TEMPLATES = [
|
||||
"【真实体验】{tool_name}试用{n}天后的真实感受",
|
||||
"【对比评测】{tool_a} vs {tool_b}:到底谁更好用?",
|
||||
"【使用技巧】{tool_name}的{n}个隐藏功能你知道吗?",
|
||||
"【效率提升】用了{tool_name},我的工作效率提升了{n}%",
|
||||
"【避坑指南】{tool_name}常见的{n}个使用误区",
|
||||
"【学习心得】零基础{n}天掌握{tool_name}",
|
||||
"【行业思考】{category}领域AI工具的进化方向",
|
||||
"【故事分享】我是如何用{skill_type}完成一个不可能的任务的",
|
||||
"【趋势预测】{year}年{category}工具的发展趋势",
|
||||
"【问题交流】大家用{tool_name}遇到过这个问题吗?",
|
||||
];
|
||||
|
||||
// 回复风格类型 – 确保回复语调多样化
|
||||
export const COMMENT_STYLES = [
|
||||
"agree_expert", // 资深用户赞同
|
||||
"question_detail", // 追问细节
|
||||
"share_experience", // 分享自己经验
|
||||
"constructive_critique",// 建设性批评
|
||||
"appreciation", // 感谢分享
|
||||
"alternative_suggest", // 推荐替代方案
|
||||
];
|
||||
|
||||
// 回复风格对应的中文提示(注入 prompt 指导 AI 生成特定风格)
|
||||
export const COMMENT_STYLE_HINTS = {
|
||||
agree_expert: "用资深用户的身份,赞同前面某个具体观点,并补充你自己的实战数据或行业经验来佐证",
|
||||
question_detail: "针对前面内容的某个具体细节,提出一个有深度的问题,引导对方或其他人进一步展开",
|
||||
share_experience: "分享一个你亲身经历的相关案例或故事,让讨论更接地气",
|
||||
constructive_critique: "提出一个不同的视角或潜在的盲点,语气要理性、建设性,不是抬杠",
|
||||
appreciation: "表达对前面讨论的欣赏,指出哪些观点对你有启发,然后自然地延伸一个相关话题",
|
||||
alternative_suggest: "推荐一个实用的替代方案或工具,说明它的优缺点和适用场景",
|
||||
};
|
||||
|
||||
// 轮转索引 – 确保话题模板不会短期内重复
|
||||
let templateIndex = 0;
|
||||
export function getNextTemplate() {
|
||||
const t = TOPIC_TEMPLATES[templateIndex % TOPIC_TEMPLATES.length];
|
||||
templateIndex++;
|
||||
return t;
|
||||
}
|
||||
|
||||
// 重置轮转索引(用于测试或手动干预)
|
||||
export function resetTemplateIndex() {
|
||||
templateIndex = 0;
|
||||
}
|
||||
|
||||
// 选择回复风格 – 避免与最近使用过的风格重复
|
||||
export function pickCommentStyle(recentStyles = []) {
|
||||
const available = COMMENT_STYLES.filter(s => !recentStyles.includes(s));
|
||||
if (available.length === 0) {
|
||||
// 所有风格都在最近使用过,随机选一个
|
||||
return COMMENT_STYLES[Math.floor(Math.random() * COMMENT_STYLES.length)];
|
||||
}
|
||||
return available[Math.floor(Math.random() * available.length)];
|
||||
}
|
||||
|
||||
// 获取风格的中文提示
|
||||
export function getStyleHint(style) {
|
||||
return COMMENT_STYLE_HINTS[style] || COMMENT_STYLE_HINTS.share_experience;
|
||||
}
|
||||
|
||||
// 话题内容类型 – 为发帖内容提供多样化结构
|
||||
export const TOPIC_CONTENT_PATTERNS = [
|
||||
"story_lead", // 以个人故事开头
|
||||
"question_kickoff", // 以一个尖锐问题开头
|
||||
"data_insight", // 以数据洞察开头
|
||||
"controversial_take",// 以争议性观点开头
|
||||
"trend_notice", // 以最近趋势变化开头
|
||||
"personal_struggle", // 以个人困惑/难题开头
|
||||
];
|
||||
|
||||
let patternIndex = 0;
|
||||
export function getNextContentPattern() {
|
||||
const p = TOPIC_CONTENT_PATTERNS[patternIndex % TOPIC_CONTENT_PATTERNS.length];
|
||||
patternIndex++;
|
||||
return p;
|
||||
}
|
||||
Reference in New Issue
Block a user