Files
Suanming-Web/server.js
Kevin Wong 1db55865c0 更新
2026-03-11 14:08:24 +08:00

721 lines
20 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
}