guanwei 1.3.1 → 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/chartBrief.ts +10 -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 +20 -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 +55 -1
- package/shared/core/types.ts +10 -6
- 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-O2O9peYE.js +0 -102
- package/dist/assets/index-O2O9peYE.js.map +0 -1
- package/server/src/data/db.json +0 -7016
- package/server/src/data/guanwei.db +0 -0
|
@@ -21,7 +21,12 @@ router.post('/draw', (req, res) => {
|
|
|
21
21
|
let spread: Spread | undefined;
|
|
22
22
|
|
|
23
23
|
if (customSpread) {
|
|
24
|
-
|
|
24
|
+
// 自定义牌阵限长(2026-09 修 P0-5):原实现按 positions.length 无上限循环,可被 10 万项拖死
|
|
25
|
+
const pos = Array.isArray(customSpread?.positions) ? customSpread.positions : [];
|
|
26
|
+
if (pos.length === 0 || pos.length > 20) {
|
|
27
|
+
return res.status(400).json({ error: 'BAD_SPREAD', message: '自定义牌阵位数为 1-20' });
|
|
28
|
+
}
|
|
29
|
+
spread = { ...customSpread, positions: pos.slice(0, 20) } as Spread;
|
|
25
30
|
} else {
|
|
26
31
|
spread = defaultSpreads.find(s => s.id === spreadId);
|
|
27
32
|
}
|
|
@@ -41,10 +46,17 @@ router.post('/interpret', (req, res) => {
|
|
|
41
46
|
return res.status(400).json({ error: '缺少必要参数' });
|
|
42
47
|
}
|
|
43
48
|
|
|
49
|
+
// 抽牌/解读入参限长(同上)
|
|
50
|
+
if (!Array.isArray(cards) || cards.length === 0 || cards.length > 20) {
|
|
51
|
+
return res.status(400).json({ error: 'BAD_CARDS', message: '牌数需为 1-20' });
|
|
52
|
+
}
|
|
53
|
+
if (String(question).length > 300) {
|
|
54
|
+
return res.status(400).json({ error: 'BAD_QUESTION', message: '所问之事请控制在 300 字以内' });
|
|
55
|
+
}
|
|
44
56
|
const result = generateInterpretation(
|
|
45
|
-
cards,
|
|
46
|
-
spread,
|
|
47
|
-
question,
|
|
57
|
+
cards.slice(0, 20),
|
|
58
|
+
{ ...spread, positions: (spread.positions || []).slice(0, 20) },
|
|
59
|
+
String(question).slice(0, 300),
|
|
48
60
|
category as QuestionCategory
|
|
49
61
|
);
|
|
50
62
|
|
|
@@ -69,13 +81,22 @@ router.post('/interpret/stream', (req, res) => {
|
|
|
69
81
|
return res.status(400).json({ error: '缺少必要参数' });
|
|
70
82
|
}
|
|
71
83
|
|
|
84
|
+
// 抽牌/解读入参限长(同上)
|
|
85
|
+
if (!Array.isArray(cards) || cards.length === 0 || cards.length > 20) {
|
|
86
|
+
return res.status(400).json({ error: 'BAD_CARDS', message: '牌数需为 1-20' });
|
|
87
|
+
}
|
|
88
|
+
if (String(question).length > 300) {
|
|
89
|
+
return res.status(400).json({ error: 'BAD_QUESTION', message: '所问之事请控制在 300 字以内' });
|
|
90
|
+
}
|
|
72
91
|
const result = generateInterpretation(
|
|
73
|
-
cards,
|
|
74
|
-
spread,
|
|
75
|
-
question,
|
|
92
|
+
cards.slice(0, 20),
|
|
93
|
+
{ ...spread, positions: (spread.positions || []).slice(0, 20) },
|
|
94
|
+
String(question).slice(0, 300),
|
|
76
95
|
category as QuestionCategory
|
|
77
96
|
);
|
|
78
97
|
|
|
98
|
+
let closed = false;
|
|
99
|
+
req.on('close', () => { closed = true; }); // 客户端断开 → 停止逐字推送(修 P3-2)
|
|
79
100
|
res.setHeader('Content-Type', 'text/event-stream');
|
|
80
101
|
res.setHeader('Cache-Control', 'no-cache');
|
|
81
102
|
res.setHeader('Connection', 'keep-alive');
|
|
@@ -83,6 +104,7 @@ router.post('/interpret/stream', (req, res) => {
|
|
|
83
104
|
let sectionIndex = 0;
|
|
84
105
|
|
|
85
106
|
const sendSection = () => {
|
|
107
|
+
if (closed) return; // 客户端已断开:停止计时器链
|
|
86
108
|
if (sectionIndex >= result.sections.length) {
|
|
87
109
|
res.write('event: done\n');
|
|
88
110
|
res.write('data: [DONE]\n\n');
|
|
@@ -95,6 +117,7 @@ router.post('/interpret/stream', (req, res) => {
|
|
|
95
117
|
let charIndex = 0;
|
|
96
118
|
|
|
97
119
|
const typeChar = () => {
|
|
120
|
+
if (closed) return; // 同上
|
|
98
121
|
if (charIndex >= chars.length) {
|
|
99
122
|
sectionIndex++;
|
|
100
123
|
setTimeout(sendSection, 300);
|
|
@@ -4,9 +4,12 @@ import fs from 'fs';
|
|
|
4
4
|
import path from 'path';
|
|
5
5
|
import crypto from 'crypto';
|
|
6
6
|
import { fileURLToPath } from 'url';
|
|
7
|
+
import { USERS_DB } from '../services/dataDir.js';
|
|
8
|
+
import { readUsersDb, writeUsersDb, withUsersDb } from '../services/usersDb.js';
|
|
7
9
|
|
|
8
10
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
-
|
|
11
|
+
// 用户档案 JSON 库:默认 server/data/db.json(运行时目录,与源码分离,绝不入 npm 包)
|
|
12
|
+
const DB_FILE = USERS_DB;
|
|
10
13
|
|
|
11
14
|
function scryptAsync(pw: string, salt: Buffer, keylen: number): Promise<Buffer> {
|
|
12
15
|
return new Promise((resolve, reject) => {
|
|
@@ -29,14 +32,53 @@ interface DbUser {
|
|
|
29
32
|
|
|
30
33
|
interface Db { users: DbUser[] }
|
|
31
34
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
// 读写统一走 services/usersDb:原子写(临时文件 + rename),避免半截文件把全库读成空库
|
|
36
|
+
function loadDb(): Db { return readUsersDb() as Db; }
|
|
37
|
+
function saveDb(db: Db): void { writeUsersDb(db); }
|
|
38
|
+
|
|
39
|
+
// ─── 输入校验(2026-09 安全修复)───
|
|
40
|
+
// profile/records 原样入库会被后续注入 AI prompt 并落盘;此处白名单化 + 拒绝原型污染键
|
|
41
|
+
const DANGEROUS_KEYS = ['__proto__', 'constructor', 'prototype'];
|
|
36
42
|
|
|
37
|
-
function
|
|
38
|
-
|
|
39
|
-
|
|
43
|
+
function isSafeKey(k: string): boolean { return !DANGEROUS_KEYS.includes(k); }
|
|
44
|
+
function cleanString(v: unknown, max = 200): string {
|
|
45
|
+
return typeof v === 'string' ? v.slice(0, max) : '';
|
|
46
|
+
}
|
|
47
|
+
/** 出生档案白名单校验:仅保留已知字段,限制长度/数值范围 */
|
|
48
|
+
function sanitizeProfile(input: any): Record<string, unknown> | null {
|
|
49
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) return null;
|
|
50
|
+
const p = input as Record<string, any>;
|
|
51
|
+
const out: Record<string, unknown> = {};
|
|
52
|
+
if (typeof p.birthDate === 'string' && /^\d{4}-\d{1,2}-\d{1,2}$/.test(p.birthDate)) out.birthDate = p.birthDate.slice(0, 10);
|
|
53
|
+
if (typeof p.birthTime === 'string' && /^\d{1,2}:\d{2}$/.test(p.birthTime)) out.birthTime = p.birthTime.slice(0, 5);
|
|
54
|
+
if (Number.isInteger(p.birthHourIndex) && p.birthHourIndex >= -1 && p.birthHourIndex <= 11) out.birthHourIndex = p.birthHourIndex;
|
|
55
|
+
if (typeof p.birthTimeUnknown === 'boolean') out.birthTimeUnknown = p.birthTimeUnknown;
|
|
56
|
+
if (p.gender === '男' || p.gender === '女') out.gender = p.gender;
|
|
57
|
+
if (p.location && typeof p.location === 'object' && !Array.isArray(p.location)) {
|
|
58
|
+
const l = p.location as Record<string, any>;
|
|
59
|
+
const lng = Number(l.lng), lat = Number(l.lat);
|
|
60
|
+
if (Number.isFinite(lng) && lng >= -180 && lng <= 180 && Number.isFinite(lat) && lat >= -90 && lat <= 90) {
|
|
61
|
+
out.location = { province: cleanString(l.province, 40), city: cleanString(l.city, 40), district: cleanString(l.district, 40), lng, lat };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (Array.isArray(p.lifeEvents)) {
|
|
65
|
+
out.lifeEvents = p.lifeEvents.slice(0, 50)
|
|
66
|
+
.filter((e: any) => e && typeof e === 'object' && Number.isInteger(e.year) && e.year >= 1900 && e.year <= 2200)
|
|
67
|
+
.map((e: any) => ({ year: e.year, text: cleanString(e.text, 200) }));
|
|
68
|
+
}
|
|
69
|
+
for (const k of Object.keys(out)) if (!isSafeKey(k)) delete out[k];
|
|
70
|
+
if (JSON.stringify(out).length > 20000) return null;
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
/** 记录同步校验:整表覆盖须限条数与单条体积 */
|
|
74
|
+
function sanitizeRecords(input: any): Record<string, unknown>[] | null {
|
|
75
|
+
if (!Array.isArray(input)) return null;
|
|
76
|
+
if (input.length > 2000) return null;
|
|
77
|
+
for (const item of input) {
|
|
78
|
+
if (item && typeof item === 'object' && JSON.stringify(item).length > 20000) return null;
|
|
79
|
+
if (item && typeof item === 'object' && Object.keys(item).some(k => !isSafeKey(k))) return null;
|
|
80
|
+
}
|
|
81
|
+
return input;
|
|
40
82
|
}
|
|
41
83
|
|
|
42
84
|
// 密码哈希:scrypt(带随机盐,防彩虹表/暴力破解)。
|
|
@@ -100,50 +142,55 @@ router.post('/register', async (req, res) => {
|
|
|
100
142
|
const { username, password, profile } = req.body;
|
|
101
143
|
const name = String(username || '').trim();
|
|
102
144
|
if (name.length < 2) return res.status(400).json({ error: '名号至少二字' });
|
|
103
|
-
if (
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
145
|
+
if (name.length > 24) return res.status(400).json({ error: '名号至多二十四字' });
|
|
146
|
+
if (!password || String(password).length < 8) return res.status(400).json({ error: '密语至少八位' });
|
|
147
|
+
const cleanProfile = sanitizeProfile(profile) || undefined;
|
|
148
|
+
// 锁内完成读-改-写:scrypt 为异步,若跨 await 会让并发注册互相覆盖(实测 30 并发仅 1 落库)
|
|
149
|
+
const outcome = await withUsersDb(async (db) => {
|
|
150
|
+
const users = (db as any).users as DbUser[];
|
|
151
|
+
const existing = users.find(u => u.username === name);
|
|
152
|
+
if (existing) {
|
|
153
|
+
// 占位账号(自动建档、无密语):升级须持有建档时发放的 claimToken,防任意抢占
|
|
154
|
+
if (!existing.passHash) {
|
|
155
|
+
const claimToken = String(req.body.claimToken || '');
|
|
156
|
+
if (!tokenMatches(existing, claimToken)) {
|
|
157
|
+
return { status: 409, body: { error: 'ACCOUNT_CLAIMED', message: '此名号已被自动建档占用,需持有建档凭据方可注册(或更换名号)' } };
|
|
158
|
+
}
|
|
159
|
+
existing.passHash = await hashPassword(String(password));
|
|
160
|
+
existing.token = newToken();
|
|
161
|
+
existing.tokenExpires = Date.now() + TOKEN_TTL;
|
|
162
|
+
if (cleanProfile) existing.profile = { ...existing.profile, ...cleanProfile };
|
|
163
|
+
return { status: 200, body: { ok: true, upgraded: true, token: existing.token, user: { username: existing.username, profile: existing.profile, samples: existing.samples } } };
|
|
112
164
|
}
|
|
113
|
-
|
|
114
|
-
existing.token = newToken();
|
|
115
|
-
existing.tokenExpires = Date.now() + TOKEN_TTL;
|
|
116
|
-
if (profile) existing.profile = { ...existing.profile, ...profile };
|
|
117
|
-
saveDb(db);
|
|
118
|
-
return res.json({ ok: true, upgraded: true, token: existing.token, user: { username: existing.username, profile: existing.profile, samples: existing.samples } });
|
|
165
|
+
return { status: 400, body: { error: '此名号已有人用' } };
|
|
119
166
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
res.json({ ok: true, token: user.token, user: { username: user.username, profile: user.profile, samples: user.samples } });
|
|
167
|
+
const user: DbUser = { username: name, passHash: await hashPassword(String(password)), createdAt: Date.now(), profile: cleanProfile || {}, samples: [], records: [], token: newToken(), tokenExpires: Date.now() + TOKEN_TTL };
|
|
168
|
+
users.push(user);
|
|
169
|
+
return { status: 200, body: { ok: true, token: user.token, user: { username: user.username, profile: user.profile, samples: user.samples } } };
|
|
170
|
+
});
|
|
171
|
+
res.status(outcome.status).json(outcome.body);
|
|
126
172
|
});
|
|
127
173
|
|
|
128
174
|
// 登录
|
|
129
175
|
router.post('/login', async (req, res) => {
|
|
130
176
|
const { username, password } = req.body;
|
|
131
|
-
const
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
user.passHash
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
177
|
+
const name = String(username || '').trim();
|
|
178
|
+
const outcome = await withUsersDb(async (db) => {
|
|
179
|
+
const users = (db as any).users as DbUser[];
|
|
180
|
+
const user = users.find(u => u.username === name);
|
|
181
|
+
// 名号不存在也走一次等价 scrypt,避免时序差异泄露账号是否存在(P2-2)
|
|
182
|
+
const ok = await verifyPassword(String(password || ''), user ? user.passHash : 'scrypt$' + '0'.repeat(32) + '$' + '0'.repeat(128));
|
|
183
|
+
if (!user || !ok) return { status: 401, body: { error: '名号或密语未合' } };
|
|
184
|
+
if (user.passHash && !user.passHash.startsWith('scrypt$')) {
|
|
185
|
+
user.passHash = await hashPassword(String(password));
|
|
186
|
+
console.log(`[users] 密码哈希已升级为 scrypt: ${user.username}`);
|
|
187
|
+
}
|
|
188
|
+
// 登录成功 → 轮换 token(旧 token 即失效)
|
|
189
|
+
user.token = newToken();
|
|
190
|
+
user.tokenExpires = Date.now() + TOKEN_TTL;
|
|
191
|
+
return { status: 200, body: { ok: true, token: user.token, user: { username: user.username, profile: user.profile, samples: user.samples } } };
|
|
192
|
+
});
|
|
193
|
+
res.status(outcome.status).json(outcome.body);
|
|
147
194
|
});
|
|
148
195
|
|
|
149
196
|
// ─── 写接口鉴权中间件:云同步写操作须携带本人 token(堵「知道 username 即可写任意档案」)───
|
|
@@ -170,7 +217,9 @@ router.put('/:username/profile', requireOwner, (req, res) => {
|
|
|
170
217
|
const db = loadDb();
|
|
171
218
|
const user = db.users.find(u => u.username === req.params.username);
|
|
172
219
|
if (!user) return res.status(404).json({ error: '馆中无此人' });
|
|
173
|
-
|
|
220
|
+
const clean = sanitizeProfile(req.body.profile);
|
|
221
|
+
if (!clean) return res.status(400).json({ error: 'BAD_PROFILE', message: '档案格式不合法(字段白名单/长度/数值范围校验未通过)' });
|
|
222
|
+
user.profile = { ...user.profile, ...clean };
|
|
174
223
|
saveDb(db);
|
|
175
224
|
res.json({ ok: true, profile: user.profile });
|
|
176
225
|
});
|
|
@@ -180,7 +229,7 @@ router.post('/:username/samples', requireOwner, (req, res) => {
|
|
|
180
229
|
const db = loadDb();
|
|
181
230
|
const user = db.users.find(u => u.username === req.params.username);
|
|
182
231
|
if (!user) return res.status(404).json({ error: '馆中无此人' });
|
|
183
|
-
const sample = { id: 's' + Date.now(), name: String(req.body.name || '未名档案'), profile: req.body.profile || {} };
|
|
232
|
+
const sample = { id: 's' + Date.now(), name: String(req.body.name || '未名档案').slice(0, 40), profile: sanitizeProfile(req.body.profile) || {} };
|
|
184
233
|
user.samples.push(sample);
|
|
185
234
|
saveDb(db);
|
|
186
235
|
res.json({ ok: true, samples: user.samples });
|
|
@@ -200,7 +249,9 @@ router.put('/:username/records', requireOwner, (req, res) => {
|
|
|
200
249
|
const db = loadDb();
|
|
201
250
|
const user = db.users.find(u => u.username === req.params.username);
|
|
202
251
|
if (!user) return res.status(404).json({ error: '馆中无此人' });
|
|
203
|
-
|
|
252
|
+
const recs = sanitizeRecords(req.body.records);
|
|
253
|
+
if (!recs) return res.status(400).json({ error: 'BAD_RECORDS', message: '记录格式不合法(条数上限 2000 / 单条 20KB / 禁止危险键)' });
|
|
254
|
+
user.records = recs;
|
|
204
255
|
saveDb(db);
|
|
205
256
|
res.json({ ok: true, count: user.records.length });
|
|
206
257
|
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// 鉴权统一入口:token → 用户(含过期校验)、请求来源判定
|
|
2
|
+
//
|
|
3
|
+
// 背景(2026-09 安全审查):
|
|
4
|
+
// - 三处路由各自实现 authedUsername,口径漂移:ai.ts 漏校验 tokenExpires(过期 token 在 AI 链路永久可用)
|
|
5
|
+
// - 旧记录可能没有 tokenExpires 字段(1.3.3 前签发)→ 原逻辑视作「永不过期」;
|
|
6
|
+
// 这些 token 已随 npm 1.3.2 包公开泄漏,故一律按失效处理(用户重新登录即可获新 token)
|
|
7
|
+
import crypto from 'crypto';
|
|
8
|
+
import { readUsersDb } from './usersDb.js';
|
|
9
|
+
|
|
10
|
+
export interface TokenUser { username: string; user: any }
|
|
11
|
+
|
|
12
|
+
/** 请求携带的 token → 用户(无 token / 不匹配 / 已过期 → null) */
|
|
13
|
+
export function resolveTokenUser(req: any): TokenUser | null {
|
|
14
|
+
const tk = String(req.headers['x-guanwei-token'] || '');
|
|
15
|
+
if (!tk) return null;
|
|
16
|
+
try {
|
|
17
|
+
const db = readUsersDb();
|
|
18
|
+
const user = db.users.find((u: any) =>
|
|
19
|
+
u.token && u.token.length === tk.length && crypto.timingSafeEqual(Buffer.from(u.token), Buffer.from(tk)));
|
|
20
|
+
if (!user) return null;
|
|
21
|
+
// 过期即失效;无 tokenExpires 的旧记录(含已泄漏 token)同样视为失效
|
|
22
|
+
if (!user.tokenExpires || Date.now() > user.tokenExpires) return null;
|
|
23
|
+
return { username: user.username, user };
|
|
24
|
+
} catch { return null; }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 请求来源是否可信内网(回环 / RFC1918 / ULA):匿名建档与占位账号放行仅限此类来源 */
|
|
28
|
+
export function isTrustedOrigin(req: any): boolean {
|
|
29
|
+
const raw = String(req.ip || req.socket?.remoteAddress || '');
|
|
30
|
+
const ip = raw.replace(/^::ffff:/, '');
|
|
31
|
+
if (ip === '127.0.0.1' || ip === '::1') return true;
|
|
32
|
+
if (/^10\./.test(ip)) return true;
|
|
33
|
+
if (/^192\.168\./.test(ip)) return true;
|
|
34
|
+
if (/^172\.(1[6-9]|2\d|3[01])\./.test(ip)) return true;
|
|
35
|
+
if (/^f[cd][0-9a-f]{2}:/i.test(ip)) return true; // fc00::/7
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 显式放开匿名访问(公网部署且自担风险时设 GUANWEI_ALLOW_ANON=1) */
|
|
40
|
+
export function anonAllowed(): boolean {
|
|
41
|
+
return process.env.GUANWEI_ALLOW_ANON === '1';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 强制所有匿名访问携带 token(公网部署更严;GUANWEI_REQUIRE_TOKEN=1) */
|
|
45
|
+
export function requireTokenAlways(): boolean {
|
|
46
|
+
return process.env.GUANWEI_REQUIRE_TOKEN === '1';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 是否允许「无 token 的匿名操作」(建档/读占位账号) */
|
|
50
|
+
export function allowAnonymous(req: any): boolean {
|
|
51
|
+
if (requireTokenAlways()) return false;
|
|
52
|
+
return anonAllowed() || isTrustedOrigin(req);
|
|
53
|
+
}
|
|
@@ -61,6 +61,16 @@ function ziweiBrief(r: ZiweiResult): string {
|
|
|
61
61
|
palaceLines.push(PALACE_NAMES[i] + parts.join('|'));
|
|
62
62
|
}
|
|
63
63
|
L.push('【十二宫事实(解读六亲/宫位必须逐字引用,不得推算或编造)】' + palaceLines.join(';') + '。');
|
|
64
|
+
// 昼夜与有效亮度(修正反馈:太阳喜昼、太阴喜夜——AI 论日月亮度须用有效值)
|
|
65
|
+
if (r.dayNight) L.push('【昼夜】' + (r.dayNight === 'day' ? '昼生(太阳得时、太阴减力)' : '夜生(太阴得时、太阳减力)') + '——论太阳/太阴亮度以【有效亮度】为准。');
|
|
66
|
+
if (r.effBrightness) L.push('【有效亮度(昼夜调整后)】' + Object.entries(r.effBrightness).map(([s, b]) => s + b).join('、') + '。');
|
|
67
|
+
// 空宫借对宫(修正反馈:空宫须借对宫看,勿论作真空)
|
|
68
|
+
if (r.borrowedStars) {
|
|
69
|
+
const PALACE2 = ['命', '兄弟', '夫妻', '子女', '财帛', '疾厄', '迁移', '交友', '官禄', '田宅', '福德', '父母'];
|
|
70
|
+
const bl = Object.entries(r.borrowedStars).filter(([, v]) => v.length > 0)
|
|
71
|
+
.map(([k, v]) => PALACE2[+k] + '宫空借对宫' + [...new Set(v)].join('、')).join(';');
|
|
72
|
+
if (bl) L.push('【空宫借星(空宫须借对宫主星论之,非真空)】' + bl + '。');
|
|
73
|
+
}
|
|
64
74
|
if (r.geju?.length) L.push('【格局】' + r.geju.map(g => g.name + '(' + g.ji + ')').join('、') + '。');
|
|
65
75
|
if (r.dayun?.length) L.push('【大限】当前第' + ((r.curDayunIdx ?? 0) + 1) + '大限(' + r.dayun[r.curDayunIdx ?? 0].start + '-' + r.dayun[r.curDayunIdx ?? 0].end + '岁,行至' + DIZHI[r.dayun[r.curDayunIdx ?? 0].palaceIdx] + '宫)。');
|
|
66
76
|
L.push('【流年】' + (r.nominalAge ?? '') + '虚岁流年命宫落' + (r.liunianPalaceName || '') + '(' + DIZHI[r.liunianIdx ?? 0] + '),主星' + (r.liunianStars?.join('、') || '未临') + '。');
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// 运行时数据路径解析(用户档案 JSON / 占卜 SQLite / 测试库)
|
|
2
|
+
//
|
|
3
|
+
// 设计(2026-09 安全修复):
|
|
4
|
+
// - 默认目录 **server/data/**(与 server/src 源码分离)——原 server/src/data 会被
|
|
5
|
+
// package.json 的 files 白名单("server/src")强制打进 npm tarball,导致用户档案
|
|
6
|
+
// (含 token/passHash/出生信息)与占卜记录随包公开(1.3.2 及更早版本已发生)。
|
|
7
|
+
// - 兼容旧路径:首次访问某文件时,若旧路径 server/src/data/<name> 存在且新路径不存在,
|
|
8
|
+
// 自动复制一份(幂等,不删除旧文件,便于回滚)。
|
|
9
|
+
// - 环境变量优先:GUANWEI_DATA_DIR(目录)、GUANWEI_USERS_DB / GUANWEI_DB_FILE(单文件,测试隔离用)。
|
|
10
|
+
import fs from 'fs';
|
|
11
|
+
import os from 'os';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import { fileURLToPath } from 'url';
|
|
14
|
+
|
|
15
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 运行时数据目录:默认 **用户主目录** `~/.guanwei/data`(可用 GUANWEI_DATA_DIR 覆盖)。
|
|
19
|
+
*
|
|
20
|
+
* 为什么放在项目目录之外(2026-09 事故后的硬门槛):
|
|
21
|
+
* 原默认 server/src/data 在项目树内,npm 的 `files` 白名单一旦包含它就会被 publish 打包公开
|
|
22
|
+
* (1.1.1–1.3.2 全部版本因此泄漏用户档案与占卜记录)。移出项目树后,
|
|
23
|
+
* 即使白名单再次写错,npm/Docker 构建上下文也**物理上取不到**这些文件。
|
|
24
|
+
*/
|
|
25
|
+
/** 选择数据目录:env > 用户主目录(推荐)> 项目内 server/data(受限环境回退,仍受三重发布守卫保护) */
|
|
26
|
+
function pickDataDir(): string {
|
|
27
|
+
const envDir = process.env.GUANWEI_DATA_DIR;
|
|
28
|
+
if (envDir) return envDir;
|
|
29
|
+
const home = path.join(os.homedir(), '.guanwei', 'data');
|
|
30
|
+
try {
|
|
31
|
+
fs.mkdirSync(home, { recursive: true });
|
|
32
|
+
fs.accessSync(home, fs.constants.W_OK);
|
|
33
|
+
return home;
|
|
34
|
+
} catch {
|
|
35
|
+
const fallback = path.join(__dirname, '..', '..', 'data');
|
|
36
|
+
console.warn('[data] 用户主目录不可写,回退到项目内目录: ' + fallback + '(发布守卫仍会阻止其进入 npm 包)');
|
|
37
|
+
return fallback;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const DATA_DIR = pickDataDir();
|
|
42
|
+
/** 兼容迁移源(按序尝试):曾经使用过的项目内目录 */
|
|
43
|
+
const LEGACY_DIRS = [
|
|
44
|
+
path.join(__dirname, '..', '..', 'data'), // server/data(1.3.3 过渡路径)
|
|
45
|
+
path.join(__dirname, '..', 'data'), // server/src/data(1.1.1–1.3.2 旧路径)
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
/** 解析数据文件路径(含旧目录一次性迁移) */
|
|
49
|
+
export function dataFile(name: string): string {
|
|
50
|
+
const target = path.join(DATA_DIR, name);
|
|
51
|
+
try {
|
|
52
|
+
const legacy = LEGACY_DIRS.map(d => path.join(d, name)).find(p => fs.existsSync(p) && fs.statSync(p).size > 0);
|
|
53
|
+
const legacyOk = !!legacy;
|
|
54
|
+
const targetExists = fs.existsSync(target);
|
|
55
|
+
const targetSize = targetExists ? fs.statSync(target).size : 0;
|
|
56
|
+
let needFill = !targetExists || targetSize === 0;
|
|
57
|
+
// SQLite 库启发式:目标存在但显著小于旧库(如历史遗留的空壳库)→ 也迁移,避免真实记录被空库遮蔽
|
|
58
|
+
if (!needFill && /\.(db|sqlite|sqlite3)$/.test(name) && legacyOk && targetSize < fs.statSync(legacy!).size / 2) {
|
|
59
|
+
needFill = true;
|
|
60
|
+
}
|
|
61
|
+
if (legacyOk && needFill) {
|
|
62
|
+
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
63
|
+
if (targetExists && targetSize > 0) {
|
|
64
|
+
const bak = target + '.bak-' + Date.now();
|
|
65
|
+
fs.copyFileSync(target, bak); // 覆盖前备份,可人工回滚
|
|
66
|
+
console.log('[data] 目标库较小,已备份: ' + bak);
|
|
67
|
+
}
|
|
68
|
+
fs.copyFileSync(legacy!, target);
|
|
69
|
+
console.log('[data] 已从旧路径迁移: ' + legacy + ' → ' + target + ' (' + fs.statSync(target).size + ' bytes)');
|
|
70
|
+
}
|
|
71
|
+
} catch (e: any) {
|
|
72
|
+
console.error('[data] 迁移失败(将按新路径处理):', e?.message || e);
|
|
73
|
+
}
|
|
74
|
+
return target;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 用户档案库(JSON) */
|
|
78
|
+
export const USERS_DB = process.env.GUANWEI_USERS_DB || dataFile('db.json');
|
|
79
|
+
/** 占卜记录库(SQLite) */
|
|
80
|
+
export const DIVINE_DB = process.env.GUANWEI_DB_FILE || dataFile('guanwei.db');
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
// 占卜数据存储(SQLite):起占记录 / AI 报告 / 失败留档 / 占卜历史
|
|
2
2
|
// 依赖:node:sqlite(Node 22 内置,零依赖)
|
|
3
3
|
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
+
import { DIVINE_DB } from './dataDir.js';
|
|
4
5
|
import fs from 'fs';
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
5
7
|
import path from 'path';
|
|
6
8
|
import { fileURLToPath } from 'url';
|
|
7
9
|
|
|
8
10
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
-
const DB_FILE =
|
|
11
|
+
const DB_FILE = DIVINE_DB;
|
|
10
12
|
|
|
11
13
|
let db: DatabaseSync | null = null;
|
|
12
14
|
|
|
@@ -104,7 +106,8 @@ function toRecord(row: any): DivineRecord {
|
|
|
104
106
|
export function createDivination(args: CreateDivinationArgs): DivineRecord {
|
|
105
107
|
const d = getDb();
|
|
106
108
|
const now = Date.now();
|
|
107
|
-
|
|
109
|
+
// 原 Date.now()+Math.random() 可枚举(配合 403/404 差异可探测记录存在性)→ 改 CSPRNG UUID
|
|
110
|
+
const id = 'd_' + now + '_' + randomUUID();
|
|
108
111
|
const display = args.display !== undefined ? args.display : args.resultRaw;
|
|
109
112
|
d.prepare(`INSERT INTO divinations
|
|
110
113
|
(id, username, art_id, kind, question, profile_id, profile_json, params_json, result_raw_json, display_json, status, created_at, updated_at)
|
|
@@ -11,9 +11,9 @@ function extractKeywords(artId: string, resultRaw: unknown): string[] {
|
|
|
11
11
|
case 'bazi':
|
|
12
12
|
// 月令/格局/用神/调候/旺衰
|
|
13
13
|
if (r.monthZhi) kws.push('月令');
|
|
14
|
-
if (r.geju) kws.push('格局', String(r.geju));
|
|
15
|
-
if (r.yongshen) kws.push('用神', String(r.yongshen));
|
|
16
|
-
if (r.strength) kws.push(
|
|
14
|
+
if (r.geju) kws.push('格局', String(r.geju?.name || '')); // geju 为对象,须取名(原 String(obj) 恒为 [object Object])
|
|
15
|
+
if (r.yongshen) kws.push('用神', String(r.yongshen?.wx || r.yongshen?.shishen || ''));
|
|
16
|
+
if (r.strength) kws.push('旺衰', String(r.strength)); // 原三元两分支同值(死代码)
|
|
17
17
|
if (r.tiaohou) kws.push('调候');
|
|
18
18
|
break;
|
|
19
19
|
case 'liuyao':
|
|
@@ -72,7 +72,7 @@ async function callOpenAI(cfg: ProviderConfig, messages: ChatMessage[], stream:
|
|
|
72
72
|
|
|
73
73
|
async function callGoogle(cfg: ProviderConfig, messages: ChatMessage[], stream: boolean): Promise<Response> {
|
|
74
74
|
const model = process.env[cfg.modelEnv || ''] || cfg.defaultModel;
|
|
75
|
-
const endpoint = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent
|
|
75
|
+
const endpoint = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`;
|
|
76
76
|
// 合并 messages 为 Google 格式:system → systemInstruction,其余 → contents
|
|
77
77
|
const system = messages.filter(m => m.role === 'system').map(m => m.content).join('\n\n');
|
|
78
78
|
const contents = messages.filter(m => m.role !== 'system').map(m => ({
|
|
@@ -81,7 +81,8 @@ async function callGoogle(cfg: ProviderConfig, messages: ChatMessage[], stream:
|
|
|
81
81
|
}));
|
|
82
82
|
return fetch(endpoint, {
|
|
83
83
|
method: 'POST',
|
|
84
|
-
|
|
84
|
+
// API Key 用 header 传递(原 ?key= 会进入 URL、代理日志与错误对象)
|
|
85
|
+
headers: { 'Content-Type': 'application/json', 'x-goog-api-key': String(process.env[cfg.keyEnv] || '') },
|
|
85
86
|
body: JSON.stringify({
|
|
86
87
|
systemInstruction: system ? { parts: [{ text: system }] } : undefined,
|
|
87
88
|
contents,
|
|
@@ -99,7 +100,7 @@ async function withRetry<T>(fn: () => Promise<T>, label: string): Promise<T> {
|
|
|
99
100
|
return await fn();
|
|
100
101
|
} catch (e: any) {
|
|
101
102
|
lastErr = e;
|
|
102
|
-
if (!/AI_HTTP_(429|5\d\d
|
|
103
|
+
if (!/AI_HTTP_(429|5\d\d)/.test(e.message || '')) throw e; // 400 属请求体错误,重试必然再失败
|
|
103
104
|
if (attempt < 2) {
|
|
104
105
|
console.warn('[llm] ' + label + ' 第 ' + (attempt + 1) + ' 次失败(' + e.message + '),重试…');
|
|
105
106
|
await new Promise(r => setTimeout(r, 1500 * (attempt + 1)));
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// 用户库(JSON)读写:进程内串行锁 + 原子写
|
|
2
|
+
//
|
|
3
|
+
// 背景(2026-09 并发写缺陷):原 users.ts/divine.ts 各自 read-modify-write 整份 db.json,
|
|
4
|
+
// 且 load→改→save 之间跨 await(scrypt 哈希),并发请求互相覆盖——实测 30 个并发注册仅 1 个落库。
|
|
5
|
+
// 现统一走本模块:单进程内以 Promise 链串行化写操作,落盘用 临时文件 + rename 原子替换。
|
|
6
|
+
import fs from 'fs';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import { USERS_DB } from './dataDir.js';
|
|
9
|
+
|
|
10
|
+
export interface UsersDb { users: any[] }
|
|
11
|
+
|
|
12
|
+
/** 读用户库(文件缺失/损坏 → 空库,交由调用方建档) */
|
|
13
|
+
export function readUsersDb(): UsersDb {
|
|
14
|
+
try { return JSON.parse(fs.readFileSync(USERS_DB, 'utf-8')); }
|
|
15
|
+
catch { return { users: [] }; }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** 原子写:先写同目录临时文件再 rename,避免半截文件与并发撕裂 */
|
|
19
|
+
export function writeUsersDb(db: UsersDb): void {
|
|
20
|
+
fs.mkdirSync(path.dirname(USERS_DB), { recursive: true });
|
|
21
|
+
const tmp = USERS_DB + '.tmp-' + process.pid + '-' + Date.now();
|
|
22
|
+
fs.writeFileSync(tmp, JSON.stringify(db, null, 2));
|
|
23
|
+
fs.renameSync(tmp, USERS_DB);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 写串行队列(单进程内生效;多进程部署需外部锁,见 README 部署说明)
|
|
27
|
+
let chain: Promise<unknown> = Promise.resolve();
|
|
28
|
+
|
|
29
|
+
/** 串行执行一次「读-改-写」事务;fn 内可安全 await(如 scrypt) */
|
|
30
|
+
export function withUsersDb<T>(fn: (db: UsersDb) => T | Promise<T>): Promise<T> {
|
|
31
|
+
const run = chain.then(() => {
|
|
32
|
+
const db = readUsersDb();
|
|
33
|
+
return Promise.resolve(fn(db)).then((result) => {
|
|
34
|
+
writeUsersDb(db); // fn 内直接改 db 对象即可;由本函数统一落盘
|
|
35
|
+
return result;
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
chain = run.catch(() => { /* 防止链断 */ });
|
|
39
|
+
return run as Promise<T>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 只读事务(不落盘) */
|
|
43
|
+
export function readUsers<T>(fn: (db: UsersDb) => T): T {
|
|
44
|
+
return fn(readUsersDb());
|
|
45
|
+
}
|
|
@@ -21,11 +21,11 @@ const RAW: [number, number, string, string, string][] = [
|
|
|
21
21
|
[1, 1, '乾为天', '元亨利贞。天行健,君子以自强不息。', '刚健中正,自强之象'],
|
|
22
22
|
[8, 8, '坤为地', '元亨,利牝马之贞。地势坤,君子以厚德载物。', '柔顺包容,厚德之象'],
|
|
23
23
|
[6, 4, '水雷屯', '元亨利贞,勿用有攸往,利建侯。', '始生维艰,草创之象'],
|
|
24
|
-
[7, 6, '山水蒙', '
|
|
24
|
+
[7, 6, '山水蒙', '亨。匪我求童蒙,童蒙求我。初筮告,再三渎,渎则不告。利贞。', '蒙昧待启,求教之象'],
|
|
25
25
|
[6, 1, '水天需', '有孚,光亨,贞吉,利涉大川。', '云上于天,待时之象'],
|
|
26
26
|
[1, 6, '天水讼', '有孚窒惕,中吉,终凶。利见大人,不利涉大川。', '争讼之象,和为贵'],
|
|
27
|
-
[
|
|
28
|
-
[
|
|
27
|
+
[8, 6, '地水师', '贞,丈人吉,无咎。', '行险而顺,统众之象'],
|
|
28
|
+
[6, 8, '水地比', '吉。原筮,元永贞,无咎。不宁方来,后夫凶。', '亲附之象,众心所归'],
|
|
29
29
|
[5, 1, '风天小畜', '亨。密云不雨,自我西郊。', '小有蓄积,待时而动'],
|
|
30
30
|
[1, 2, '天泽履', '履虎尾,不咥人,亨。', '谨慎履险,如履薄冰'],
|
|
31
31
|
[8, 1, '地天泰', '小往大来,吉亨。天地交而万物通。', '通泰之象,上下交心'],
|
|
@@ -41,7 +41,7 @@ const RAW: [number, number, string, string, string][] = [
|
|
|
41
41
|
[3, 4, '火雷噬嗑', '亨。利用狱。雷电合而章。', '咬合之象,明罚敕法'],
|
|
42
42
|
[7, 3, '山火贲', '亨。小利有攸往。', '文饰之象,饰外修内'],
|
|
43
43
|
[7, 8, '山地剥', '不利有攸往。顺而止之。', '剥落之象,顺势而止'],
|
|
44
|
-
[8, 4, '地雷复', '
|
|
44
|
+
[8, 4, '地雷复', '亨。出入无疾,朋来无咎。反复其道,七日来复,利有攸往。', '复归之象,一阳来复'],
|
|
45
45
|
[1, 4, '天雷无妄', '元亨利贞。其匪正有眚,不利有攸往。', '无妄之象,守正勿妄'],
|
|
46
46
|
[7, 1, '山天大畜', '利贞。不家食吉,利涉大川。', '大蓄之象,厚积薄发'],
|
|
47
47
|
[7, 4, '山雷颐', '贞吉。观颐,自求口实。', '颐养之象,自养养人'],
|
|
@@ -57,7 +57,7 @@ const RAW: [number, number, string, string, string][] = [
|
|
|
57
57
|
[5, 3, '风火家人', '利女贞。', '一家之象,各正其位'],
|
|
58
58
|
[3, 2, '火泽睽', '小事吉。二女同居,其志不同行。', '乖离之象,求同存异'],
|
|
59
59
|
[6, 7, '水山蹇', '利西南,不利东北。利见大人,贞吉。', '艰难之象,见险知止'],
|
|
60
|
-
[4, 6, '雷水解', '
|
|
60
|
+
[4, 6, '雷水解', '利西南。无所往,其来复吉。有攸往,夙吉。', '解除之象,雷雨作而百果生'],
|
|
61
61
|
[7, 2, '山泽损', '有孚,元吉,无咎,可贞,利有攸往。', '减损之象,损下益上'],
|
|
62
62
|
[5, 4, '风雷益', '利有攸往,利涉大川。', '增益之象,损上益下'],
|
|
63
63
|
[2, 1, '泽天夬', '扬于王庭,孚号有厉。告自邑,不利即戎。', '决断之象,刚决柔也'],
|
|
@@ -6,16 +6,18 @@ export const QM_STARS = ['天蓬', '天芮', '天冲', '天辅', '天禽', '天
|
|
|
6
6
|
export const QM_SHEN = ['值符', '腾蛇', '太阴', '六合', '白虎', '玄武', '九地', '九天'];
|
|
7
7
|
export const QM_QIYI = ['戊', '己', '庚', '辛', '壬', '癸', '丁', '丙', '乙'];
|
|
8
8
|
|
|
9
|
-
// 节气 →
|
|
9
|
+
// 节气 → 阴阳遁局数(上/中/下三元,权威口径,对照 qimen-dunjia《遁甲演義/寶鑒御定》局表)
|
|
10
|
+
// 阳遁:冬至~芒种(12 节);阴遁:夏至~大雪。春分~芒种仍阳遁(勿误作阴)。
|
|
11
|
+
// 2026-09 修正:原表春分起误标阴遁且局数系统性错误
|
|
10
12
|
export const QM_SEASONS: { name: string; yin: boolean; ju: number[] }[] = [
|
|
11
|
-
{ name: '冬至', yin: false, ju: [1,
|
|
12
|
-
{ name: '立春', yin: false, ju: [8,
|
|
13
|
-
{ name: '春分', yin:
|
|
14
|
-
{ name: '立夏', yin:
|
|
15
|
-
{ name: '夏至', yin: true, ju: [9,
|
|
16
|
-
{ name: '立秋', yin: true, ju: [2,
|
|
17
|
-
{ name: '秋分', yin: true, ju: [7,
|
|
18
|
-
{ name: '立冬', yin: true, ju: [6,
|
|
13
|
+
{ name: '冬至', yin: false, ju: [1, 7, 4] }, { name: '小寒', yin: false, ju: [2, 8, 5] }, { name: '大寒', yin: false, ju: [3, 9, 6] },
|
|
14
|
+
{ name: '立春', yin: false, ju: [8, 5, 2] }, { name: '雨水', yin: false, ju: [9, 6, 3] }, { name: '惊蛰', yin: false, ju: [1, 7, 4] },
|
|
15
|
+
{ name: '春分', yin: false, ju: [3, 9, 6] }, { name: '清明', yin: false, ju: [4, 1, 7] }, { name: '谷雨', yin: false, ju: [5, 2, 8] },
|
|
16
|
+
{ name: '立夏', yin: false, ju: [4, 1, 7] }, { name: '小满', yin: false, ju: [5, 2, 8] }, { name: '芒种', yin: false, ju: [6, 3, 9] },
|
|
17
|
+
{ name: '夏至', yin: true, ju: [9, 3, 6] }, { name: '小暑', yin: true, ju: [8, 2, 5] }, { name: '大暑', yin: true, ju: [7, 1, 4] },
|
|
18
|
+
{ name: '立秋', yin: true, ju: [2, 5, 8] }, { name: '处暑', yin: true, ju: [1, 4, 7] }, { name: '白露', yin: true, ju: [9, 3, 6] },
|
|
19
|
+
{ name: '秋分', yin: true, ju: [7, 1, 4] }, { name: '寒露', yin: true, ju: [6, 9, 3] }, { name: '霜降', yin: true, ju: [5, 8, 2] },
|
|
20
|
+
{ name: '立冬', yin: true, ju: [6, 9, 3] }, { name: '小雪', yin: true, ju: [5, 8, 2] }, { name: '大雪', yin: true, ju: [4, 7, 1] },
|
|
19
21
|
];
|
|
20
22
|
|
|
21
23
|
// 节气近似日期(公历,演示级排盘与奇门定遁用)
|