Files
Suanming-Web/server.js
2026-01-08 17:40:59 +08:00

308 lines
8.6 KiB
JavaScript
Raw 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');
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 || '';
// 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,
});
app.use(cors());
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.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,
raw_suanming: rawSuanming,
meta: {
type: req.body.type || 'bazi',
model: deepseekResult.model,
elapsed_ms: Date.now() - req.requestTime,
warnings: deepseekResult.warning ? [deepseekResult.warning] : [],
},
},
});
} 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}`);
// 启动时预热 Token
await tokenManager.getToken();
});
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 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,
model: DEEPSEEK_MODEL,
};
if (!DEEPSEEK_API_KEY) {
baseResult.warning = 'DeepSeek API Key 未配置,已跳过 AI 解读';
baseResult.text =
'⚠️ DeepSeek API Key 未配置,无法生成 AI 解读。\n请在 .env 中设置 DEEPSEEK_API_KEY 后重试。';
return baseResult;
}
const prompt = [
`用户姓名: ${body.name}`,
`出生日期: ${body.birth_date} ${body.birth_time}`,
`性别: ${body.gender}`,
`是否农历: ${body.is_lunar ? '是' : '否'}`,
`用户问题: ${body.question || '无特定问题'}`,
`额外关注点: ${JSON.stringify(body.extra_options || {})}`,
'',
'以下是 suanming 系统返回的原始数据请结合命理知识给出通俗易懂且积极向上的解读最后请附上声明“以上内容由AI生成仅供参考。”',
JSON.stringify(rawSuanming, null, 2),
].join('\n');
const payload = {
model: DEEPSEEK_MODEL,
temperature: 0.7,
max_tokens: 1024,
messages: [
{
role: 'system',
content:
'你是一位经验丰富的命理大师,擅长把专业的八字分析转化为温暖、易懂且具有行动建议的文字。回答时保持积极、真诚,并提醒内容仅供参考。',
},
{
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 调用失败';
baseResult.warning = `DeepSeek 调用失败:${errMsg}`;
baseResult.text =
'⚠️ DeepSeek 调用失败,无法生成 AI 解读。请稍后重试或检查 API Key 配置。';
return baseResult;
}
}
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';
}