Init: 导入源码
This commit is contained in:
307
server.js
Normal file
307
server.js
Normal file
@@ -0,0 +1,307 @@
|
||||
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';
|
||||
}
|
||||
Reference in New Issue
Block a user