721 lines
20 KiB
JavaScript
721 lines
20 KiB
JavaScript
const express = require('express');
|
||
const path = require('path');
|
||
const axios = require('axios');
|
||
const cors = require('cors');
|
||
const dotenv = require('dotenv');
|
||
const OpenAI = require('openai');
|
||
const { KnowledgeBase } = require('./knowledge-base');
|
||
|
||
dotenv.config();
|
||
|
||
const app = express();
|
||
const PORT = process.env.PORT || 4173;
|
||
|
||
const SUANMING_API_BASE = process.env.SUANMING_API_BASE || 'http://localhost:3001/api';
|
||
const DEEPSEEK_API_KEY = process.env.DEEPSEEK_API_KEY || '';
|
||
const DEEPSEEK_API_URL =
|
||
process.env.DEEPSEEK_API_URL || 'https://api.deepseek.com';
|
||
const DEEPSEEK_MODEL = process.env.DEEPSEEK_MODEL || 'deepseek-chat';
|
||
const DEEPSEEK_TIMEOUT_MS = Number(process.env.DEEPSEEK_TIMEOUT_MS || 20000);
|
||
const SUANMING_TIMEOUT_MS = Number(process.env.SUANMING_TIMEOUT_MS || 15000);
|
||
const SUANMING_EMAIL = process.env.SUANMING_EMAIL || '';
|
||
const SUANMING_PASSWORD = process.env.SUANMING_PASSWORD || '';
|
||
const DOCUMENTS_DIR = path.resolve(__dirname, process.env.DOCUMENTS_DIR || 'Documents');
|
||
const KNOWLEDGE_ENABLED = parseBoolean(process.env.KNOWLEDGE_ENABLED, true);
|
||
const KNOWLEDGE_TOP_K = Number(process.env.KNOWLEDGE_TOP_K || 6);
|
||
const KNOWLEDGE_CHUNK_SIZE = Number(process.env.KNOWLEDGE_CHUNK_SIZE || 1200);
|
||
const KNOWLEDGE_MAX_CONTEXT_CHARS = Number(
|
||
process.env.KNOWLEDGE_MAX_CONTEXT_CHARS || 7000
|
||
);
|
||
const RAW_SUANMING_PROMPT_LIMIT = Number(
|
||
process.env.RAW_SUANMING_PROMPT_LIMIT || 12000
|
||
);
|
||
const ZODIAC_GUIDE_FILE = '十二星座运势解析与开运指南.md';
|
||
const CORS_ALLOWED_ORIGINS = parseCsvEnv(process.env.CORS_ALLOWED_ORIGINS || '');
|
||
const KNOWLEDGE_RELOAD_AUTH_ENABLED = parseBoolean(
|
||
process.env.KNOWLEDGE_RELOAD_AUTH_ENABLED,
|
||
true
|
||
);
|
||
const KNOWLEDGE_ADMIN_TOKEN = process.env.KNOWLEDGE_ADMIN_TOKEN || '';
|
||
const INCLUDE_RAW_SUANMING = parseBoolean(process.env.INCLUDE_RAW_SUANMING, true);
|
||
|
||
const knowledgeBase = new KnowledgeBase({
|
||
enabled: KNOWLEDGE_ENABLED,
|
||
documentsDir: DOCUMENTS_DIR,
|
||
topK: KNOWLEDGE_TOP_K,
|
||
chunkSize: KNOWLEDGE_CHUNK_SIZE,
|
||
maxContextChars: KNOWLEDGE_MAX_CONTEXT_CHARS,
|
||
defaultKeywords: ['八字', '命理', '运势', '事业', '财运', '感情', '健康', '风水', '周易'],
|
||
});
|
||
|
||
// Token 管理器
|
||
const tokenManager = {
|
||
token: null,
|
||
loginPromise: null, // 防止并发登录
|
||
|
||
async login() {
|
||
// 如果已有登录请求在进行,等待其完成
|
||
if (this.loginPromise) {
|
||
return this.loginPromise;
|
||
}
|
||
|
||
if (!SUANMING_EMAIL || !SUANMING_PASSWORD) {
|
||
console.error('[tokenManager] SUANMING_EMAIL 或 SUANMING_PASSWORD 未配置');
|
||
return null;
|
||
}
|
||
|
||
// 创建登录 Promise 并保存引用
|
||
this.loginPromise = (async () => {
|
||
try {
|
||
console.log('[tokenManager] 正在登录获取 token...');
|
||
const response = await axios.post(
|
||
`${SUANMING_API_BASE}/auth/login`,
|
||
{ email: SUANMING_EMAIL, password: SUANMING_PASSWORD },
|
||
{ timeout: SUANMING_TIMEOUT_MS }
|
||
);
|
||
|
||
this.token = response.data?.data?.token;
|
||
if (this.token) {
|
||
console.log('[tokenManager] Token 获取成功');
|
||
}
|
||
return this.token;
|
||
} catch (error) {
|
||
console.error('[tokenManager] 登录失败:', error.message);
|
||
return null;
|
||
} finally {
|
||
this.loginPromise = null;
|
||
}
|
||
})();
|
||
|
||
return this.loginPromise;
|
||
},
|
||
|
||
async getToken() {
|
||
if (!this.token) {
|
||
await this.login();
|
||
}
|
||
return this.token;
|
||
},
|
||
|
||
async refreshToken() {
|
||
this.token = null;
|
||
return this.login();
|
||
}
|
||
};
|
||
|
||
const DEEPSEEK_BASE_URL = DEEPSEEK_API_URL.replace(
|
||
/\/v1\/chat\/completions\/?$/,
|
||
''
|
||
);
|
||
|
||
const openaiClient =
|
||
DEEPSEEK_API_KEY &&
|
||
new OpenAI({
|
||
baseURL: DEEPSEEK_BASE_URL,
|
||
apiKey: DEEPSEEK_API_KEY,
|
||
timeout: DEEPSEEK_TIMEOUT_MS,
|
||
});
|
||
|
||
const corsOptions = buildCorsOptions();
|
||
app.use(cors(corsOptions));
|
||
app.options('*', cors(corsOptions));
|
||
app.use(express.json({ limit: '1mb' }));
|
||
|
||
app.use((req, _res, next) => {
|
||
req.requestTime = Date.now();
|
||
next();
|
||
});
|
||
|
||
const publicDir = path.join(__dirname, 'public');
|
||
app.use(express.static(publicDir));
|
||
|
||
app.get('/api/health', (_req, res) => {
|
||
res.json({
|
||
status: 'ok',
|
||
time: new Date().toISOString(),
|
||
});
|
||
});
|
||
|
||
app.get('/api/knowledge/status', async (_req, res) => {
|
||
try {
|
||
const status = await knowledgeBase.getStatus();
|
||
res.json({ success: true, data: status });
|
||
} catch (error) {
|
||
res.status(500).json({
|
||
success: false,
|
||
error: {
|
||
code: 'KNOWLEDGE_STATUS_FAILED',
|
||
message: error.message,
|
||
},
|
||
});
|
||
}
|
||
});
|
||
|
||
app.post('/api/knowledge/reload', requireKnowledgeReloadAuth, async (_req, res) => {
|
||
try {
|
||
await knowledgeBase.ensureLoaded(true);
|
||
const status = await knowledgeBase.getStatus();
|
||
res.json({ success: true, data: status });
|
||
} catch (error) {
|
||
res.status(500).json({
|
||
success: false,
|
||
error: {
|
||
code: 'KNOWLEDGE_RELOAD_FAILED',
|
||
message: error.message,
|
||
},
|
||
});
|
||
}
|
||
});
|
||
|
||
app.post('/api/fortune', async (req, res) => {
|
||
try {
|
||
const validationError = validateFortuneRequest(req.body);
|
||
if (validationError) {
|
||
return res.status(400).json({
|
||
success: false,
|
||
error: {
|
||
code: 'VALIDATION_ERROR',
|
||
message: validationError,
|
||
},
|
||
});
|
||
}
|
||
|
||
const { endpoint, payload } = buildSuanmingRequest(req.body);
|
||
|
||
// 调用 suanming API,支持 token 过期自动重试
|
||
let suanmingResponse;
|
||
try {
|
||
suanmingResponse = await callSuanmingApi(endpoint, payload);
|
||
} catch (error) {
|
||
// 如果是 401 错误,刷新 token 后重试一次
|
||
if (error.response?.status === 401) {
|
||
console.log('[fortune] Token 过期,正在刷新...');
|
||
await tokenManager.refreshToken();
|
||
suanmingResponse = await callSuanmingApi(endpoint, payload);
|
||
} else {
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
const rawSuanming = suanmingResponse.data;
|
||
const deepseekResult = await buildDeepseekInterpretation(req.body, rawSuanming);
|
||
|
||
return res.json({
|
||
success: true,
|
||
data: {
|
||
ai_text: deepseekResult.text,
|
||
...(INCLUDE_RAW_SUANMING ? { raw_suanming: rawSuanming } : {}),
|
||
meta: {
|
||
type: req.body.type || 'bazi',
|
||
model: deepseekResult.model,
|
||
elapsed_ms: Date.now() - req.requestTime,
|
||
raw_suanming_included: INCLUDE_RAW_SUANMING,
|
||
warnings: mergeWarnings(deepseekResult),
|
||
time_anchor: deepseekResult.time_anchor,
|
||
knowledge: deepseekResult.knowledge,
|
||
},
|
||
},
|
||
});
|
||
} catch (error) {
|
||
console.error('[fortune] request failed', error.message);
|
||
const status = error.response?.status || error.statusCode || 500;
|
||
const errorPayload = {
|
||
success: false,
|
||
error: {
|
||
code: deriveErrorCode(error),
|
||
message: error.response?.data?.error?.message || error.message || 'Unknown error',
|
||
},
|
||
};
|
||
|
||
if (error.response?.data) {
|
||
errorPayload.error.details = error.response.data;
|
||
}
|
||
|
||
return res.status(status).json(errorPayload);
|
||
}
|
||
});
|
||
|
||
// 调用 suanming API
|
||
async function callSuanmingApi(endpoint, payload) {
|
||
const token = await tokenManager.getToken();
|
||
const headers = { 'Content-Type': 'application/json' };
|
||
if (token) {
|
||
headers.Authorization = `Bearer ${token}`;
|
||
}
|
||
|
||
return axios.post(
|
||
`${SUANMING_API_BASE}${endpoint}`,
|
||
payload,
|
||
{ timeout: SUANMING_TIMEOUT_MS, headers }
|
||
);
|
||
}
|
||
|
||
app.get('*', (_req, res) => {
|
||
res.sendFile(path.join(publicDir, 'index.html'));
|
||
});
|
||
|
||
app.listen(PORT, async () => {
|
||
console.log(`🧙 Suanming gateway running at http://localhost:${PORT}`);
|
||
if (!CORS_ALLOWED_ORIGINS.length) {
|
||
console.warn('[security] CORS_ALLOWED_ORIGINS 未配置,当前仅同源可用,跨域请求将被浏览器拦截。');
|
||
}
|
||
if (KNOWLEDGE_RELOAD_AUTH_ENABLED && !KNOWLEDGE_ADMIN_TOKEN) {
|
||
console.warn(
|
||
'[security] KNOWLEDGE_ADMIN_TOKEN 未配置,/api/knowledge/reload 将返回 503。'
|
||
);
|
||
}
|
||
// 启动时预热 Token
|
||
await tokenManager.getToken();
|
||
// 启动时加载知识库文档
|
||
try {
|
||
await knowledgeBase.ensureLoaded();
|
||
} catch (error) {
|
||
console.error(`[knowledge] startup preload failed: ${error.message}`);
|
||
}
|
||
});
|
||
|
||
function validateFortuneRequest(body = {}) {
|
||
const requiredFields = ['name', 'birth_date', 'birth_time', 'gender'];
|
||
const missing = requiredFields.filter((key) => !body[key]);
|
||
if (missing.length) {
|
||
return `缺少字段: ${missing.join(', ')}`;
|
||
}
|
||
|
||
const allowedTypes = ['bazi'];
|
||
const type = body.type || 'bazi';
|
||
if (!allowedTypes.includes(type)) {
|
||
return `暂不支持的 type: ${type},目前仅支持 ${allowedTypes.join(', ')}`;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function requireKnowledgeReloadAuth(req, res, next) {
|
||
if (!KNOWLEDGE_RELOAD_AUTH_ENABLED) {
|
||
return next();
|
||
}
|
||
|
||
if (!KNOWLEDGE_ADMIN_TOKEN) {
|
||
return res.status(503).json({
|
||
success: false,
|
||
error: {
|
||
code: 'KNOWLEDGE_ADMIN_TOKEN_MISSING',
|
||
message:
|
||
'知识库重载鉴权已启用,但 KNOWLEDGE_ADMIN_TOKEN 未配置。请先在 .env 配置后重启服务。',
|
||
},
|
||
});
|
||
}
|
||
|
||
const tokenFromHeader = req.get('x-knowledge-token');
|
||
const tokenFromBearer = parseBearerToken(req.get('authorization'));
|
||
const token = tokenFromHeader || tokenFromBearer;
|
||
|
||
if (!token || token !== KNOWLEDGE_ADMIN_TOKEN) {
|
||
return res.status(401).json({
|
||
success: false,
|
||
error: {
|
||
code: 'KNOWLEDGE_RELOAD_FORBIDDEN',
|
||
message: '知识库重载接口鉴权失败。请提供正确的 X-Knowledge-Token。',
|
||
},
|
||
});
|
||
}
|
||
|
||
return next();
|
||
}
|
||
|
||
function buildSuanmingRequest(body) {
|
||
const type = body.type || 'bazi';
|
||
|
||
if (type === 'bazi') {
|
||
return {
|
||
endpoint: '/analysis/bazi',
|
||
payload: {
|
||
birth_data: {
|
||
name: body.name,
|
||
birth_date: body.birth_date,
|
||
birth_time: body.birth_time,
|
||
gender: body.gender,
|
||
is_lunar: Boolean(body.is_lunar),
|
||
},
|
||
},
|
||
};
|
||
}
|
||
|
||
const error = new Error(`Unsupported type: ${type}`);
|
||
error.statusCode = 400;
|
||
throw error;
|
||
}
|
||
|
||
async function buildDeepseekInterpretation(body, rawSuanming) {
|
||
const baseResult = {
|
||
text: '',
|
||
warning: null,
|
||
warnings: [],
|
||
model: DEEPSEEK_MODEL,
|
||
time_anchor: null,
|
||
knowledge: {
|
||
enabled: KNOWLEDGE_ENABLED,
|
||
source_count: 0,
|
||
sources: [],
|
||
},
|
||
};
|
||
|
||
function addWarning(message) {
|
||
if (!message) return;
|
||
if (!baseResult.warning) {
|
||
baseResult.warning = message;
|
||
}
|
||
baseResult.warnings.push(message);
|
||
}
|
||
|
||
const yearPolicy = buildYearPolicy(body, rawSuanming);
|
||
const knowledgeContext = await buildKnowledgeContext(body, rawSuanming, yearPolicy);
|
||
baseResult.knowledge = {
|
||
enabled: knowledgeContext.enabled,
|
||
source_count: knowledgeContext.references.length,
|
||
sources: knowledgeContext.references,
|
||
};
|
||
|
||
if (knowledgeContext.warning) {
|
||
addWarning(knowledgeContext.warning);
|
||
}
|
||
|
||
baseResult.time_anchor = yearPolicy;
|
||
|
||
if (!DEEPSEEK_API_KEY) {
|
||
addWarning('DeepSeek API Key 未配置,已跳过 AI 解读');
|
||
baseResult.text =
|
||
'⚠️ DeepSeek API Key 未配置,无法生成 AI 解读。\n请在 .env 中设置 DEEPSEEK_API_KEY 后重试。';
|
||
return baseResult;
|
||
}
|
||
|
||
const rawSuanmingText = stringifyWithLimit(rawSuanming, RAW_SUANMING_PROMPT_LIMIT);
|
||
const knowledgePromptSection = knowledgeContext.text
|
||
? [
|
||
'',
|
||
'以下是从 Documents 知识库检索到的参考资料,请优先使用相关内容进行解释,避免逐字抄写:',
|
||
knowledgeContext.text,
|
||
].join('\n')
|
||
: '';
|
||
|
||
const prompt = [
|
||
`用户姓名: ${body.name}`,
|
||
`出生日期: ${body.birth_date} ${body.birth_time}`,
|
||
`性别: ${body.gender}`,
|
||
`是否农历: ${body.is_lunar ? '是' : '否'}`,
|
||
`用户问题: ${body.question || '无特定问题'}`,
|
||
`额外关注点: ${JSON.stringify(body.extra_options || {})}`,
|
||
`系统当前年份: ${yearPolicy.current_year}`,
|
||
`参考预测年份: ${yearPolicy.reference_year}`,
|
||
`年份依据: ${yearPolicy.mode}`,
|
||
'',
|
||
'以下是 suanming 系统返回的原始数据,请结合命理知识给出通俗易懂且积极向上的解读,最后请附上声明:“以上内容由AI生成,仅供参考。”',
|
||
rawSuanmingText,
|
||
knowledgePromptSection,
|
||
].join('\n');
|
||
|
||
const payload = {
|
||
model: DEEPSEEK_MODEL,
|
||
temperature: 0.7,
|
||
max_tokens: 1024,
|
||
messages: [
|
||
{
|
||
role: 'system',
|
||
content:
|
||
'你是一位经验丰富的命理大师,擅长把专业的八字分析转化为温暖、易懂且具有行动建议的文字。回答时保持积极、真诚,并提醒内容仅供参考。若提供了知识库片段,优先采用与用户问题相关的片段,不要编造出处。解读必须以 raw_suanming 为最高优先级;若知识库里出现与 raw_suanming 冲突的年份或体系(如星座年度文案),应忽略冲突内容,不要照搬。年度判断需先对齐“系统当前年份”和“参考预测年份”,除非用户明确指定年份,否则按参考预测年份及其后一年给出近期判断。',
|
||
},
|
||
{
|
||
role: 'user',
|
||
content: prompt,
|
||
},
|
||
],
|
||
};
|
||
|
||
try {
|
||
if (!openaiClient) throw new Error('OpenAI client 未初始化');
|
||
|
||
const completion = await openaiClient.chat.completions.create(payload);
|
||
|
||
baseResult.text =
|
||
completion?.choices?.[0]?.message?.content?.trim() ||
|
||
'DeepSeek 没有返回内容,请稍后再试。';
|
||
|
||
return baseResult;
|
||
} catch (error) {
|
||
console.error('[deepseek] failed', error.message);
|
||
const errMsg =
|
||
error?.error?.message || error?.message || 'DeepSeek 调用失败';
|
||
addWarning(`DeepSeek 调用失败:${errMsg}`);
|
||
baseResult.text =
|
||
'⚠️ DeepSeek 调用失败,无法生成 AI 解读。请稍后重试或检查 API Key 配置。';
|
||
return baseResult;
|
||
}
|
||
}
|
||
|
||
async function buildKnowledgeContext(body, rawSuanming, yearPolicy) {
|
||
if (!KNOWLEDGE_ENABLED) {
|
||
return {
|
||
enabled: false,
|
||
text: '',
|
||
references: [],
|
||
warning: null,
|
||
};
|
||
}
|
||
|
||
try {
|
||
const queryText = buildKnowledgeQuery(body, rawSuanming);
|
||
const context = await knowledgeBase.buildContext(
|
||
queryText,
|
||
getKnowledgeSearchOptions(body, yearPolicy)
|
||
);
|
||
|
||
return {
|
||
enabled: true,
|
||
text: context.text,
|
||
references: context.references,
|
||
warning: context.references.length
|
||
? null
|
||
: '知识库未检索到相关内容,已使用默认提示词继续生成。',
|
||
};
|
||
} catch (error) {
|
||
console.error('[knowledge] build context failed', error.message);
|
||
return {
|
||
enabled: true,
|
||
text: '',
|
||
references: [],
|
||
warning: `知识库加载失败:${error.message}`,
|
||
};
|
||
}
|
||
}
|
||
|
||
function getKnowledgeSearchOptions(body = {}, yearPolicy = null) {
|
||
const type = body.type || 'bazi';
|
||
const questionText = buildQuestionText(body);
|
||
const zodiacIntent = isZodiacQuestion(questionText);
|
||
|
||
if (type === 'bazi' && !zodiacIntent) {
|
||
return {
|
||
excludeFiles: [ZODIAC_GUIDE_FILE],
|
||
preferredYear: yearPolicy?.reference_year,
|
||
};
|
||
}
|
||
|
||
if (type === 'bazi' && zodiacIntent) {
|
||
const yearMode = yearPolicy?.mode || 'current_year';
|
||
const enforceYear = yearMode !== 'user_specified';
|
||
|
||
return {
|
||
topK: Math.max(4, KNOWLEDGE_TOP_K),
|
||
maxContextChars: Math.max(5000, KNOWLEDGE_MAX_CONTEXT_CHARS),
|
||
preferredYear: yearPolicy?.reference_year,
|
||
dropMismatchedYears: enforceYear,
|
||
};
|
||
}
|
||
|
||
return {};
|
||
}
|
||
|
||
function buildQuestionText(body = {}) {
|
||
return [body.question || '', JSON.stringify(body.extra_options || {})].join(' ');
|
||
}
|
||
|
||
function isZodiacQuestion(text = '') {
|
||
const value = String(text).toLowerCase();
|
||
if (!value) return false;
|
||
|
||
const keywords = [
|
||
'星座',
|
||
'十二星座',
|
||
'太阳星座',
|
||
'月亮星座',
|
||
'上升星座',
|
||
'aries',
|
||
'taurus',
|
||
'gemini',
|
||
'cancer',
|
||
'leo',
|
||
'virgo',
|
||
'libra',
|
||
'scorpio',
|
||
'sagittarius',
|
||
'capricorn',
|
||
'aquarius',
|
||
'pisces',
|
||
'白羊',
|
||
'金牛',
|
||
'双子',
|
||
'巨蟹',
|
||
'狮子',
|
||
'处女',
|
||
'天秤',
|
||
'天蝎',
|
||
'射手',
|
||
'摩羯',
|
||
'水瓶',
|
||
'双鱼',
|
||
];
|
||
|
||
return keywords.some((keyword) => value.includes(keyword));
|
||
}
|
||
|
||
function buildYearPolicy(body = {}, rawSuanming = {}) {
|
||
const currentYear = new Date().getFullYear();
|
||
const explicitYear = extractExplicitYear(body.question);
|
||
const analysisYear = extractAnalysisYear(rawSuanming);
|
||
|
||
if (explicitYear) {
|
||
return {
|
||
current_year: currentYear,
|
||
reference_year: explicitYear,
|
||
mode: 'user_specified',
|
||
};
|
||
}
|
||
|
||
if (analysisYear) {
|
||
return {
|
||
current_year: currentYear,
|
||
reference_year: analysisYear,
|
||
mode: 'analysis_date',
|
||
};
|
||
}
|
||
|
||
return {
|
||
current_year: currentYear,
|
||
reference_year: currentYear,
|
||
mode: 'current_year',
|
||
};
|
||
}
|
||
|
||
function extractExplicitYear(text = '') {
|
||
const match = String(text).match(/(20\d{2})\s*年?/);
|
||
if (!match) return null;
|
||
|
||
const year = Number(match[1]);
|
||
if (!Number.isInteger(year) || year < 2000 || year > 2100) {
|
||
return null;
|
||
}
|
||
return year;
|
||
}
|
||
|
||
function extractAnalysisYear(rawSuanming = {}) {
|
||
const analysisDate =
|
||
rawSuanming?.data?.analysis?.analysis_date || rawSuanming?.analysis?.analysis_date;
|
||
|
||
if (analysisDate) {
|
||
const parsed = new Date(analysisDate);
|
||
if (!Number.isNaN(parsed.getTime())) {
|
||
return parsed.getFullYear();
|
||
}
|
||
}
|
||
|
||
const yearlyAnalysis =
|
||
rawSuanming?.data?.analysis?.dayun_analysis?.detailed_yearly_analysis ||
|
||
rawSuanming?.analysis?.dayun_analysis?.detailed_yearly_analysis ||
|
||
[];
|
||
|
||
if (Array.isArray(yearlyAnalysis) && yearlyAnalysis.length) {
|
||
const firstYear = Number(yearlyAnalysis[0]?.year);
|
||
if (Number.isInteger(firstYear) && firstYear > 1900) {
|
||
return firstYear;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function buildKnowledgeQuery(body, rawSuanming) {
|
||
const rawPreview = stringifyWithLimit(rawSuanming, 1600);
|
||
return [
|
||
body.type || 'bazi',
|
||
body.question || '',
|
||
JSON.stringify(body.extra_options || {}),
|
||
body.gender || '',
|
||
rawPreview,
|
||
].join(' ');
|
||
}
|
||
|
||
function stringifyWithLimit(input, maxChars) {
|
||
const serialized = JSON.stringify(input, null, 2);
|
||
if (!serialized) return '';
|
||
|
||
if (!Number.isFinite(maxChars) || maxChars <= 0 || serialized.length <= maxChars) {
|
||
return serialized;
|
||
}
|
||
|
||
return `${serialized.slice(0, maxChars)}\n...(原始数据过长,已截断)`;
|
||
}
|
||
|
||
function mergeWarnings(result = {}) {
|
||
if (Array.isArray(result.warnings) && result.warnings.length) {
|
||
return [...new Set(result.warnings.filter(Boolean))];
|
||
}
|
||
|
||
if (result.warning) {
|
||
return [result.warning];
|
||
}
|
||
|
||
return [];
|
||
}
|
||
|
||
function buildCorsOptions() {
|
||
const allowedOrigins = CORS_ALLOWED_ORIGINS;
|
||
|
||
return {
|
||
origin(origin, callback) {
|
||
if (!origin) {
|
||
return callback(null, true);
|
||
}
|
||
|
||
if (!allowedOrigins.length) {
|
||
return callback(null, false);
|
||
}
|
||
|
||
const normalizedOrigin = normalizeOrigin(origin);
|
||
const allowed = allowedOrigins.includes(normalizedOrigin);
|
||
return callback(null, allowed);
|
||
},
|
||
methods: ['GET', 'POST', 'OPTIONS'],
|
||
allowedHeaders: ['Content-Type', 'Authorization', 'X-Knowledge-Token'],
|
||
};
|
||
}
|
||
|
||
function parseBearerToken(value = '') {
|
||
const match = String(value).match(/^bearer\s+(.+)$/i);
|
||
return match ? match[1].trim() : '';
|
||
}
|
||
|
||
function parseCsvEnv(value = '') {
|
||
return String(value)
|
||
.split(',')
|
||
.map((item) => normalizeOrigin(item))
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function normalizeOrigin(value = '') {
|
||
return String(value).trim().replace(/\/+$/, '');
|
||
}
|
||
|
||
function parseBoolean(value, defaultValue) {
|
||
if (value === undefined || value === null || value === '') {
|
||
return defaultValue;
|
||
}
|
||
|
||
const normalized = String(value).trim().toLowerCase();
|
||
if (['1', 'true', 'yes', 'on'].includes(normalized)) {
|
||
return true;
|
||
}
|
||
|
||
if (['0', 'false', 'no', 'off'].includes(normalized)) {
|
||
return false;
|
||
}
|
||
|
||
return defaultValue;
|
||
}
|
||
|
||
function deriveErrorCode(error) {
|
||
if (error.code === 'ECONNABORTED') return 'UPSTREAM_TIMEOUT';
|
||
if (error.response?.status === 404) return 'ENDPOINT_NOT_FOUND';
|
||
if (error.response?.status === 401) return 'UNAUTHORIZED';
|
||
return 'GATEWAY_ERROR';
|
||
}
|