guanwei 1.3.2 → 1.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -12
- package/dist/assets/index-Bzt3QHIz.js +102 -0
- package/dist/index.html +1 -1
- package/package.json +17 -8
- package/packages/guanwei-api/package.json +26 -0
- package/packages/guanwei-api/src/mcp-http.ts +89 -0
- package/packages/guanwei-api/src/mcp.ts +108 -0
- package/packages/guanwei-api/src/routes/arts.ts +23 -0
- package/packages/guanwei-api/src/routes/chart.ts +24 -0
- package/packages/guanwei-api/src/server.ts +96 -0
- package/server/package.json +24 -0
- package/server/src/index.ts +72 -16
- package/server/src/routes/ai.ts +10 -22
- package/server/src/routes/divine.ts +34 -33
- package/server/src/routes/hour.ts +4 -0
- package/server/src/routes/tarot.ts +30 -7
- package/server/src/routes/users.ts +118 -123
- package/server/src/services/auth.ts +50 -0
- package/server/src/services/dataDir.ts +95 -0
- package/server/src/services/db.ts +131 -0
- package/server/src/services/divineStore.ts +9 -55
- package/server/src/services/duanyu.ts +3 -3
- package/server/src/services/llmProvider.ts +4 -3
- package/server/src/services/usersStore.ts +221 -0
- package/shared/core/data/gua64.ts +5 -5
- package/shared/core/data/qimen.ts +11 -9
- package/shared/core/data/ziwei.ts +16 -15
- package/shared/core/engine/bazi.ts +64 -13
- package/shared/core/engine/calendar.ts +18 -5
- package/shared/core/engine/chart.ts +3 -1
- package/shared/core/engine/liuren.ts +32 -13
- package/shared/core/engine/liuyao.ts +6 -1
- package/shared/core/engine/qimen.ts +102 -60
- package/shared/core/engine/trueSolarTime.ts +9 -6
- package/shared/core/engine/xiaoliuren.ts +6 -2
- package/shared/core/engine/ziwei.ts +4 -3
- package/shared/core/types.ts +7 -7
- package/src/components/arts/XiaoliurenArt.tsx +1 -1
- package/src/pages/AuthPage.tsx +3 -0
- package/src/pages/DemoPage.tsx +1 -1
- package/src/services/api.ts +38 -6
- package/src/utils/userStore.ts +7 -3
- package/vite.config.ts +7 -3
- package/dist/assets/index-BZTUsUN1.js +0 -102
- package/dist/assets/index-BZTUsUN1.js.map +0 -1
- package/scripts/release.sh +0 -81
- package/server/src/data/db.json +0 -7226
- package/server/src/data/guanwei.db +0 -0
package/dist/index.html
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
7
|
<title>观微 · 玄学问道 —— 九术参详,观微知著</title>
|
|
8
8
|
<meta name="description" content="以纸墨留白为骨,融四柱八字、紫微斗数、奇门遁甲、梅花易数、六爻、大六壬、小六壬、星盘、塔罗九术于一体。观微知著,以术问道。" />
|
|
9
|
-
<script type="module" crossorigin src="/assets/index-
|
|
9
|
+
<script type="module" crossorigin src="/assets/index-Bzt3QHIz.js"></script>
|
|
10
10
|
<link rel="stylesheet" crossorigin href="/assets/index-hLV3S-Cj.css">
|
|
11
11
|
</head>
|
|
12
12
|
<body>
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "guanwei",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "1.3.
|
|
4
|
+
"version": "1.3.5",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"dev": "vite",
|
|
@@ -14,14 +14,14 @@
|
|
|
14
14
|
"prepublishOnly": "npm run build"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"
|
|
18
|
-
"cors": "^2.8.5",
|
|
19
|
-
"tsx": "^4.7.0",
|
|
17
|
+
"@vitejs/plugin-react": "^4.4.1",
|
|
20
18
|
"astronomy-engine": "^2.1.19",
|
|
21
|
-
"
|
|
19
|
+
"cors": "^2.8.5",
|
|
20
|
+
"express": "^4.18.2",
|
|
22
21
|
"iztro": "2.6.0",
|
|
22
|
+
"lunar-typescript": "^1.8.6",
|
|
23
|
+
"tsx": "^4.7.0",
|
|
23
24
|
"vite": "^6.3.5",
|
|
24
|
-
"@vitejs/plugin-react": "^4.4.1",
|
|
25
25
|
"vite-tsconfig-paths": "^5.1.4"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"lucide-react": "^0.511.0",
|
|
44
44
|
"playwright": "^1.62.1",
|
|
45
45
|
"postcss": "^8.5.3",
|
|
46
|
+
"qimen-dunjia": "^3.1.0",
|
|
46
47
|
"react": "^18.3.1",
|
|
47
48
|
"react-dom": "^18.3.1",
|
|
48
49
|
"react-router-dom": "^7.3.0",
|
|
@@ -61,9 +62,17 @@
|
|
|
61
62
|
"files": [
|
|
62
63
|
"dist",
|
|
63
64
|
"src",
|
|
64
|
-
"server/src",
|
|
65
|
+
"server/src/index.ts",
|
|
66
|
+
"server/src/routes",
|
|
67
|
+
"server/src/services",
|
|
68
|
+
"server/src/types",
|
|
69
|
+
"server/package.json",
|
|
65
70
|
"shared",
|
|
66
|
-
"
|
|
71
|
+
"packages",
|
|
72
|
+
"scripts/guanwei",
|
|
73
|
+
"scripts/setup.sh",
|
|
74
|
+
"scripts/setup.bat",
|
|
75
|
+
"scripts/install.sh",
|
|
67
76
|
"public",
|
|
68
77
|
"index.html",
|
|
69
78
|
"vite.config.ts",
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "guanwei-api",
|
|
3
|
+
"version": "1.3.5",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "观微排盘 API(/v1 · 开放分发,复用 shared/core 单一算法副本)",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"start": "tsx src/server.ts",
|
|
9
|
+
"dev": "tsx watch src/server.ts",
|
|
10
|
+
"mcp": "tsx src/mcp.ts",
|
|
11
|
+
"api": "tsx src/server.ts"
|
|
12
|
+
},
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=22.13.0"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"astronomy-engine": "^2.1.19",
|
|
18
|
+
"express": "^4.18.2",
|
|
19
|
+
"iztro": "2.6.0",
|
|
20
|
+
"lunar-typescript": "^1.8.6",
|
|
21
|
+
"tsx": "^4.7.0"
|
|
22
|
+
},
|
|
23
|
+
"bin": {
|
|
24
|
+
"guanwei-mcp": "src/mcp.ts"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// MCP HTTP 传输(国内客户端:WorkBuddy / ima 等,支持 type:"sse"|"http")
|
|
2
|
+
// 兼容两种模式:
|
|
3
|
+
// - Streamable HTTP(新版):POST /mcp 收 JSON-RPC(可返回 JSON 或 SSE 流);GET /mcp 建 SSE 推送
|
|
4
|
+
// - 旧版 SSE:GET /mcp/sse 建连 + POST /mcp/messages 发请求
|
|
5
|
+
// 协议核心复用 mcp.ts 的 handleMcpRequest(工具实现单一副本)
|
|
6
|
+
import { Router, type Request, type Response } from 'express';
|
|
7
|
+
import { handleMcpRequest } from './mcp.js';
|
|
8
|
+
|
|
9
|
+
export function mcpRouter(): Router {
|
|
10
|
+
const router = Router();
|
|
11
|
+
const clients = new Set<Response>();
|
|
12
|
+
// 容量与闲置上限(2026-09 修 P0-2):SSE 长连接无凭证,原实现无上限可被挂死连接占满 fd/内存
|
|
13
|
+
const MAX_SSE_CLIENTS = Number(process.env.GUANWEI_API_MAX_SSE || 50);
|
|
14
|
+
const SSE_IDLE_MS = Number(process.env.GUANWEI_API_SSE_IDLE_MS || 10 * 60 * 1000);
|
|
15
|
+
|
|
16
|
+
/** 建连(含容量检查 + 闲置超时回收);超限返回 false */
|
|
17
|
+
function sseOpen(res: Response, onReady?: () => void): boolean {
|
|
18
|
+
if (clients.size >= MAX_SSE_CLIENTS) {
|
|
19
|
+
res.status(503).json({ error: 'SSE_LIMIT', message: '连接数已达上限,请稍后再试' });
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
res.writeHead(200, {
|
|
23
|
+
'Content-Type': 'text/event-stream',
|
|
24
|
+
'Cache-Control': 'no-cache',
|
|
25
|
+
Connection: 'keep-alive',
|
|
26
|
+
'X-Accel-Buffering': 'no',
|
|
27
|
+
});
|
|
28
|
+
onReady?.();
|
|
29
|
+
res.write(': connected\n\n');
|
|
30
|
+
clients.add(res);
|
|
31
|
+
// 心跳 + 闲置超时:长期无活动主动断开,回收连接
|
|
32
|
+
const hb = setInterval(() => { try { res.write(': ping\n\n'); } catch { /* ignore */ } }, 25000);
|
|
33
|
+
const idle = setTimeout(() => { try { res.end(); } catch { /* ignore */ } }, SSE_IDLE_MS);
|
|
34
|
+
res.on('close', () => { clients.delete(res); clearInterval(hb); clearTimeout(idle); });
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function sseInit(res: Response): void {
|
|
39
|
+
sseOpen(res);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 把响应推给所有 SSE 客户端(服务端主动消息:log/资源更新等)
|
|
43
|
+
function broadcast(msg: object): void {
|
|
44
|
+
const payload = 'data: ' + JSON.stringify(msg) + '\n\n';
|
|
45
|
+
for (const c of clients) { try { c.write(payload); } catch { /* ignore */ } }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---- 旧版 SSE 传输(type:"sse")----
|
|
49
|
+
router.get('/sse', (req, res) => {
|
|
50
|
+
// 按 MCP 旧规范:先发 endpoint 事件告知消息 POST 地址
|
|
51
|
+
const base = req.protocol + '://' + req.get('host');
|
|
52
|
+
sseOpen(res, () => {
|
|
53
|
+
res.write('event: endpoint\ndata: ' + base + '/mcp/messages\n\n');
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
router.post('/messages', (req: Request, res: Response) => {
|
|
58
|
+
const body = req.body || {};
|
|
59
|
+
const resp = handleMcpRequest(body);
|
|
60
|
+
if (resp) res.json(resp);
|
|
61
|
+
else res.status(202).end(); // 通知类:202 Accepted
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// ---- Streamable HTTP(type:"http",新版标准)----
|
|
65
|
+
router.post('/', (req: Request, res: Response) => {
|
|
66
|
+
const accept = String(req.headers.accept || 'application/json, text/event-stream');
|
|
67
|
+
const body = req.body || {};
|
|
68
|
+
const resp = handleMcpRequest(body);
|
|
69
|
+
if (!resp) { res.status(202).end(); return; } // 通知
|
|
70
|
+
if (accept.includes('text/event-stream')) {
|
|
71
|
+
res.writeHead(200, {
|
|
72
|
+
'Content-Type': 'text/event-stream',
|
|
73
|
+
'Cache-Control': 'no-cache',
|
|
74
|
+
Connection: 'keep-alive',
|
|
75
|
+
'X-Accel-Buffering': 'no',
|
|
76
|
+
});
|
|
77
|
+
res.write('data: ' + JSON.stringify(resp) + '\n\n');
|
|
78
|
+
res.end();
|
|
79
|
+
} else {
|
|
80
|
+
res.json(resp);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
router.get('/', (req, res) => {
|
|
85
|
+
sseInit(res);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return router;
|
|
89
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// 观微 MCP Server:Claude Code / Cursor 等 agent 可直接调用九术排盘
|
|
2
|
+
// 零依赖实现 MCP stdio(JSON-RPC 2.0 over stdin/stdout):
|
|
3
|
+
// initialize → tools/list → tools/call(guanwei_chart)
|
|
4
|
+
// 复用 shared/core/engine/chart.ts 单一算法副本
|
|
5
|
+
import { chartCalc, CHART_ARTS, CHART_INPUT_SCHEMA } from '../../../shared/core/engine/chart.js';
|
|
6
|
+
|
|
7
|
+
const ART_NAMES: Record<string, string> = {
|
|
8
|
+
bazi: '四柱八字', ziwei: '紫微斗数', astrology: '古典星盘',
|
|
9
|
+
qimen: '奇门遁甲', meihua: '梅花易数', liuyao: '六爻',
|
|
10
|
+
liuren: '大六壬', xiaoliuren: '小六壬', tarot: '塔罗',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// ─── MCP 协议 ───
|
|
14
|
+
export interface McpRequest { jsonrpc: '2.0'; id?: number | string | null; method: string; params?: any }
|
|
15
|
+
export interface McpResponse { jsonrpc: '2.0'; id: number | string | null; result?: any; error?: { code: number; message: string } }
|
|
16
|
+
|
|
17
|
+
function send(msg: McpResponse): void {
|
|
18
|
+
process.stdout.write(JSON.stringify(msg) + '\n');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function toolResult(content: string): any {
|
|
22
|
+
return { content: [{ type: 'text', text: content }] };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// tools/list:声明 guanwei_chart
|
|
26
|
+
function toolsList(): any {
|
|
27
|
+
const arts = CHART_ARTS.map(id => ({ id, name: ART_NAMES[id] || id }));
|
|
28
|
+
return {
|
|
29
|
+
tools: [{
|
|
30
|
+
name: 'guanwei_chart',
|
|
31
|
+
description: '九术排盘(四柱八字/紫微斗数/古典星盘/奇门遁甲/梅花易数/六爻/大六壬/小六壬/塔罗)。输入 art + inputs 返回完整盘面结果。可用的 art 与 inputs 字段:' + JSON.stringify(arts),
|
|
32
|
+
inputSchema: {
|
|
33
|
+
type: 'object',
|
|
34
|
+
properties: {
|
|
35
|
+
art: { type: 'string', description: '术名:' + CHART_ARTS.join('/') },
|
|
36
|
+
inputs: {
|
|
37
|
+
type: 'object',
|
|
38
|
+
description: '该术输入参数(见各术 schema)',
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
required: ['art'],
|
|
42
|
+
},
|
|
43
|
+
}],
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// tools/call:执行排盘
|
|
48
|
+
function toolsCall(params: any): { result?: any; error?: { code: number; message: string } } {
|
|
49
|
+
const name = params?.name;
|
|
50
|
+
const args = params?.arguments || {};
|
|
51
|
+
if (name !== 'guanwei_chart') {
|
|
52
|
+
return { error: { code: -32602, message: '未知工具: ' + name } };
|
|
53
|
+
}
|
|
54
|
+
const art = String(args.art || '');
|
|
55
|
+
if (!CHART_ARTS.includes(art as any)) {
|
|
56
|
+
return { result: toolResult('❌ 术无此名: ' + art + '。可用:' + CHART_ARTS.join(' / ')) };
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const r = chartCalc(art, args.inputs || {});
|
|
60
|
+
return { result: toolResult('【' + (ART_NAMES[art] || art) + ' 排盘结果】\n' + JSON.stringify(r, null, 1)) };
|
|
61
|
+
} catch (e: any) {
|
|
62
|
+
return { result: toolResult('❌ 排盘失败:' + (e?.message || e)) };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ─── 协议核心:处理单个 JSON-RPC 请求 → 返回响应对象(stdio 与 HTTP/SSE 共用)───
|
|
67
|
+
export function handleMcpRequest(req: McpRequest): McpResponse | null {
|
|
68
|
+
if (req.method === 'initialize') {
|
|
69
|
+
return { jsonrpc: '2.0', id: req.id ?? null, result: { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'guanwei-mcp', version: '1.3.5' } } };
|
|
70
|
+
}
|
|
71
|
+
if (req.method === 'notifications/initialized' || req.method === 'notifications/cancelled') {
|
|
72
|
+
return null; // 通知无响应
|
|
73
|
+
}
|
|
74
|
+
if (req.method === 'tools/list') {
|
|
75
|
+
return { jsonrpc: '2.0', id: req.id ?? null, result: toolsList() };
|
|
76
|
+
}
|
|
77
|
+
if (req.method === 'tools/call') {
|
|
78
|
+
const r = toolsCall(req.params);
|
|
79
|
+
if (r.error) return { jsonrpc: '2.0', id: req.id ?? null, error: r.error };
|
|
80
|
+
return { jsonrpc: '2.0', id: req.id ?? null, result: r.result };
|
|
81
|
+
}
|
|
82
|
+
if (req.method === 'ping') {
|
|
83
|
+
return { jsonrpc: '2.0', id: req.id ?? null, result: {} };
|
|
84
|
+
}
|
|
85
|
+
return { jsonrpc: '2.0', id: req.id ?? null, error: { code: -32601, message: '未知方法: ' + req.method } };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ─── stdio 主循环(本地 agent:Claude Code / Cursor)───
|
|
89
|
+
// 仅直接运行(npx tsx src/mcp.ts)时启用;被 HTTP 网关 import 时不挂 stdin
|
|
90
|
+
const isDirectRun = process.argv[1] && process.argv[1].endsWith('mcp.ts');
|
|
91
|
+
if (isDirectRun) {
|
|
92
|
+
let buf = '';
|
|
93
|
+
process.stdin.setEncoding('utf-8');
|
|
94
|
+
process.stdin.on('data', (chunk: string) => {
|
|
95
|
+
buf += chunk;
|
|
96
|
+
let idx: number;
|
|
97
|
+
while ((idx = buf.indexOf('\n')) >= 0) {
|
|
98
|
+
const line = buf.slice(0, idx).trim();
|
|
99
|
+
buf = buf.slice(idx + 1);
|
|
100
|
+
if (!line) continue;
|
|
101
|
+
let req: McpRequest;
|
|
102
|
+
try { req = JSON.parse(line); } catch { continue; }
|
|
103
|
+
const resp = handleMcpRequest(req);
|
|
104
|
+
if (resp) send(resp);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
process.stdin.on('end', () => { process.exit(0); });
|
|
108
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// GET /v1/arts —— 能力清单(程序化发现:九术 + 参数 schema)
|
|
2
|
+
import { Router } from 'express';
|
|
3
|
+
import { CHART_ARTS, CHART_INPUT_SCHEMA } from '../../../../shared/core/engine/chart.js';
|
|
4
|
+
|
|
5
|
+
const router = Router();
|
|
6
|
+
|
|
7
|
+
const ART_NAMES: Record<string, string> = {
|
|
8
|
+
bazi: '四柱八字', ziwei: '紫微斗数', astrology: '古典星盘',
|
|
9
|
+
qimen: '奇门遁甲', meihua: '梅花易数', liuyao: '六爻',
|
|
10
|
+
liuren: '大六壬', xiaoliuren: '小六壬', tarot: '塔罗',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
router.get('/', (_req, res) => {
|
|
14
|
+
res.json({
|
|
15
|
+
arts: CHART_ARTS.map(id => ({
|
|
16
|
+
id,
|
|
17
|
+
name: ART_NAMES[id] || id,
|
|
18
|
+
inputs: CHART_INPUT_SCHEMA[id] || {},
|
|
19
|
+
})),
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export default router;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// POST /v1/chart —— 九术排盘(免费,纯计算零 token)
|
|
2
|
+
import { Router } from 'express';
|
|
3
|
+
import { chartCalc, CHART_ARTS } from '../../../../shared/core/engine/chart.js';
|
|
4
|
+
|
|
5
|
+
const router = Router();
|
|
6
|
+
|
|
7
|
+
router.post('/', (req, res, next) => {
|
|
8
|
+
try {
|
|
9
|
+
const { art, inputs } = req.body || {};
|
|
10
|
+
if (!art || typeof art !== 'string') {
|
|
11
|
+
return res.status(400).json({ error: 'BAD_REQUEST', message: '缺少 art(术名)' });
|
|
12
|
+
}
|
|
13
|
+
if (!CHART_ARTS.includes(art as any)) {
|
|
14
|
+
return res.status(400).json({ error: 'UNKNOWN_ART', message: '术无此名: ' + art + '(可用 GET /v1/arts 查能力清单)' });
|
|
15
|
+
}
|
|
16
|
+
(req as any).artId = art;
|
|
17
|
+
const resultRaw = chartCalc(art, inputs);
|
|
18
|
+
res.json({ ok: true, art, resultRaw, free: true });
|
|
19
|
+
} catch (e: any) {
|
|
20
|
+
next({ status: 400, code: 'CHART_FAILED', message: e?.message || '排盘失败' });
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export default router;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// 观微排盘 API 入口:/v1 开放接口(免费无 Key,排盘纯计算零 token)
|
|
2
|
+
// 协议与未来托管 API(packages/guanwei-api 演进版)同风格:/v1/chart、/v1/arts、统一错误码
|
|
3
|
+
// 复用:shared/core/engine/chart.ts 单一算法副本(divine 路由同源)
|
|
4
|
+
import express from 'express';
|
|
5
|
+
import chartRouter from './routes/chart.js';
|
|
6
|
+
import artsRouter from './routes/arts.js';
|
|
7
|
+
import { mcpRouter } from './mcp-http.js';
|
|
8
|
+
|
|
9
|
+
const app = express();
|
|
10
|
+
const PORT = Number(process.env.GUANWEI_API_PORT || 3020);
|
|
11
|
+
// 默认只绑本机:开放 API 无 Key,公网暴露须自行加反代/网关;容器与局域网用 GUANWEI_API_HOST=0.0.0.0 显式放开
|
|
12
|
+
const HOST = process.env.GUANWEI_API_HOST || '127.0.0.1';
|
|
13
|
+
|
|
14
|
+
app.use(express.json({ limit: '256kb' }));
|
|
15
|
+
|
|
16
|
+
// ─── 限流(2026-09 修 P0-2):per-IP 令牌桶兜底,防低成本 DoS / 爬取 ───
|
|
17
|
+
// 默认每分钟 120 次排盘;可用 GUANWEI_API_RATE_MAX / GUANWEI_API_RATE_WINDOW_MS 调整
|
|
18
|
+
const RATE_MAX = Number(process.env.GUANWEI_API_RATE_MAX || 120);
|
|
19
|
+
const RATE_WINDOW_MS = Number(process.env.GUANWEI_API_RATE_WINDOW_MS || 60_000);
|
|
20
|
+
const hits = new Map<string, { count: number; resetAt: number }>();
|
|
21
|
+
setInterval(() => {
|
|
22
|
+
const now = Date.now();
|
|
23
|
+
for (const [k, v] of hits) if (now > v.resetAt) hits.delete(k);
|
|
24
|
+
}, 5 * 60 * 1000).unref();
|
|
25
|
+
app.use((req, res, next) => {
|
|
26
|
+
if (req.path === '/v1' || req.path === '/v1/') return next(); // 能力探测不计数
|
|
27
|
+
const ip = req.ip || req.socket.remoteAddress || 'unknown';
|
|
28
|
+
const now = Date.now();
|
|
29
|
+
const rec = hits.get(ip);
|
|
30
|
+
if (!rec || now > rec.resetAt) {
|
|
31
|
+
hits.set(ip, { count: 1, resetAt: now + RATE_WINDOW_MS });
|
|
32
|
+
return next();
|
|
33
|
+
}
|
|
34
|
+
rec.count++;
|
|
35
|
+
if (rec.count > RATE_MAX) {
|
|
36
|
+
res.setHeader('Retry-After', String(Math.ceil((rec.resetAt - now) / 1000)));
|
|
37
|
+
return res.status(429).json({ error: 'RATE_LIMITED', message: '请求过于频繁,请稍后再试' });
|
|
38
|
+
}
|
|
39
|
+
next();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// ─── 最小使用计数("水表"):只统计次数,不记录 IP / 参数 / 载荷,也不外发 ───
|
|
43
|
+
// 目的:自托管/试跑时能回答「有没有人用、用了哪一术」,而不引入任何遥测与隐私面。
|
|
44
|
+
// 关闭:GUANWEI_API_STATS=0;查看:本机 GET /v1/stats(非本机来源返回 404)
|
|
45
|
+
const STATS_ON = process.env.GUANWEI_API_STATS !== '0';
|
|
46
|
+
const stats = { startedAt: Date.now(), total: 0, byEndpoint: {} as Record<string, number>, byDay: {} as Record<string, number> };
|
|
47
|
+
app.use((req, res, next) => {
|
|
48
|
+
if (!STATS_ON) return next();
|
|
49
|
+
if (req.path === '/v1/stats') return next();
|
|
50
|
+
const key = (req.path.startsWith('/v1/chart') ? '/v1/chart' : req.path.startsWith('/mcp') ? '/mcp' : req.path);
|
|
51
|
+
const day = new Date().toISOString().slice(0, 10);
|
|
52
|
+
stats.total++;
|
|
53
|
+
stats.byEndpoint[key] = (stats.byEndpoint[key] || 0) + 1;
|
|
54
|
+
stats.byDay[day] = (stats.byDay[day] || 0) + 1;
|
|
55
|
+
next();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// 请求日志(access log:谁在什么时间调了哪一术,Gate 1 需求验证数据)
|
|
59
|
+
app.use((req, res, next) => {
|
|
60
|
+
res.on('finish', () => {
|
|
61
|
+
const ip = req.ip || req.socket.remoteAddress || '-';
|
|
62
|
+
const line = `[${new Date().toISOString()}] ${req.method} ${req.originalUrl} → ${res.statusCode} ip=${ip}${(req as any).artId ? ' art=' + (req as any).artId : ''}`;
|
|
63
|
+
console.log(line);
|
|
64
|
+
});
|
|
65
|
+
next();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
app.get('/v1/stats', (req, res) => {
|
|
69
|
+
const raw = String(req.ip || req.socket?.remoteAddress || '').replace(/^::ffff:/, '');
|
|
70
|
+
const local = raw === '127.0.0.1' || raw === '::1';
|
|
71
|
+
if (!local) { res.status(404).json({ error: 'NOT_FOUND' }); return; }
|
|
72
|
+
res.json({ ...stats, uptimeSec: Math.round((Date.now() - stats.startedAt) / 1000), note: '仅计数,无 IP/参数/载荷;GUANWEI_API_STATS=0 可关闭' });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
app.get('/v1', (_req, res) => {
|
|
76
|
+
res.json({ name: '观微排盘 API', version: 'v1', endpoints: ['POST /v1/chart', 'GET /v1/arts'], docs: 'https://github.com/RubyCcll/guanwei' });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
app.use('/v1/chart', chartRouter);
|
|
80
|
+
app.use('/v1/arts', artsRouter);
|
|
81
|
+
|
|
82
|
+
// MCP HTTP 传输(国内客户端:WorkBuddy type:"sse"|"http" 等)
|
|
83
|
+
// - POST /mcp(Streamable HTTP)· GET /mcp(SSE)
|
|
84
|
+
// - GET /mcp/sse + POST /mcp/messages(旧版 SSE 传输)
|
|
85
|
+
app.use('/mcp', mcpRouter());
|
|
86
|
+
|
|
87
|
+
// 统一错误处理
|
|
88
|
+
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
|
89
|
+
res.status(err.status || 500).json({ error: err.code || 'INTERNAL', message: err.message || '服务未应机' });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
app.listen(PORT, HOST, () => {
|
|
93
|
+
console.log(`🚀 观微排盘 API 已启动: http://${HOST}:${PORT}/v1`);
|
|
94
|
+
console.log(` POST /v1/chart 排盘(免费) · GET /v1/arts 能力清单`);
|
|
95
|
+
console.log(` 限流 ${RATE_MAX}/${RATE_WINDOW_MS / 1000}s per IP · 监听 ${HOST}(公网暴露请置 GUANWEI_API_HOST=0.0.0.0 并加反代)`);
|
|
96
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "guanwei-server",
|
|
3
|
+
"version": "1.3.5",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "tsx watch src/index.ts",
|
|
7
|
+
"build": "tsc",
|
|
8
|
+
"start": "node dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"cors": "^2.8.5",
|
|
12
|
+
"express": "^4.18.2"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"@types/cors": "^2.8.17",
|
|
16
|
+
"@types/express": "^4.17.21",
|
|
17
|
+
"@types/node": "^22.0.0",
|
|
18
|
+
"tsx": "^4.7.0",
|
|
19
|
+
"typescript": "^5.8.3"
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=22.13.0"
|
|
23
|
+
}
|
|
24
|
+
}
|
package/server/src/index.ts
CHANGED
|
@@ -18,11 +18,8 @@ import { fileURLToPath } from 'url';
|
|
|
18
18
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; // 非法键名 → 跳过
|
|
19
19
|
if (process.env[key] !== undefined) continue; // 环境变量优先,不覆盖
|
|
20
20
|
let val = line.slice(eq + 1).trim();
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
const hash = val.indexOf(' #');
|
|
24
|
-
if (hash >= 0) val = val.slice(0, hash);
|
|
25
|
-
}
|
|
21
|
+
// 注:仅支持「整行注释」(行首 #)。不做行内注释剥离——API Key 可能含「 #」,
|
|
22
|
+
// 原实现会把密钥截断且仍报「已配置」,故障延后到运行期 401(2026-09 修 P1-5)
|
|
26
23
|
// 引号包裹 → 整体剥引号 + 处理转义(仅双引号内做转义展开)
|
|
27
24
|
const q = val.startsWith('"') ? '"' : val.startsWith("'") ? "'" : null;
|
|
28
25
|
if (q && val.endsWith(q) && val.length >= 2) {
|
|
@@ -46,18 +43,39 @@ import aiRouter from './routes/ai.js';
|
|
|
46
43
|
import usersRouter from './routes/users.js';
|
|
47
44
|
import divineRouter from './routes/divine.js';
|
|
48
45
|
import hourRouter from './routes/hour.js';
|
|
46
|
+
import { DATA_DIR, DIVINE_DB } from './services/dataDir.js';
|
|
47
|
+
import { ensureInit as initUsersStore, userCount } from './services/usersStore.js';
|
|
49
48
|
|
|
50
49
|
const app = express();
|
|
51
50
|
const PORT = process.env.PORT || 3018;
|
|
51
|
+
// 默认只绑本机(个人自托管场景);容器/局域网部署用 HOST=0.0.0.0 显式放开
|
|
52
|
+
const HOST = process.env.HOST || '127.0.0.1';
|
|
53
|
+
// 反代(nginx/Docker)后取真实客户端 IP:否则限流键恒为网关地址,退化为全站单桶(2026-09 修 P1-2)
|
|
54
|
+
app.set('trust proxy', 'loopback, linklocal, uniquelocal');
|
|
52
55
|
|
|
53
|
-
|
|
56
|
+
// CORS:默认仅同源/本机(原 `cors()` 全开,配合匿名占位账号放行可被任意站点跨源读取,2026-09 修 P2-1)
|
|
57
|
+
// 需要放开时设 GUANWEI_ALLOWED_ORIGINS=https://a.com,https://b.com
|
|
58
|
+
const ALLOWED = (process.env.GUANWEI_ALLOWED_ORIGINS || '').split(',').map(x => x.trim()).filter(Boolean);
|
|
59
|
+
app.use(cors({
|
|
60
|
+
origin(origin: string | undefined, cb: (e: Error | null, ok?: boolean) => void) {
|
|
61
|
+
if (!origin) return cb(null, true); // 同源/服务端调用
|
|
62
|
+
if (ALLOWED.length === 0) {
|
|
63
|
+
const local = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(origin);
|
|
64
|
+
return cb(null, local); // 未配置 → 仅本机前端
|
|
65
|
+
}
|
|
66
|
+
return cb(null, ALLOWED.includes(origin));
|
|
67
|
+
},
|
|
68
|
+
}));
|
|
54
69
|
app.use(express.json());
|
|
55
70
|
|
|
56
|
-
// ─── 限流(防 BYOK Key 被刷爆 / 无限建占刷库):per-IP 令牌桶 ───
|
|
71
|
+
// ─── 限流(防 BYOK Key 被刷爆 / 无限建占刷库):per-IP + per-路由组 令牌桶 ───
|
|
57
72
|
// /api/ai:每分钟 30 次(AI 烧 token,重点防护)
|
|
58
73
|
// /api/divine 写:每分钟 60 次(防无限建占刷 SQLite)
|
|
59
|
-
|
|
60
|
-
|
|
74
|
+
// 2026-09 修 P1-1:桶键含路由组——原实现单 Map 仅按 IP 计数,
|
|
75
|
+
// 用户连续起占 31 次会把首个 AI 解读请求顶成 429(跨路由互相误伤)
|
|
76
|
+
const RATE_ENV = (k: string, d: number) => Number(process.env[k] || d);
|
|
77
|
+
const AI_RATE_LIMIT = { windowMs: 60_000, max: RATE_ENV('GUANWEI_RATE_AI', 30) };
|
|
78
|
+
const DIVINE_RATE_LIMIT = { windowMs: 60_000, max: RATE_ENV('GUANWEI_RATE_DIVINE', 60) };
|
|
61
79
|
const hits = new Map<string, { count: number; resetAt: number }>();
|
|
62
80
|
// 定期清理过期桶(防内存泄漏,M-NEW3)
|
|
63
81
|
setInterval(() => {
|
|
@@ -65,27 +83,39 @@ setInterval(() => {
|
|
|
65
83
|
for (const [k, v] of hits) if (now > v.resetAt) hits.delete(k);
|
|
66
84
|
}, 10 * 60 * 1000).unref();
|
|
67
85
|
|
|
68
|
-
function rateLimit(limit: { windowMs: number; max: number }) {
|
|
86
|
+
function rateLimit(group: string, limit: { windowMs: number; max: number }) {
|
|
69
87
|
return (req: any, res: any, next: any) => {
|
|
70
88
|
const ip = req.ip || req.socket.remoteAddress || 'unknown';
|
|
89
|
+
const key = group + ':' + ip;
|
|
71
90
|
const now = Date.now();
|
|
72
|
-
const rec = hits.get(
|
|
91
|
+
const rec = hits.get(key);
|
|
73
92
|
if (!rec || now > rec.resetAt) {
|
|
74
|
-
hits.set(
|
|
93
|
+
hits.set(key, { count: 1, resetAt: now + limit.windowMs });
|
|
75
94
|
return next();
|
|
76
95
|
}
|
|
77
96
|
rec.count++;
|
|
78
97
|
if (rec.count > limit.max) {
|
|
98
|
+
// 与开放 API 同口径:给出可退避的秒数(前端据此退避重试)
|
|
99
|
+
res.setHeader('Retry-After', String(Math.max(1, Math.ceil((rec.resetAt - now) / 1000))));
|
|
79
100
|
return res.status(429).json({ error: 'RATE_LIMITED', message: '请求过于频繁,请稍后再试' });
|
|
80
101
|
}
|
|
81
102
|
next();
|
|
82
103
|
};
|
|
83
104
|
}
|
|
84
|
-
app.use('/api/ai', rateLimit(AI_RATE_LIMIT));
|
|
105
|
+
app.use('/api/ai', rateLimit('ai', AI_RATE_LIMIT));
|
|
85
106
|
app.use('/api/divine', (req, res, next) => {
|
|
86
107
|
if (req.method === 'GET') return next(); // 读操作不限制
|
|
87
|
-
return rateLimit(DIVINE_RATE_LIMIT)(req, res, next);
|
|
108
|
+
return rateLimit('divine', DIVINE_RATE_LIMIT)(req, res, next);
|
|
88
109
|
});
|
|
110
|
+
// 登录/注册:更严阈值(防口令爆破与脚本化抢注,2026-09 修 P1-3)
|
|
111
|
+
const AUTH_RATE_LIMIT = { windowMs: 60_000, max: RATE_ENV('GUANWEI_RATE_AUTH', 10) };
|
|
112
|
+
const GENERIC_LIMIT = { windowMs: 60_000, max: RATE_ENV('GUANWEI_RATE_GENERIC', 120) };
|
|
113
|
+
app.use('/api/users/login', rateLimit('login', AUTH_RATE_LIMIT));
|
|
114
|
+
app.use('/api/users/register', rateLimit('register', AUTH_RATE_LIMIT));
|
|
115
|
+
app.use('/api/users', rateLimit('users', GENERIC_LIMIT));
|
|
116
|
+
// 其余匿名计算端点(原不在任何桶内 → 可被无上限刷,2026-09 修 P0-5/P2-7)
|
|
117
|
+
app.use('/api/tarot', rateLimit('tarot', GENERIC_LIMIT));
|
|
118
|
+
app.use('/api/hour', rateLimit('hour', GENERIC_LIMIT));
|
|
89
119
|
|
|
90
120
|
// 请求日志(联调排查用)
|
|
91
121
|
app.use((req, res, next) => {
|
|
@@ -106,7 +136,13 @@ app.get('/api', (_req, res) => {
|
|
|
106
136
|
});
|
|
107
137
|
|
|
108
138
|
app.get('/api/health', (_req, res) => {
|
|
109
|
-
|
|
139
|
+
// 轻量自检:数据目录可写 + LLM 配置状态(不含敏感值)
|
|
140
|
+
let dataDirOk = true;
|
|
141
|
+
try { fs.accessSync(DATA_DIR, fs.constants.W_OK); } catch { dataDirOk = false; }
|
|
142
|
+
// 只报账号数量与存储类型,不泄露账号名(隐私口径)
|
|
143
|
+
let users: number | null = null;
|
|
144
|
+
try { users = userCount(); } catch { /* 库不可读时保持 null */ }
|
|
145
|
+
res.json({ status: dataDirOk ? 'ok' : 'degraded', dataDir: dataDirOk ? 'ok' : 'readonly', store: 'sqlite', users, timestamp: Date.now() });
|
|
110
146
|
});
|
|
111
147
|
|
|
112
148
|
app.use('/api/tarot', tarotRouter);
|
|
@@ -115,7 +151,27 @@ app.use('/api/users', usersRouter);
|
|
|
115
151
|
app.use('/api/divine', divineRouter);
|
|
116
152
|
app.use('/api', hourRouter);
|
|
117
153
|
|
|
118
|
-
|
|
154
|
+
// 统一错误处理(2026-09 修 P1-1):JSON 解析失败/未捕获异常一律返回 JSON,
|
|
155
|
+
// 且不把堆栈与绝对路径回给客户端(原实现由 Express 默认处理器返回 HTML 堆栈页)
|
|
156
|
+
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
|
157
|
+
const status = err?.status || err?.statusCode || 500;
|
|
158
|
+
const isJsonErr = err?.type === 'entity.parse.failed';
|
|
159
|
+
if (status >= 500) console.error('[server] 未处理异常:', err?.message || err);
|
|
160
|
+
res.status(status).json({
|
|
161
|
+
error: isJsonErr ? 'BAD_JSON' : (err?.code || 'INTERNAL'),
|
|
162
|
+
message: isJsonErr ? '请求体不是合法 JSON' : (status >= 500 ? '服务暂时不可用' : (err?.message || '请求有误')),
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// 启动即初始化统一存储:建表 + 旧 db.json 一次性导入(幂等;失败不阻塞启动)
|
|
167
|
+
try {
|
|
168
|
+
initUsersStore();
|
|
169
|
+
console.log(`[store] SQLite: ${DIVINE_DB}(账号 ${userCount()} 个)`);
|
|
170
|
+
} catch (e: any) {
|
|
171
|
+
console.error('[store] 初始化失败:', e?.message || e);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
app.listen(Number(PORT), HOST, () => {
|
|
119
175
|
console.log(`
|
|
120
176
|
🎴 观微后端服务已启动
|
|
121
177
|
|