guanwei 1.3.2 → 1.3.4
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 +28 -8
- package/dist/assets/index-DVd66cjI.js +102 -0
- package/dist/index.html +1 -1
- package/package.json +13 -7
- 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 +73 -0
- package/scripts/check-boundary.mjs +80 -0
- package/scripts/check-package.mjs +71 -0
- package/scripts/credentials.mjs +52 -0
- package/scripts/github-history-cleanup.mjs +140 -0
- package/scripts/npm-release.mjs +79 -0
- package/scripts/preflight-release.sh +29 -0
- package/scripts/release.sh +8 -3
- package/server/package.json +24 -0
- package/server/src/index.ts +58 -16
- package/server/src/routes/ai.ts +11 -17
- package/server/src/routes/divine.ts +36 -29
- package/server/src/routes/hour.ts +4 -0
- package/server/src/routes/tarot.ts +30 -7
- package/server/src/routes/users.ts +99 -48
- package/server/src/services/auth.ts +53 -0
- package/server/src/services/dataDir.ts +80 -0
- package/server/src/services/divineStore.ts +5 -2
- package/server/src/services/duanyu.ts +3 -3
- package/server/src/services/llmProvider.ts +4 -3
- package/server/src/services/usersDb.ts +45 -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/DemoPage.tsx +1 -1
- package/src/services/api.ts +18 -1
- package/src/utils/userStore.ts +6 -2
- package/vite.config.ts +7 -3
- package/dist/assets/index-BZTUsUN1.js +0 -102
- package/dist/assets/index-BZTUsUN1.js.map +0 -1
- 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-DVd66cjI.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.4",
|
|
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,8 +62,13 @@
|
|
|
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",
|
|
71
|
+
"packages",
|
|
66
72
|
"scripts",
|
|
67
73
|
"public",
|
|
68
74
|
"index.html",
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "guanwei-api",
|
|
3
|
+
"version": "1.3.4",
|
|
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.4' } } };
|
|
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,73 @@
|
|
|
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
|
+
// 请求日志(access log:谁在什么时间调了哪一术,Gate 1 需求验证数据)
|
|
43
|
+
app.use((req, res, next) => {
|
|
44
|
+
res.on('finish', () => {
|
|
45
|
+
const ip = req.ip || req.socket.remoteAddress || '-';
|
|
46
|
+
const line = `[${new Date().toISOString()}] ${req.method} ${req.originalUrl} → ${res.statusCode} ip=${ip}${(req as any).artId ? ' art=' + (req as any).artId : ''}`;
|
|
47
|
+
console.log(line);
|
|
48
|
+
});
|
|
49
|
+
next();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
app.get('/v1', (_req, res) => {
|
|
53
|
+
res.json({ name: '观微排盘 API', version: 'v1', endpoints: ['POST /v1/chart', 'GET /v1/arts'], docs: 'https://github.com/RubyCcll/guanwei' });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
app.use('/v1/chart', chartRouter);
|
|
57
|
+
app.use('/v1/arts', artsRouter);
|
|
58
|
+
|
|
59
|
+
// MCP HTTP 传输(国内客户端:WorkBuddy type:"sse"|"http" 等)
|
|
60
|
+
// - POST /mcp(Streamable HTTP)· GET /mcp(SSE)
|
|
61
|
+
// - GET /mcp/sse + POST /mcp/messages(旧版 SSE 传输)
|
|
62
|
+
app.use('/mcp', mcpRouter());
|
|
63
|
+
|
|
64
|
+
// 统一错误处理
|
|
65
|
+
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
|
66
|
+
res.status(err.status || 500).json({ error: err.code || 'INTERNAL', message: err.message || '服务未应机' });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
app.listen(PORT, HOST, () => {
|
|
70
|
+
console.log(`🚀 观微排盘 API 已启动: http://${HOST}:${PORT}/v1`);
|
|
71
|
+
console.log(` POST /v1/chart 排盘(免费) · GET /v1/arts 能力清单`);
|
|
72
|
+
console.log(` 限流 ${RATE_MAX}/${RATE_WINDOW_MS / 1000}s per IP · 监听 ${HOST}(公网暴露请置 GUANWEI_API_HOST=0.0.0.0 并加反代)`);
|
|
73
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// 仓库边界守卫:明确区分「公开区 / 内部区 / 打包区」,并拦截越界
|
|
3
|
+
//
|
|
4
|
+
// 公开区(git 跟踪 → GitHub 可见) :src/ server/src/ shared/ packages/ scripts/ tests/ .github/ README 等
|
|
5
|
+
// 内部区(仅本地,git 忽略 → 不可见):internal/(规划、监控、审计、SOP、草稿、真实案例)
|
|
6
|
+
// 打包区(npm publish 内容) :package.json `files` 白名单(必须是公开区子集)
|
|
7
|
+
//
|
|
8
|
+
// 用法:node scripts/check-boundary.mjs 退出码 0=边界干净;1=越界
|
|
9
|
+
// 接入:.github/workflows/ci.yml、scripts/preflight-release.sh
|
|
10
|
+
import { execFileSync } from 'node:child_process';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
|
|
14
|
+
const tracked = execFileSync('git', ['ls-files'], { encoding: 'utf8' }).split('\n').filter(Boolean);
|
|
15
|
+
|
|
16
|
+
// ── 1) 路径级:公开区不得出现内部/敏感路径 ──
|
|
17
|
+
const FORBIDDEN_PATH = [
|
|
18
|
+
[/^internal\//, '内部区目录(应仅本地存在)'],
|
|
19
|
+
[/^docs\/.*\.md$/, '内部文档(内部文档应放 internal/docs/;docs/ 只留对外素材)'],
|
|
20
|
+
[/(^|\/)\.env(\.|$)/, '环境变量文件'],
|
|
21
|
+
[/\.(db|sqlite|sqlite3)$/, '运行时数据库'],
|
|
22
|
+
[/(^|\/)db\.json$/, '用户档案库'],
|
|
23
|
+
[/(^|\/)(id_rsa|.*\.pem)$/, '私钥'],
|
|
24
|
+
[/(^|\/)(\.audit|audit-tmp)/, '审计临时文件'],
|
|
25
|
+
[/^tests\/tmp-test-/, '本地临时测试(可能含真实案例)'],
|
|
26
|
+
];
|
|
27
|
+
const pathViolations = [];
|
|
28
|
+
for (const f of tracked) {
|
|
29
|
+
if (/\.example$/.test(f)) continue; // *.env.example 为公开模板(无值),可入库
|
|
30
|
+
for (const [re, why] of FORBIDDEN_PATH) if (re.test(f)) pathViolations.push(`${f} ← ${why}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ── 2) 内容级:公开区文件不得含密钥/隐私特征(跳过二进制与压缩产物)──
|
|
34
|
+
const KEY_PATTERNS = [
|
|
35
|
+
[/sk-[A-Za-z0-9_-]{20,}/, '疑似 LLM API key'],
|
|
36
|
+
[/AIza[0-9A-Za-z_-]{30,}/, '疑似 Google API key'],
|
|
37
|
+
[/gsk_[A-Za-z0-9]{40,}/, '疑似 Groq key'],
|
|
38
|
+
[/gh[pousr]_[A-Za-z0-9]{30,}/, '疑似 GitHub token'],
|
|
39
|
+
[/_authToken\s*=\s*(?!\$\{)[A-Za-z0-9_.\-]{16,}/, 'npm 认证 token(真实值)'],
|
|
40
|
+
];
|
|
41
|
+
const SKIP_EXT = /\.(png|jpe?g|gif|ico|webp|woff2?|ttf|otf|pdf|zip|gz|mp4|db)$/i;
|
|
42
|
+
const contentViolations = [];
|
|
43
|
+
for (const f of tracked) {
|
|
44
|
+
if (SKIP_EXT.test(f)) continue;
|
|
45
|
+
let text;
|
|
46
|
+
try { if (fs.statSync(f).size > 4 * 1024 * 1024) continue; text = fs.readFileSync(f, 'utf8'); } catch { continue; }
|
|
47
|
+
for (const [re, why] of KEY_PATTERNS) if (re.test(text)) contentViolations.push(`${f} ← ${why}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── 3) 打包区:npm files 白名单必须是公开区子集 ──
|
|
51
|
+
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
|
|
52
|
+
const files = pkg.files || [];
|
|
53
|
+
const BAD_ALLOW = [
|
|
54
|
+
[/^internal/, '内部区不得进 npm 包'],
|
|
55
|
+
[/^docs/, 'docs/ 仅 GitHub 素材,无需进包'],
|
|
56
|
+
[/^tests/, '测试不入包'],
|
|
57
|
+
[/^\.github/, 'CI 配置不入包'],
|
|
58
|
+
[/^server\/src\/data/, '运行时数据不入包'],
|
|
59
|
+
];
|
|
60
|
+
const allowViolations = files.filter(f => BAD_ALLOW.some(([re]) => re.test(f))).map(f => `files 白名单含 ${f}`);
|
|
61
|
+
|
|
62
|
+
// ── 报告 ──
|
|
63
|
+
const count = (prefix) => tracked.filter(f => f === prefix || f.startsWith(prefix + '/')).length;
|
|
64
|
+
const dist = Object.fromEntries(['src', 'shared', 'server', 'packages', 'scripts', 'tests', '.github']
|
|
65
|
+
.map(d => [d, count(d)]).filter(([, n]) => n > 0));
|
|
66
|
+
|
|
67
|
+
console.log('公开区(git 跟踪):', tracked.length, '个文件');
|
|
68
|
+
console.log(' 顶层分布:', JSON.stringify(dist));
|
|
69
|
+
console.log(' 内部区(仅本地): internal/ 下', fs.existsSync('internal')
|
|
70
|
+
? execFileSync('find', ['internal', '-type', 'f'], { encoding: 'utf8' }).split('\n').filter(Boolean).length
|
|
71
|
+
: 0, '个文件(git 忽略,永不公开)');
|
|
72
|
+
console.log('打包区(npm files 白名单):', files.length, '条 → 内容由 scripts/check-package.mjs 二次校验');
|
|
73
|
+
|
|
74
|
+
const all = [...pathViolations, ...contentViolations, ...allowViolations];
|
|
75
|
+
if (all.length) {
|
|
76
|
+
console.error('\n✗ 边界守卫未通过:');
|
|
77
|
+
for (const v of all.slice(0, 20)) console.error(' -', v);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
console.log('\n✅ 边界干净:公开区无内部内容,打包区是公开区子集');
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// 发布内容守卫(硬门槛):断言 npm 包内不含任何运行时数据与密钥
|
|
3
|
+
//
|
|
4
|
+
// 用法:node scripts/check-package.mjs (CI / release / 本地发布前均调用)
|
|
5
|
+
// 退出码:0=干净;1=命中敏感内容(拒绝发布)
|
|
6
|
+
//
|
|
7
|
+
// 背景(2026-09 事故):`files` 白名单曾含 `server/src`,而运行时数据默认写在
|
|
8
|
+
// `server/src/data/`,导致 1.1.1–1.3.2 全部 npm 版本把用户档案(含 token/passHash/
|
|
9
|
+
// 出生信息)与占卜记录打包公开。此守卫对**文件名与文件内容**双重检查。
|
|
10
|
+
import { execFileSync } from 'node:child_process';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
|
|
15
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gw-pack-'));
|
|
16
|
+
let tgz;
|
|
17
|
+
try {
|
|
18
|
+
// --cache 指向临时目录:避免依赖本机 ~/.npm 缓存状态(权限异常时 npm 会直接失败)
|
|
19
|
+
const out = execFileSync('npm', ['pack', '--ignore-scripts', '--json', '--pack-destination', tmp, '--cache', path.join(tmp, 'npmcache')],
|
|
20
|
+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
21
|
+
tgz = path.join(tmp, JSON.parse(out)[0].filename);
|
|
22
|
+
} catch (e) {
|
|
23
|
+
console.error('✗ npm pack 失败:', e.message);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// 1) 文件名黑名单
|
|
28
|
+
const BAD_NAME = [
|
|
29
|
+
/(^|\/)\.env(\.|$)/, /(^|\/)db\.json$/, /(^|\/)guanwei\.db$/, /\.(sqlite|sqlite3|db)$/,
|
|
30
|
+
/(^|\/)\.npmrc$/, /(^|\/)id_rsa/, /\.pem$/, /(^|\/)secret/i,
|
|
31
|
+
];
|
|
32
|
+
const names = execFileSync('tar', ['-tzf', tgz], { encoding: 'utf8' }).split('\n').filter(Boolean);
|
|
33
|
+
const badNames = names.filter(n => BAD_NAME.some(re => re.test(n)));
|
|
34
|
+
|
|
35
|
+
// 2) 内容级扫描(解包后逐文件,跳过二进制大文件)
|
|
36
|
+
const dir = path.join(tmp, 'x');
|
|
37
|
+
fs.mkdirSync(dir);
|
|
38
|
+
execFileSync('tar', ['-xzf', tgz, '-C', dir]);
|
|
39
|
+
const BAD_CONTENT = [
|
|
40
|
+
[/sk-[A-Za-z0-9_-]{20,}/, '疑似 OpenAI/DeepSeek 风格 key'],
|
|
41
|
+
[/AIza[0-9A-Za-z_-]{30,}/, '疑似 Google API key'],
|
|
42
|
+
[/gsk_[A-Za-z0-9]{40,}/, '疑似 Groq key'],
|
|
43
|
+
[/sk-[a-f0-9]{32}/, '疑似 DashScope key'],
|
|
44
|
+
[/xox[baprs]-[A-Za-z0-9-]{10,}/, '疑似 Slack token'],
|
|
45
|
+
[/gh[pousr]_[A-Za-z0-9]{30,}/, '疑似 GitHub token'],
|
|
46
|
+
[/_authToken\s*=\s*(?!\$\{)[A-Za-z0-9_.\-]{16,}/, 'npm 认证 token(真实值)'],
|
|
47
|
+
[/"(passHash|tokenExpires)"\s*:/, '用户档案库特征字段'],
|
|
48
|
+
];
|
|
49
|
+
const hits = [];
|
|
50
|
+
const walk = (d) => {
|
|
51
|
+
for (const ent of fs.readdirSync(d, { withFileTypes: true })) {
|
|
52
|
+
const p = path.join(d, ent.name);
|
|
53
|
+
if (ent.isDirectory()) { walk(p); continue; }
|
|
54
|
+
const rel = path.relative(dir, p);
|
|
55
|
+
if (/\.(png|jpg|jpeg|gif|ico|woff2?|ttf|db|zip|pdf)$/i.test(ent.name)) continue; // 二进制跳过
|
|
56
|
+
if (fs.statSync(p).size > 8 * 1024 * 1024) continue;
|
|
57
|
+
const text = fs.readFileSync(p, 'utf8');
|
|
58
|
+
for (const [re, label] of BAD_CONTENT) if (re.test(text)) hits.push(rel + ' ← ' + label);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
walk(dir);
|
|
62
|
+
|
|
63
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
64
|
+
|
|
65
|
+
if (badNames.length || hits.length) {
|
|
66
|
+
console.error('✗ 包内容守卫未通过——拒绝发布');
|
|
67
|
+
if (badNames.length) console.error(' 敏感文件:', badNames.slice(0, 10));
|
|
68
|
+
if (hits.length) console.error(' 敏感内容:', hits.slice(0, 10));
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
console.log(`✅ 包内容干净(${names.length} 个文件,无运行时数据/密钥)`);
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// 本地凭据加载(统一入口,供各脚本复用)
|
|
2
|
+
//
|
|
3
|
+
// 设计说明(为什么不是直接让 npm 读 .env):
|
|
4
|
+
// - npm CLI **不读** .env;它只读 .npmrc,并支持 `${VAR}` 环境变量插值
|
|
5
|
+
// - 因此约定:token 值集中放 internal/.env(人可读、整目录 gitignore),
|
|
6
|
+
// 由脚本读取后注入环境/临时 .npmrc,再调用 npm
|
|
7
|
+
//
|
|
8
|
+
// 读取优先级:环境变量 > internal/.env > internal/<name> 独立文件
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
|
|
12
|
+
const ROOT = process.cwd();
|
|
13
|
+
const ENV_FILE = path.join(ROOT, 'internal', '.env');
|
|
14
|
+
|
|
15
|
+
/** 极简 dotenv 解析(仅 KEY=VALUE,支持 # 整行注释与引号) */
|
|
16
|
+
function parseEnvFile(file) {
|
|
17
|
+
const out = {};
|
|
18
|
+
if (!fs.existsSync(file)) return out;
|
|
19
|
+
for (const raw of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
|
|
20
|
+
const line = raw.trim();
|
|
21
|
+
if (!line || line.startsWith('#')) continue;
|
|
22
|
+
const eq = line.indexOf('=');
|
|
23
|
+
if (eq <= 0) continue;
|
|
24
|
+
const k = line.slice(0, eq).trim();
|
|
25
|
+
let v = line.slice(eq + 1).trim();
|
|
26
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
|
|
27
|
+
if (v) out[k] = v;
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readFileIfExists(rel) {
|
|
33
|
+
const p = path.join(ROOT, rel);
|
|
34
|
+
try { return fs.existsSync(p) ? fs.readFileSync(p, 'utf8').trim() : ''; } catch { return ''; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 返回 { GITHUB_TOKEN, NPM_TOKEN }(缺失则为空字符串) */
|
|
38
|
+
export function loadCredentials() {
|
|
39
|
+
const fromEnvFile = parseEnvFile(ENV_FILE);
|
|
40
|
+
const pick = (key, file) =>
|
|
41
|
+
process.env[key]?.trim() || fromEnvFile[key] || readFileIfExists(file) || '';
|
|
42
|
+
return {
|
|
43
|
+
GITHUB_TOKEN: pick('GITHUB_TOKEN', 'internal/github-token'),
|
|
44
|
+
NPM_TOKEN: pick('NPM_TOKEN', 'internal/npm-token'),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const CRED_FILES = {
|
|
49
|
+
env: 'internal/.env',
|
|
50
|
+
github: 'internal/github-token',
|
|
51
|
+
npm: 'internal/npm-token',
|
|
52
|
+
};
|