Init: 导入源码
This commit is contained in:
91
README.md
91
README.md
@@ -1,2 +1,91 @@
|
|||||||
# Suanming-Web
|
# Suanming Web Gateway
|
||||||
|
|
||||||
|
统一的算命网页入口:后端调用 suanming API 获取原始命理数据,再调用 DeepSeek LLM 输出自然语言解读,前端只需请求 `/api/fortune`。
|
||||||
|
|
||||||
|
## 功能概览
|
||||||
|
|
||||||
|
- `POST /api/fortune`:校验参数 → 调用 `suanming` → 调用 `DeepSeek` → 返回 `ai_text + raw_suanming`。
|
||||||
|
- Express 静态站点:单页表单,包含示例填充、请求状态、AI 文本展示、原始 JSON 展开、免责声明等。
|
||||||
|
- `.env` 控制 suanming、DeepSeek、端口与超时,示例参见 [.env.example](./.env.example)。
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
web/
|
||||||
|
├── public/ # 静态前端资源(index.html / styles.css / app.js)
|
||||||
|
├── server.js # Express 网关及 API 实现
|
||||||
|
├── package.json # 项目依赖
|
||||||
|
└── .env.example # 环境变量模板
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
1. **安装依赖**
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **配置环境变量**
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# 修改 .env,填入真实的 suanming 地址、访问令牌以及 DeepSeek Key
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **启动服务**
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# 浏览器访问 http://localhost:4173
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 说明
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/fortune
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
请求示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "bazi",
|
||||||
|
"name": "张三",
|
||||||
|
"birth_date": "1990-01-01",
|
||||||
|
"birth_time": "12:00",
|
||||||
|
"gender": "male",
|
||||||
|
"is_lunar": false,
|
||||||
|
"question": "想了解近期事业与财运",
|
||||||
|
"extra_options": { "focus": "career" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
返回示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"ai_text": "DeepSeek 的自然语言解读",
|
||||||
|
"raw_suanming": { "...": "原始 suanming JSON" },
|
||||||
|
"meta": {
|
||||||
|
"type": "bazi",
|
||||||
|
"model": "deepseek-chat",
|
||||||
|
"elapsed_ms": 2143,
|
||||||
|
"warnings": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 自定义
|
||||||
|
|
||||||
|
- DeepSeek 调用通过 `openai` SDK 完成,`.env` 中的 `DEEPSEEK_API_URL` 只需设置到根域名(默认 `https://api.deepseek.com`)。
|
||||||
|
- 若你的 suanming API 启用了认证,把登录后拿到的 Bearer token 填入 `SUANMING_API_TOKEN`,网关会自动加到 `Authorization` 头。
|
||||||
|
- 若暂未配置 DeepSeek Key,会返回提示并保留 `raw_suanming`,方便调试。
|
||||||
|
- 如需拓展 `name`、`fortune` 等类型,可在 `server.js` 的 `buildSuanmingRequest` 中补充对应 endpoint/payload。
|
||||||
|
- 前端使用原生 JS,便于在任何环境下部署,若后续需要组件化框架,可直接复用当前 API。
|
||||||
|
|
||||||
|
## 免责声明
|
||||||
|
|
||||||
|
页面底部及 DeepSeek Prompt 均包含“仅供娱乐参考”的提示,请在生产部署时继续保留。
|
||||||
|
|||||||
1168
package-lock.json
generated
Normal file
1168
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
package.json
Normal file
25
package.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "suanming-web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Gateway backend and lightweight web UI for Suanming fortune analysis.",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "node server.js",
|
||||||
|
"start": "node server.js"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"suanming",
|
||||||
|
"fortune",
|
||||||
|
"deepseek",
|
||||||
|
"express"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.7.4",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"openai": "^4.69.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
102
public/app.js
Normal file
102
public/app.js
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
const form = document.getElementById('fortune-form');
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
const aiTextEl = document.getElementById('ai-text');
|
||||||
|
const warningsEl = document.getElementById('warnings');
|
||||||
|
const submitBtn = document.getElementById('submit-btn');
|
||||||
|
const fillDemoBtn = document.getElementById('fill-demo');
|
||||||
|
|
||||||
|
const STATUS_CLASS = ['idle', 'info', 'success', 'error'];
|
||||||
|
|
||||||
|
function setStatus(text, variant = 'idle') {
|
||||||
|
statusEl.textContent = text;
|
||||||
|
STATUS_CLASS.forEach((cls) => statusEl.classList.remove(cls));
|
||||||
|
statusEl.classList.add(variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPayload() {
|
||||||
|
const formData = new FormData(form);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: formData.get('type') || 'bazi',
|
||||||
|
name: (formData.get('name') || '').trim(),
|
||||||
|
birth_date: formData.get('birth_date'),
|
||||||
|
birth_time: formData.get('birth_time'),
|
||||||
|
gender: formData.get('gender'),
|
||||||
|
is_lunar: formData.get('is_lunar') === 'on',
|
||||||
|
question: (formData.get('question') || '').trim(),
|
||||||
|
extra_options: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitForm(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const payload = buildPayload();
|
||||||
|
|
||||||
|
setStatus('⏳ 正在分析 ...', 'info');
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/fortune', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok || !data.success) {
|
||||||
|
throw new Error(data.error?.message || '后端服务异常');
|
||||||
|
}
|
||||||
|
|
||||||
|
renderResult(data.data);
|
||||||
|
setStatus('✅ 分析完成', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
setStatus(`❌ ${error.message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderResult(result) {
|
||||||
|
aiTextEl.textContent = (result.ai_text || '').trim();
|
||||||
|
|
||||||
|
const warnings = result.meta?.warnings?.filter(Boolean) || [];
|
||||||
|
if (warnings.length) {
|
||||||
|
warningsEl.classList.remove('hidden');
|
||||||
|
warningsEl.innerHTML = warnings.map((w) => `<p>⚠️ ${w}</p>`).join('');
|
||||||
|
} else {
|
||||||
|
warningsEl.classList.add('hidden');
|
||||||
|
warningsEl.textContent = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillDemo() {
|
||||||
|
const now = new Date();
|
||||||
|
const date = now.toISOString().slice(0, 10);
|
||||||
|
const time = '08:30';
|
||||||
|
|
||||||
|
form.type.value = 'bazi';
|
||||||
|
form.name.value = '李清扬';
|
||||||
|
form.gender.value = 'female';
|
||||||
|
form.birth_date.value = date;
|
||||||
|
form.birth_time.value = time;
|
||||||
|
form.is_lunar.checked = false;
|
||||||
|
form.question.value = '想了解未来三个月的事业机会和财务规划建议。';
|
||||||
|
setStatus('示例数据已填充,可直接提交。', 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
const defaultDate = '1990-01-01';
|
||||||
|
const defaultTime = '12:00';
|
||||||
|
if (!form.birth_date.value) {
|
||||||
|
form.birth_date.value = defaultDate;
|
||||||
|
}
|
||||||
|
if (!form.birth_time.value) {
|
||||||
|
form.birth_time.value = defaultTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener('submit', submitForm);
|
||||||
|
fillDemoBtn.addEventListener('click', fillDemo);
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
BIN
public/favicon.png
Normal file
BIN
public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
103
public/index.html
Normal file
103
public/index.html
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#0f1115" />
|
||||||
|
<title>天问</title>
|
||||||
|
<link rel="icon" type="image/png" href="favicon.png" />
|
||||||
|
<link rel="stylesheet" href="styles.css" />
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<header class="hero">
|
||||||
|
<h1>天问</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="layout">
|
||||||
|
<section class="panel form-panel">
|
||||||
|
<div class="panel__header">
|
||||||
|
<h2>填写用户信息</h2>
|
||||||
|
<button class="ghost" type="button" id="fill-demo">使用示例数据</button>
|
||||||
|
</div>
|
||||||
|
<form id="fortune-form">
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="type">分析类型</label>
|
||||||
|
<select id="type" name="type">
|
||||||
|
<option value="bazi" selected>八字分析</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="name">姓名 *</label>
|
||||||
|
<input id="name" name="name" placeholder="张三" required />
|
||||||
|
</div>
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="gender">性别 *</label>
|
||||||
|
<select id="gender" name="gender" required>
|
||||||
|
<option value="male">男</option>
|
||||||
|
<option value="female">女</option>
|
||||||
|
<option value="unknown">保密</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="birth_date">出生日期 *</label>
|
||||||
|
<input type="date" id="birth_date" name="birth_date" required />
|
||||||
|
</div>
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="birth_time">出生时间 *</label>
|
||||||
|
<input type="time" id="birth_time" name="birth_time" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group checkbox">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="is_lunar" name="is_lunar" />
|
||||||
|
使用农历生日
|
||||||
|
</label>
|
||||||
|
<small>若勾选,请确保日期已换算为农历。</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="question">想了解的重点</label>
|
||||||
|
<textarea id="question" name="question" rows="3" placeholder="近期事业与财运如何?"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button id="submit-btn" type="submit">提交并生成解读</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel result-panel">
|
||||||
|
<div class="panel__header">
|
||||||
|
<h2>AI 解读结果</h2>
|
||||||
|
<div id="status" class="status idle">等待提交</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<article id="ai-text" class="ai-text">
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<div id="warnings" class="warnings hidden"></div>
|
||||||
|
|
||||||
|
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
<p>⚠️ 本服务仅供娱乐参考,任何结论不构成专业建议。</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="app.js" type="module"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
277
public/styles.css
Normal file
277
public/styles.css
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
--bg: #0f1115;
|
||||||
|
--panel: #181b22;
|
||||||
|
--panel-light: #1f242f;
|
||||||
|
--text: #f8fbff;
|
||||||
|
--muted: #9ca3af;
|
||||||
|
--accent: #7c5dfa;
|
||||||
|
--accent-soft: rgba(124, 93, 250, 0.18);
|
||||||
|
--border: rgba(255, 255, 255, 0.08);
|
||||||
|
--success: #22c55e;
|
||||||
|
--error: #f87171;
|
||||||
|
font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
background-color: #0f1115;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 48px 20px 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(2rem, 4vw, 3rem);
|
||||||
|
background: linear-gradient(120deg, var(--accent), #9f6bff);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: linear-gradient(145deg, var(--panel), var(--panel-light));
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel__header h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group label {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: border 0.2s, background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
select:focus,
|
||||||
|
textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group small {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox label {
|
||||||
|
font-weight: 400;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions button {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button[type='submit'] {
|
||||||
|
background: linear-gradient(120deg, var(--accent), #9f6bff);
|
||||||
|
border: none;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
color: white;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s, transform 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
button[type='submit']:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
button[type='submit']:active {
|
||||||
|
transform: scale(0.99);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.idle {
|
||||||
|
border-color: var(--border);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.info {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.success {
|
||||||
|
border-color: rgba(34, 197, 94, 0.5);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.error {
|
||||||
|
border-color: rgba(248, 113, 113, 0.4);
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-text {
|
||||||
|
min-height: 200px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-text .placeholder {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warnings {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px dashed rgba(248, 196, 113, 0.5);
|
||||||
|
color: #fbbf24;
|
||||||
|
background: rgba(251, 191, 36, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warnings.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
margin-top: 40px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer code {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.form-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
button[type='submit'] {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
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