Init: 导入源码

This commit is contained in:
Kevin Wong
2026-01-08 17:40:59 +08:00
parent 01c96a26d0
commit c2e08794c2
8 changed files with 2072 additions and 1 deletions

View File

@@ -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

File diff suppressed because it is too large Load Diff

25
package.json Normal file
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

103
public/index.html Normal file
View 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
View 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
View 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';
}