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
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// GitHub 仓库历史清理工具(可复用于任何仓库)
|
|
3
|
+
//
|
|
4
|
+
// 场景:用 git filter-repo 重写历史后,远端仍残留指向旧历史的 tag / release / 分支
|
|
5
|
+
// (尤其 immutable release 的 tag 会被 GitHub 永久绑定,无法用 git push 更新)。
|
|
6
|
+
//
|
|
7
|
+
// 用法:
|
|
8
|
+
// GITHUB_TOKEN=xxx node scripts/github-history-cleanup.mjs list --repo owner/name
|
|
9
|
+
// GITHUB_TOKEN=xxx node scripts/github-history-cleanup.mjs purge --repo owner/name --keep v1.3.4 [--apply]
|
|
10
|
+
// GITHUB_TOKEN=xxx node scripts/github-history-cleanup.mjs branches --repo owner/name [--apply]
|
|
11
|
+
//
|
|
12
|
+
// token 读取顺序:--token-file > $GITHUB_TOKEN > internal/github-token(本地私有,git 忽略)
|
|
13
|
+
// 默认 **dry-run**:不加 --apply 只打印将要执行的操作。
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
|
|
17
|
+
const argv = process.argv.slice(2);
|
|
18
|
+
const cmd = argv[0];
|
|
19
|
+
const arg = (name, def) => {
|
|
20
|
+
const i = argv.indexOf(name);
|
|
21
|
+
return i >= 0 && argv[i + 1] ? argv[i + 1] : def;
|
|
22
|
+
};
|
|
23
|
+
const has = (name) => argv.includes(name);
|
|
24
|
+
const REPO = arg('--repo');
|
|
25
|
+
const KEEP = (arg('--keep', '') || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
26
|
+
const APPLY = has('--apply');
|
|
27
|
+
if (!REPO || !REPO.includes('/')) {
|
|
28
|
+
console.error('用法: node scripts/github-history-cleanup.mjs <list|purge|branches|releases> --repo owner/name [--keep tag,...] [--apply]');
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
const TOKEN = (() => {
|
|
32
|
+
const f = arg('--token-file');
|
|
33
|
+
const candidates = [f, process.env.GITHUB_TOKEN && '(env)', path.resolve('internal/github-token')].filter(Boolean);
|
|
34
|
+
if (f && fs.existsSync(f)) return fs.readFileSync(f, 'utf8').trim();
|
|
35
|
+
if (process.env.GITHUB_TOKEN) return process.env.GITHUB_TOKEN.trim();
|
|
36
|
+
const local = path.resolve('internal/github-token');
|
|
37
|
+
if (fs.existsSync(local)) return fs.readFileSync(local, 'utf8').trim();
|
|
38
|
+
console.error('缺少 token(--token-file / $GITHUB_TOKEN / internal/github-token 均不可用)', candidates);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
})();
|
|
41
|
+
|
|
42
|
+
async function api(method, p, body) {
|
|
43
|
+
const res = await fetch('https://api.github.com' + p, {
|
|
44
|
+
method,
|
|
45
|
+
headers: {
|
|
46
|
+
Authorization: 'Bearer ' + TOKEN,
|
|
47
|
+
Accept: 'application/vnd.github+json',
|
|
48
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
49
|
+
...(body ? { 'Content-Type': 'application/json' } : {}),
|
|
50
|
+
},
|
|
51
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
52
|
+
});
|
|
53
|
+
const text = await res.text();
|
|
54
|
+
return { status: res.status, body: text ? JSON.parse(text) : null };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function listAll(p) {
|
|
58
|
+
const out = [];
|
|
59
|
+
for (let page = 1; page <= 10; page++) {
|
|
60
|
+
const { status, body } = await api('GET', `${p}${p.includes('?') ? '&' : '?'}per_page=100&page=${page}`);
|
|
61
|
+
if (status !== 200 || !Array.isArray(body) || body.length === 0) break;
|
|
62
|
+
out.push(...body);
|
|
63
|
+
if (body.length < 100) break;
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const info = async () => {
|
|
69
|
+
const { status, body } = await api('GET', `/repos/${REPO}`);
|
|
70
|
+
if (status !== 200) { console.error('仓库不可读:', status, body?.message); process.exit(1); }
|
|
71
|
+
return body;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const run = async () => {
|
|
75
|
+
const repo = await info();
|
|
76
|
+
const releases = await listAll(`/repos/${REPO}/releases`);
|
|
77
|
+
const tags = await listAll(`/repos/${REPO}/git/refs/tags`);
|
|
78
|
+
const branches = await listAll(`/repos/${REPO}/branches`);
|
|
79
|
+
const tagNames = tags.map(t => t.ref.replace('refs/tags/', ''));
|
|
80
|
+
|
|
81
|
+
if (cmd === 'list') {
|
|
82
|
+
console.log(`仓库 ${REPO}(默认分支 ${repo.default_branch})`);
|
|
83
|
+
console.log(` releases (${releases.length}):`, releases.map(r => `${r.tag_name}${r.immutable ? '(immutable)' : ''}`).join(', ') || '无');
|
|
84
|
+
console.log(` tags (${tagNames.length}):`, tagNames.join(', ') || '无');
|
|
85
|
+
console.log(` branches (${branches.length}):`, branches.map(b => b.name).join(', '));
|
|
86
|
+
const stale = tagNames.filter(t => !KEEP.includes(t));
|
|
87
|
+
console.log(`\n--keep 之外的 tag(purge 将删除其 release + tag):`, stale.join(', ') || '无');
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (cmd === 'branches') {
|
|
92
|
+
const stale = branches.map(b => b.name).filter(n => n !== repo.default_branch);
|
|
93
|
+
if (!stale.length) return console.log('无非默认分支');
|
|
94
|
+
for (const n of stale) {
|
|
95
|
+
if (!APPLY) { console.log(`[dry-run] 将删除分支 ${n}`); continue; }
|
|
96
|
+
const { status } = await api('DELETE', `/repos/${REPO}/git/refs/heads/${n}`);
|
|
97
|
+
console.log(`删除分支 ${n} → ${status}`);
|
|
98
|
+
}
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (cmd === 'purge' || cmd === 'releases') {
|
|
103
|
+
// 防误删:未显式 --keep 时,默认保留「最新版本 tag」(latest release → semver 最大者)
|
|
104
|
+
let keep = [...KEEP];
|
|
105
|
+
if (keep.length === 0) {
|
|
106
|
+
const { body: latest } = await api('GET', `/repos/${REPO}/releases/latest`);
|
|
107
|
+
const cand = latest?.tag_name || tagNames
|
|
108
|
+
.slice()
|
|
109
|
+
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
|
|
110
|
+
.pop();
|
|
111
|
+
if (cand) { keep = [cand]; console.log(`(未指定 --keep,默认保留最新版本 ${cand})`); }
|
|
112
|
+
}
|
|
113
|
+
const staleTags = tagNames.filter(t => !keep.includes(t));
|
|
114
|
+
const byTag = new Map(releases.map(r => [r.tag_name, r]));
|
|
115
|
+
for (const t of staleTags) {
|
|
116
|
+
const rel = byTag.get(t);
|
|
117
|
+
if (rel) {
|
|
118
|
+
if (!APPLY) console.log(`[dry-run] 将删除 release ${t}${rel.immutable ? '(immutable)' : ''} + tag ${t}`);
|
|
119
|
+
else {
|
|
120
|
+
const d1 = await api('DELETE', `/repos/${REPO}/releases/${rel.id}`);
|
|
121
|
+
const d2 = await api('DELETE', `/repos/${REPO}/git/refs/tags/${t}`);
|
|
122
|
+
console.log(`删除 ${t}: release=${d1.status} tag=${d2.status}`);
|
|
123
|
+
}
|
|
124
|
+
} else if (cmd === 'purge') {
|
|
125
|
+
if (!APPLY) console.log(`[dry-run] 将删除 tag ${t}(无 release)`);
|
|
126
|
+
else {
|
|
127
|
+
const d = await api('DELETE', `/repos/${REPO}/git/refs/tags/${t}`);
|
|
128
|
+
console.log(`删除 tag ${t} → ${d.status}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (!APPLY) console.log(`\n共 ${staleTags.length} 个待处理;确认后加 --apply 执行`);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
console.error('未知子命令:', cmd);
|
|
137
|
+
process.exit(1);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
run().catch(e => { console.error('执行失败:', e.message); process.exit(1); });
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// npm 发布工具(读本地凭据 → 校验 → 包内容守卫 → 发布)
|
|
3
|
+
//
|
|
4
|
+
// 用法:
|
|
5
|
+
// node scripts/npm-release.mjs --check # 只校验凭据与登录态(不发布)
|
|
6
|
+
// node scripts/npm-release.mjs --dry-run # 走一遍打包与守卫,不真正发布
|
|
7
|
+
// node scripts/npm-release.mjs # 正式发布(用 internal/.env 的 NPM_TOKEN)
|
|
8
|
+
// node scripts/npm-release.mjs --otp 123456 # 用动态码(无 Automation token 时)
|
|
9
|
+
//
|
|
10
|
+
// 为什么需要它:npm 不读 .env;本脚本把 internal/.env 的 NPM_TOKEN 注入临时 .npmrc 后调用 npm。
|
|
11
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { loadCredentials, CRED_FILES } from './credentials.mjs';
|
|
15
|
+
|
|
16
|
+
const argv = process.argv.slice(2);
|
|
17
|
+
const has = (f) => argv.includes(f);
|
|
18
|
+
const val = (f) => { const i = argv.indexOf(f); return i >= 0 ? argv[i + 1] : ''; };
|
|
19
|
+
const REGISTRY = 'https://registry.npmjs.org';
|
|
20
|
+
const ROOT = process.cwd();
|
|
21
|
+
|
|
22
|
+
const { NPM_TOKEN } = loadCredentials();
|
|
23
|
+
const otp = val('--otp');
|
|
24
|
+
|
|
25
|
+
async function whoami(token) {
|
|
26
|
+
const res = await fetch(`${REGISTRY}/-/whoami`, { headers: token ? { Authorization: 'Bearer ' + token } : {} });
|
|
27
|
+
if (!res.ok) return null;
|
|
28
|
+
return (await res.json()).username;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const run = async () => {
|
|
32
|
+
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
|
|
33
|
+
console.log(`包 ${pkg.name}@${pkg.version} → ${REGISTRY}`);
|
|
34
|
+
|
|
35
|
+
// 1) 凭据检查
|
|
36
|
+
let user = null;
|
|
37
|
+
if (NPM_TOKEN) {
|
|
38
|
+
user = await whoami(NPM_TOKEN);
|
|
39
|
+
console.log(`NPM_TOKEN 校验: ${user ? '✅ 有效(账号 ' + user + ')' : '❌ 无效/已撤销'}`);
|
|
40
|
+
if (!user) process.exit(1);
|
|
41
|
+
} else {
|
|
42
|
+
console.log(`未找到 NPM_TOKEN(可写入 ${CRED_FILES.env} 或 ${CRED_FILES.npm})`);
|
|
43
|
+
user = await whoami(''); // 回退系统 ~/.npmrc
|
|
44
|
+
console.log(`系统 npm 登录态: ${user ? '✅ ' + user : '❌ 未登录/已失效'}`);
|
|
45
|
+
if (!user && !otp) { console.error('既无 NPM_TOKEN 也无有效登录态 → 无法发布'); process.exit(1); }
|
|
46
|
+
}
|
|
47
|
+
if (has('--check')) return;
|
|
48
|
+
|
|
49
|
+
// 2) 包内容守卫(硬门槛)
|
|
50
|
+
if (!has('--skip-guard')) {
|
|
51
|
+
console.log('包内容守卫...');
|
|
52
|
+
execFileSync('node', ['scripts/check-package.mjs'], { stdio: 'inherit' });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 3) 临时 userconfig(仅当使用 NPM_TOKEN)
|
|
56
|
+
let cfgPath = '';
|
|
57
|
+
if (NPM_TOKEN) {
|
|
58
|
+
cfgPath = path.join(ROOT, 'internal', '.npmrc');
|
|
59
|
+
fs.writeFileSync(cfgPath, `registry=${REGISTRY}\n//registry.npmjs.org/:_authToken=\${NPM_TOKEN}\n`);
|
|
60
|
+
fs.chmodSync(cfgPath, 0o600);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 4) 发布
|
|
64
|
+
const args = ['publish', '--registry', REGISTRY];
|
|
65
|
+
if (cfgPath) args.push('--userconfig', cfgPath);
|
|
66
|
+
if (otp) args.push('--otp', String(otp));
|
|
67
|
+
if (has('--dry-run')) args.push('--dry-run');
|
|
68
|
+
console.log('执行: npm ' + args.join(' '));
|
|
69
|
+
const r = spawnSync('npm', args, {
|
|
70
|
+
stdio: 'inherit',
|
|
71
|
+
env: { ...process.env, ...(NPM_TOKEN ? { NPM_TOKEN } : {}), npm_config_cache: path.join(ROOT, '.npm-cache') },
|
|
72
|
+
});
|
|
73
|
+
if (cfgPath) { try { fs.unlinkSync(cfgPath); } catch { /* ignore */ } }
|
|
74
|
+
try { fs.rmSync(path.join(ROOT, '.npm-cache'), { recursive: true, force: true }); } catch { /* ignore */ }
|
|
75
|
+
if (r.status !== 0) { console.error('❌ 发布失败(退出码 ' + r.status + ')'); process.exit(r.status || 1); }
|
|
76
|
+
console.log('✅ 发布完成');
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
run().catch(e => { console.error('执行失败:', e.message); process.exit(1); });
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# 发版前自检(本地硬门槛):内容干净 + 测试通过 + 版本一致
|
|
3
|
+
# 用法:./scripts/preflight-release.sh [期望版本号]
|
|
4
|
+
set -euo pipefail
|
|
5
|
+
cd "$(dirname "$0")/.."
|
|
6
|
+
|
|
7
|
+
echo "1/5 仓库边界守卫(公开区不得含内部内容;打包区须为公开区子集)..."
|
|
8
|
+
node scripts/check-boundary.mjs
|
|
9
|
+
|
|
10
|
+
echo "2/5 包内容守卫(文件名 + 内容级扫描,拒绝任何运行时数据/密钥)..."
|
|
11
|
+
node scripts/check-package.mjs
|
|
12
|
+
|
|
13
|
+
echo "3/5 类型检查..."
|
|
14
|
+
npx tsc --noEmit
|
|
15
|
+
(cd server && npx tsc --noEmit)
|
|
16
|
+
|
|
17
|
+
echo "4/5 全量测试..."
|
|
18
|
+
npx vitest run --reporter=dot >/tmp/gw-preflight-test.log 2>&1 || { echo "✗ 测试未通过,见 /tmp/gw-preflight-test.log"; tail -20 /tmp/gw-preflight-test.log; exit 1; }
|
|
19
|
+
grep -E "Test Files|Tests " /tmp/gw-preflight-test.log | tail -2
|
|
20
|
+
|
|
21
|
+
echo "5/5 版本一致性..."
|
|
22
|
+
V=$(node -e "console.log(require('./package.json').version)")
|
|
23
|
+
for f in server/package.json packages/guanwei-api/package.json package-lock.json server/package-lock.json; do
|
|
24
|
+
Vf=$(node -e "const d=require('./$f');console.log(d.version||d.packages?.['']?.version||'?')")
|
|
25
|
+
[ "$Vf" = "$V" ] || { echo "✗ 版本漂移:$f = $Vf,根 = $V"; exit 1; }
|
|
26
|
+
done
|
|
27
|
+
grep -q "version: '$V'" packages/guanwei-api/src/mcp.ts || echo "⚠️ MCP serverInfo.version 未同步到 $V"
|
|
28
|
+
if [ -n "${1:-}" ] && [ "$1" != "$V" ]; then echo "✗ 参数版本 $1 ≠ package.json $V"; exit 1; fi
|
|
29
|
+
echo "✅ 自检通过:v$V 可发布"
|
package/scripts/release.sh
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
# 用法:./scripts/release.sh 1.1.2 仅 git 发版(tag 触发 CI/Release/镜像)
|
|
5
5
|
# ./scripts/release.sh 1.1.2 --npm 额外发布 npm 包(需先配置 npm 登录)
|
|
6
6
|
# 要求:工作区无未提交改动;CHANGELOG.md 已写好本次版本条目
|
|
7
|
-
# 详见(本地文档,不入库):docs/RELEASE-SOP.md(发版 SOP 详情/踩坑记录)
|
|
7
|
+
# 详见(本地文档,不入库):internal/docs/RELEASE-SOP.md(发版 SOP 详情/踩坑记录)
|
|
8
8
|
# ============================================================
|
|
9
9
|
set -euo pipefail
|
|
10
10
|
cd "$(dirname "$0")/.."
|
|
@@ -54,7 +54,12 @@ fi
|
|
|
54
54
|
|
|
55
55
|
echo "3️⃣ 提交 + 打 tag + 推送 git"
|
|
56
56
|
git add package.json package-lock.json server/package.json server/package-lock.json CHANGELOG.md
|
|
57
|
-
|
|
57
|
+
# 版本号/CHANGELOG 已在之前提交中就位时,此处无暂存差异——跳过提交,避免 set -e 中断发版
|
|
58
|
+
if git diff --cached --quiet; then
|
|
59
|
+
echo " ℹ️ 版本文件无变化(已在历史提交中就位),跳过版本提交"
|
|
60
|
+
else
|
|
61
|
+
git commit -m "chore: 版本升级 ${NEW_VER}"
|
|
62
|
+
fi
|
|
58
63
|
# 提交后再次校验:确保 tag 一定落在版本号已落地的 commit 上
|
|
59
64
|
HEAD_VER="$(node -e "console.log(require('./package.json').version)")"
|
|
60
65
|
if [[ "$HEAD_VER" != "$NEW_VER" ]]; then
|
|
@@ -70,7 +75,7 @@ echo " ✅ 已推送——CI / Release / 镜像 / Pages 将自动执行"
|
|
|
70
75
|
if [[ "$DO_NPM" == "1" ]]; then
|
|
71
76
|
echo "4️⃣ 发布 npm(registry 必须是官方源,本机默认 npmmirror 需显式指定)..."
|
|
72
77
|
# 用本地缓存避免 ~/.npm 写入受限;发布失败不中断后续提示
|
|
73
|
-
npm publish --registry https://registry.npmjs.org --cache ./.npm-cache || echo " ⚠️ npm publish 失败——按 docs/RELEASE-SOP.md 第四节排查(2FA/权限/registry)"
|
|
78
|
+
npm publish --registry https://registry.npmjs.org --cache ./.npm-cache || echo " ⚠️ npm publish 失败——按 internal/docs/RELEASE-SOP.md 第四节排查(2FA/权限/registry)"
|
|
74
79
|
rm -rf .npm-cache
|
|
75
80
|
echo "5️⃣ 验证线上版本..."
|
|
76
81
|
npm view guanwei version --registry https://registry.npmjs.org --cache ./.npm-cache 2>/dev/null || true
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "guanwei-server",
|
|
3
|
+
"version": "1.3.4",
|
|
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,38 @@ 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 } from './services/dataDir.js';
|
|
49
47
|
|
|
50
48
|
const app = express();
|
|
51
49
|
const PORT = process.env.PORT || 3018;
|
|
50
|
+
// 默认只绑本机(个人自托管场景);容器/局域网部署用 HOST=0.0.0.0 显式放开
|
|
51
|
+
const HOST = process.env.HOST || '127.0.0.1';
|
|
52
|
+
// 反代(nginx/Docker)后取真实客户端 IP:否则限流键恒为网关地址,退化为全站单桶(2026-09 修 P1-2)
|
|
53
|
+
app.set('trust proxy', 'loopback, linklocal, uniquelocal');
|
|
52
54
|
|
|
53
|
-
|
|
55
|
+
// CORS:默认仅同源/本机(原 `cors()` 全开,配合匿名占位账号放行可被任意站点跨源读取,2026-09 修 P2-1)
|
|
56
|
+
// 需要放开时设 GUANWEI_ALLOWED_ORIGINS=https://a.com,https://b.com
|
|
57
|
+
const ALLOWED = (process.env.GUANWEI_ALLOWED_ORIGINS || '').split(',').map(x => x.trim()).filter(Boolean);
|
|
58
|
+
app.use(cors({
|
|
59
|
+
origin(origin: string | undefined, cb: (e: Error | null, ok?: boolean) => void) {
|
|
60
|
+
if (!origin) return cb(null, true); // 同源/服务端调用
|
|
61
|
+
if (ALLOWED.length === 0) {
|
|
62
|
+
const local = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(origin);
|
|
63
|
+
return cb(null, local); // 未配置 → 仅本机前端
|
|
64
|
+
}
|
|
65
|
+
return cb(null, ALLOWED.includes(origin));
|
|
66
|
+
},
|
|
67
|
+
}));
|
|
54
68
|
app.use(express.json());
|
|
55
69
|
|
|
56
|
-
// ─── 限流(防 BYOK Key 被刷爆 / 无限建占刷库):per-IP 令牌桶 ───
|
|
70
|
+
// ─── 限流(防 BYOK Key 被刷爆 / 无限建占刷库):per-IP + per-路由组 令牌桶 ───
|
|
57
71
|
// /api/ai:每分钟 30 次(AI 烧 token,重点防护)
|
|
58
72
|
// /api/divine 写:每分钟 60 次(防无限建占刷 SQLite)
|
|
59
|
-
|
|
60
|
-
|
|
73
|
+
// 2026-09 修 P1-1:桶键含路由组——原实现单 Map 仅按 IP 计数,
|
|
74
|
+
// 用户连续起占 31 次会把首个 AI 解读请求顶成 429(跨路由互相误伤)
|
|
75
|
+
const RATE_ENV = (k: string, d: number) => Number(process.env[k] || d);
|
|
76
|
+
const AI_RATE_LIMIT = { windowMs: 60_000, max: RATE_ENV('GUANWEI_RATE_AI', 30) };
|
|
77
|
+
const DIVINE_RATE_LIMIT = { windowMs: 60_000, max: RATE_ENV('GUANWEI_RATE_DIVINE', 60) };
|
|
61
78
|
const hits = new Map<string, { count: number; resetAt: number }>();
|
|
62
79
|
// 定期清理过期桶(防内存泄漏,M-NEW3)
|
|
63
80
|
setInterval(() => {
|
|
@@ -65,13 +82,14 @@ setInterval(() => {
|
|
|
65
82
|
for (const [k, v] of hits) if (now > v.resetAt) hits.delete(k);
|
|
66
83
|
}, 10 * 60 * 1000).unref();
|
|
67
84
|
|
|
68
|
-
function rateLimit(limit: { windowMs: number; max: number }) {
|
|
85
|
+
function rateLimit(group: string, limit: { windowMs: number; max: number }) {
|
|
69
86
|
return (req: any, res: any, next: any) => {
|
|
70
87
|
const ip = req.ip || req.socket.remoteAddress || 'unknown';
|
|
88
|
+
const key = group + ':' + ip;
|
|
71
89
|
const now = Date.now();
|
|
72
|
-
const rec = hits.get(
|
|
90
|
+
const rec = hits.get(key);
|
|
73
91
|
if (!rec || now > rec.resetAt) {
|
|
74
|
-
hits.set(
|
|
92
|
+
hits.set(key, { count: 1, resetAt: now + limit.windowMs });
|
|
75
93
|
return next();
|
|
76
94
|
}
|
|
77
95
|
rec.count++;
|
|
@@ -81,11 +99,20 @@ function rateLimit(limit: { windowMs: number; max: number }) {
|
|
|
81
99
|
next();
|
|
82
100
|
};
|
|
83
101
|
}
|
|
84
|
-
app.use('/api/ai', rateLimit(AI_RATE_LIMIT));
|
|
102
|
+
app.use('/api/ai', rateLimit('ai', AI_RATE_LIMIT));
|
|
85
103
|
app.use('/api/divine', (req, res, next) => {
|
|
86
104
|
if (req.method === 'GET') return next(); // 读操作不限制
|
|
87
|
-
return rateLimit(DIVINE_RATE_LIMIT)(req, res, next);
|
|
105
|
+
return rateLimit('divine', DIVINE_RATE_LIMIT)(req, res, next);
|
|
88
106
|
});
|
|
107
|
+
// 登录/注册:更严阈值(防口令爆破与脚本化抢注,2026-09 修 P1-3)
|
|
108
|
+
const AUTH_RATE_LIMIT = { windowMs: 60_000, max: RATE_ENV('GUANWEI_RATE_AUTH', 10) };
|
|
109
|
+
const GENERIC_LIMIT = { windowMs: 60_000, max: RATE_ENV('GUANWEI_RATE_GENERIC', 120) };
|
|
110
|
+
app.use('/api/users/login', rateLimit('login', AUTH_RATE_LIMIT));
|
|
111
|
+
app.use('/api/users/register', rateLimit('register', AUTH_RATE_LIMIT));
|
|
112
|
+
app.use('/api/users', rateLimit('users', GENERIC_LIMIT));
|
|
113
|
+
// 其余匿名计算端点(原不在任何桶内 → 可被无上限刷,2026-09 修 P0-5/P2-7)
|
|
114
|
+
app.use('/api/tarot', rateLimit('tarot', GENERIC_LIMIT));
|
|
115
|
+
app.use('/api/hour', rateLimit('hour', GENERIC_LIMIT));
|
|
89
116
|
|
|
90
117
|
// 请求日志(联调排查用)
|
|
91
118
|
app.use((req, res, next) => {
|
|
@@ -106,7 +133,10 @@ app.get('/api', (_req, res) => {
|
|
|
106
133
|
});
|
|
107
134
|
|
|
108
135
|
app.get('/api/health', (_req, res) => {
|
|
109
|
-
|
|
136
|
+
// 轻量自检:数据目录可写 + LLM 配置状态(不含敏感值)
|
|
137
|
+
let dataDirOk = true;
|
|
138
|
+
try { fs.accessSync(DATA_DIR, fs.constants.W_OK); } catch { dataDirOk = false; }
|
|
139
|
+
res.json({ status: dataDirOk ? 'ok' : 'degraded', dataDir: dataDirOk ? 'ok' : 'readonly', timestamp: Date.now() });
|
|
110
140
|
});
|
|
111
141
|
|
|
112
142
|
app.use('/api/tarot', tarotRouter);
|
|
@@ -115,7 +145,19 @@ app.use('/api/users', usersRouter);
|
|
|
115
145
|
app.use('/api/divine', divineRouter);
|
|
116
146
|
app.use('/api', hourRouter);
|
|
117
147
|
|
|
118
|
-
|
|
148
|
+
// 统一错误处理(2026-09 修 P1-1):JSON 解析失败/未捕获异常一律返回 JSON,
|
|
149
|
+
// 且不把堆栈与绝对路径回给客户端(原实现由 Express 默认处理器返回 HTML 堆栈页)
|
|
150
|
+
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
|
151
|
+
const status = err?.status || err?.statusCode || 500;
|
|
152
|
+
const isJsonErr = err?.type === 'entity.parse.failed';
|
|
153
|
+
if (status >= 500) console.error('[server] 未处理异常:', err?.message || err);
|
|
154
|
+
res.status(status).json({
|
|
155
|
+
error: isJsonErr ? 'BAD_JSON' : (err?.code || 'INTERNAL'),
|
|
156
|
+
message: isJsonErr ? '请求体不是合法 JSON' : (status >= 500 ? '服务暂时不可用' : (err?.message || '请求有误')),
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
app.listen(Number(PORT), HOST, () => {
|
|
119
161
|
console.log(`
|
|
120
162
|
🎴 观微后端服务已启动
|
|
121
163
|
|
package/server/src/routes/ai.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// AI 解读路由:/api/ai/interpret(非流式)+ /api/ai/interpret/stream(SSE)
|
|
2
2
|
import { Router } from 'express';
|
|
3
|
-
import
|
|
3
|
+
import { resolveTokenUser, allowAnonymous } from '../services/auth.js';
|
|
4
|
+
import { readUsersDb } from '../services/usersDb.js';
|
|
4
5
|
import path from 'path';
|
|
5
|
-
import crypto from 'crypto';
|
|
6
6
|
import { fileURLToPath } from 'url';
|
|
7
7
|
import { buildMessages, buildReportMessages, buildStep1Messages, buildStep2Messages } from '../services/promptBuilder.js';
|
|
8
8
|
import { chatOnce, chatStream, activeProvider, providerStatus, lastFinishReason } from '../services/llmProvider.js';
|
|
@@ -13,29 +13,23 @@ import { MINGPAN_TEMPLATE } from '../services/promptBuilder.js';
|
|
|
13
13
|
|
|
14
14
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
15
15
|
|
|
16
|
-
// 请求 token →
|
|
16
|
+
// 请求 token → 用户名(统一走 services/auth:含 tokenExpires 校验,2026-09 修 P1-1)
|
|
17
17
|
function authedUsername(req: any): string | null {
|
|
18
|
-
|
|
19
|
-
if (!tk) return null;
|
|
20
|
-
try {
|
|
21
|
-
const db = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'data', 'db.json'), 'utf-8'));
|
|
22
|
-
const user = db.users.find((u: any) => u.token && u.token.length === tk.length && crypto.timingSafeEqual(Buffer.from(u.token), Buffer.from(tk)));
|
|
23
|
-
return user ? user.username : null;
|
|
24
|
-
} catch { return null; }
|
|
18
|
+
return resolveTokenUser(req)?.username ?? null;
|
|
25
19
|
}
|
|
26
20
|
|
|
27
21
|
|
|
28
|
-
//
|
|
22
|
+
// 解读归属校验:正式账号须本人 token;占位账号仅限「可信内网来源」无 token 访问
|
|
23
|
+
// (2026-09 修 P0-2:原实现 catch 分支 fail-open,读库失败即放行,配合无鉴权建档可跨用户读档案/耗 AI 额度)
|
|
29
24
|
function canReadChart(req: any, recUsername: string, fallbackUsername: string): boolean {
|
|
30
25
|
const authed = authedUsername(req);
|
|
31
26
|
if (authed) return authed === recUsername;
|
|
32
|
-
//
|
|
33
|
-
if (fallbackUsername && fallbackUsername !== recUsername) return false;
|
|
27
|
+
if (!allowAnonymous(req)) return false; // 公网来源必须带 token
|
|
28
|
+
if (fallbackUsername && fallbackUsername !== recUsername) return false; // 不得自报他人名号
|
|
34
29
|
try {
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
} catch { return true; }
|
|
30
|
+
const user = readUsersDb().users.find((u: any) => u.username === recUsername);
|
|
31
|
+
return !user || !user.passHash; // 占位账号放行(内网)
|
|
32
|
+
} catch { return false; } // 读库失败一律拒绝(不再 fail-open)
|
|
39
33
|
}
|
|
40
34
|
|
|
41
35
|
// 非流式:token 优先归属
|
|
@@ -2,30 +2,38 @@
|
|
|
2
2
|
import { Router } from 'express';
|
|
3
3
|
import { chartCalc } from '../../../shared/core/engine/chart.js';
|
|
4
4
|
import { createDivination, listDivinations, getDivination, deleteDivination } from '../services/divineStore.js';
|
|
5
|
+
import { resolveTokenUser, allowAnonymous } from '../services/auth.js';
|
|
6
|
+
import { readUsersDb, withUsersDb } from '../services/usersDb.js';
|
|
5
7
|
import fs from 'fs';
|
|
6
8
|
import path from 'path';
|
|
7
9
|
import crypto from 'crypto';
|
|
8
10
|
import { fileURLToPath } from 'url';
|
|
9
11
|
|
|
10
12
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
-
|
|
13
|
+
// 用户档案库路径与读写统一由 services/usersDb 提供(默认 server/data/db.json,
|
|
14
|
+
// 可用 GUANWEI_USERS_DB / GUANWEI_DATA_DIR 覆盖;原子写 + 进程内串行锁)
|
|
15
|
+
|
|
16
|
+
// 用户库读写统一走 services/usersDb(进程内串行锁 + 临时文件原子替换)
|
|
17
|
+
function loadUsersDb(): any { return readUsersDb(); }
|
|
12
18
|
|
|
13
19
|
const router = Router();
|
|
14
20
|
const MINGPAN_ARTS = ['bazi', 'ziwei', 'astrology'];
|
|
15
21
|
|
|
16
|
-
// 宽松建档:前端本地注册的用户在此同步建档(与 users.ts upsert
|
|
17
|
-
// 返回 null=失败;否则 { existed
|
|
18
|
-
|
|
22
|
+
// 宽松建档:前端本地注册的用户在此同步建档(与 users.ts upsert 同策略;新建时发 claimToken 防抢占)
|
|
23
|
+
// 返回 null=失败;否则 { existed, claimToken }
|
|
24
|
+
// 安全(2026-09 修 P0-1):claimToken 只在「本次新建」时返回,绝不回吐已存在账号的 token——
|
|
25
|
+
// 否则任何人只要知道 username 即可取走占位账号凭据并 register 抢占(含档案/起占历史)。
|
|
26
|
+
async function ensureUser(username: string): Promise<{ existed: boolean; claimToken: string } | null> {
|
|
19
27
|
if (!username || typeof username !== 'string') return null;
|
|
20
28
|
try {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
+
return await withUsersDb((db) => {
|
|
30
|
+
const exist = db.users.find((u: any) => u.username === username);
|
|
31
|
+
if (exist) return { existed: true, claimToken: '' }; // 已存在账号绝不回吐 token(P0-1)
|
|
32
|
+
const token = crypto.randomBytes(32).toString('hex');
|
|
33
|
+
db.users.push({ username, passHash: '', createdAt: Date.now(), profile: {}, samples: [], records: [], token, tokenExpires: Date.now() + 30 * 24 * 3600 * 1000 });
|
|
34
|
+
console.log('[divine] 自动建档:', username);
|
|
35
|
+
return { existed: false, claimToken: token };
|
|
36
|
+
});
|
|
29
37
|
} catch (e: any) {
|
|
30
38
|
console.error('[divine] ensureUser 异常:', e?.message || e);
|
|
31
39
|
return null;
|
|
@@ -34,13 +42,7 @@ function ensureUser(username: string): { existed: boolean; claimToken: string }
|
|
|
34
42
|
|
|
35
43
|
// 请求携带的 token → 对应用户名(有 token 则以其为准,堵「query/body 自报 username」越权)
|
|
36
44
|
function authedUsername(req: any): string | null {
|
|
37
|
-
|
|
38
|
-
if (!tk) return null;
|
|
39
|
-
try {
|
|
40
|
-
const db = JSON.parse(fs.readFileSync(DB_FILE, 'utf-8'));
|
|
41
|
-
const user = db.users.find((u: any) => u.token && u.token.length === tk.length && crypto.timingSafeEqual(Buffer.from(u.token), Buffer.from(tk)));
|
|
42
|
-
return user ? user.username : null;
|
|
43
|
-
} catch { return null; }
|
|
45
|
+
return resolveTokenUser(req)?.username ?? null;
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
// 归属校验统一入口:
|
|
@@ -51,12 +53,16 @@ function authedUsername(req: any): string | null {
|
|
|
51
53
|
// 返回 { owner, isPlaceholder };null = 校验失败(已 res 响应)
|
|
52
54
|
function resolveOwner(req: any, res: any, fallbackUsername: string): { owner: string; isPlaceholder: boolean } | null {
|
|
53
55
|
const authed = authedUsername(req);
|
|
56
|
+
// 匿名来源限制(P0-3):公网来源必须持 token;仅回环/内网允许无 token 的本地流程
|
|
57
|
+
if (!authed && !allowAnonymous(req)) {
|
|
58
|
+
res.status(401).json({ error: 'AUTH_REQUIRED', message: '请先入馆(登录)后操作(公网访问不开放匿名起占)' });
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
54
61
|
let owner = authed || String(fallbackUsername || '');
|
|
55
62
|
if (!owner) { res.status(401).json({ error: 'UNAUTHORIZED', message: '请先入馆(登录)' }); return null; }
|
|
56
63
|
let user: any = null;
|
|
57
64
|
try {
|
|
58
|
-
|
|
59
|
-
user = db.users.find((u: any) => u.username === owner) || null;
|
|
65
|
+
user = loadUsersDb().users.find((u: any) => u.username === owner) || null;
|
|
60
66
|
} catch { /* 读失败按占位处理 */ }
|
|
61
67
|
const isPlaceholder = !user || !user.passHash;
|
|
62
68
|
if (!authed && !isPlaceholder) {
|
|
@@ -68,16 +74,16 @@ function resolveOwner(req: any, res: any, fallbackUsername: string): { owner: st
|
|
|
68
74
|
}
|
|
69
75
|
|
|
70
76
|
// POST /api/divine —— 起占入库
|
|
71
|
-
router.post('/', (req, res) => {
|
|
77
|
+
router.post('/', async (req, res) => {
|
|
72
78
|
const { artId, inputs, profile, question, username, profileId } = req.body || {};
|
|
73
79
|
const resolved = resolveOwner(req, res, String(username || ''));
|
|
74
80
|
if (!resolved) return;
|
|
75
81
|
const owner = resolved.owner;
|
|
76
|
-
const ensured = ensureUser(owner);
|
|
82
|
+
const ensured = await ensureUser(owner);
|
|
77
83
|
if (!ensured) {
|
|
78
84
|
return res.status(401).json({ error: 'UNAUTHORIZED', message: '请先入馆(登录)再起占' });
|
|
79
85
|
}
|
|
80
|
-
const claimToken = ensured.claimToken; //
|
|
86
|
+
const claimToken = ensured.claimToken; // 仅「本次新建」时非空(P0-1)
|
|
81
87
|
if (!artId || !inputs) return res.status(400).json({ error: '缺少必要参数' });
|
|
82
88
|
|
|
83
89
|
let resultRaw: unknown;
|
|
@@ -101,19 +107,19 @@ router.post('/', (req, res) => {
|
|
|
101
107
|
const authed = authedUsername(req);
|
|
102
108
|
res.json({
|
|
103
109
|
ok: true, divineId: rec.id, resultRaw, display: rec.display,
|
|
104
|
-
// L-NEW1
|
|
105
|
-
claimToken: authed ?
|
|
110
|
+
// L-NEW1:仅未鉴权 + 本次新建占位账号时返回 claimToken(前端保存后注册可升级);已存在账号一律不回吐
|
|
111
|
+
claimToken: (!authed && !ensured.existed && claimToken) ? claimToken : undefined,
|
|
106
112
|
});
|
|
107
113
|
});
|
|
108
114
|
|
|
109
115
|
// GET /api/divine?username=&page=&pageSize= —— 占卜历史(H-NEW2:正式账号须本人 token)
|
|
110
|
-
router.get('/', (req, res) => {
|
|
116
|
+
router.get('/', async (req, res) => {
|
|
111
117
|
const username = String(req.query.username || '');
|
|
112
118
|
const page = Number(req.query.page || 1);
|
|
113
119
|
const pageSize = Number(req.query.pageSize || 20);
|
|
114
120
|
const resolved = resolveOwner(req, res, username);
|
|
115
121
|
if (!resolved) return;
|
|
116
|
-
if (!ensureUser(resolved.owner)) return res.status(401).json({ error: 'UNAUTHORIZED' });
|
|
122
|
+
if (!(await ensureUser(resolved.owner))) return res.status(401).json({ error: 'UNAUTHORIZED' });
|
|
117
123
|
res.json(listDivinations(resolved.owner, page, pageSize, String(req.query.profileId || '')));
|
|
118
124
|
});
|
|
119
125
|
|
|
@@ -124,7 +130,8 @@ router.get('/:id', (req, res) => {
|
|
|
124
130
|
// 归属校验:token 优先;无 token 仅占位账号可经 query username 访问
|
|
125
131
|
const resolved = resolveOwner(req, res, String(req.query.username || ''));
|
|
126
132
|
if (!resolved) return;
|
|
127
|
-
|
|
133
|
+
// 归属不符统一返回 404(原 403/404 差异可枚举 divineId 存在性,2026-09 修 P1-7)
|
|
134
|
+
if (resolved.owner !== rec.username) return res.status(404).json({ error: 'DIVINE_NOT_FOUND' });
|
|
128
135
|
res.json({ ...rec, resultRaw: rec.resultRaw, display: rec.display, report: rec.report || null });
|
|
129
136
|
});
|
|
130
137
|
|
|
@@ -12,6 +12,10 @@ router.post('/hour-infer', (req, res) => {
|
|
|
12
12
|
if (!Array.isArray(events) || events.length === 0) {
|
|
13
13
|
return res.status(400).json({ error: '请至少提供一条关键人生事件' });
|
|
14
14
|
}
|
|
15
|
+
// 条数上限(2026-09 修 P2-7):每条事件会触发 12 候选 × baziCalc,原无上限可被放大
|
|
16
|
+
if (events.length > 50) {
|
|
17
|
+
return res.status(400).json({ error: 'TOO_MANY_EVENTS', message: '关键事件请控制在 50 条以内' });
|
|
18
|
+
}
|
|
15
19
|
const cleanEvents: HourInferEvent[] = events
|
|
16
20
|
.map((e: any) => ({
|
|
17
21
|
year: Number(e?.year),
|