guanwei 1.2.2 → 1.2.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/dist/assets/{index-CQjUFsty.js → index-CjLUvkh9.js} +22 -22
- package/dist/assets/index-CjLUvkh9.js.map +1 -0
- package/dist/index.html +1 -1
- package/package.json +2 -2
- package/scripts/release.sh +1 -1
- package/server/src/data/db.json +5280 -7762
- package/server/src/data/guanwei.db +0 -0
- package/server/src/index.ts +18 -0
- package/server/src/routes/ai.ts +22 -2
- package/server/src/routes/divine.ts +29 -7
- package/server/src/routes/users.ts +44 -13
- package/server/src/services/duanyu.ts +2 -3
- package/shared/core/data/duanyu.ts +9 -10
- package/src/utils/userStore.ts +52 -5
- package/tsconfig.json +1 -1
- package/dist/assets/index-CQjUFsty.js.map +0 -1
|
Binary file
|
package/server/src/index.ts
CHANGED
|
@@ -53,6 +53,24 @@ const PORT = process.env.PORT || 3018;
|
|
|
53
53
|
app.use(cors());
|
|
54
54
|
app.use(express.json());
|
|
55
55
|
|
|
56
|
+
// ─── AI 接口限流(防 BYOK Key 被刷爆):per-IP 令牌桶 ───
|
|
57
|
+
const AI_RATE_LIMIT = { windowMs: 60_000, max: 30 }; // 每分钟 30 次(本地单用户无感,公网防刷)
|
|
58
|
+
const aiHits = new Map<string, { count: number; resetAt: number }>();
|
|
59
|
+
app.use('/api/ai', (req, res, next) => {
|
|
60
|
+
const ip = req.ip || req.socket.remoteAddress || 'unknown';
|
|
61
|
+
const now = Date.now();
|
|
62
|
+
const rec = aiHits.get(ip);
|
|
63
|
+
if (!rec || now > rec.resetAt) {
|
|
64
|
+
aiHits.set(ip, { count: 1, resetAt: now + AI_RATE_LIMIT.windowMs });
|
|
65
|
+
return next();
|
|
66
|
+
}
|
|
67
|
+
rec.count++;
|
|
68
|
+
if (rec.count > AI_RATE_LIMIT.max) {
|
|
69
|
+
return res.status(429).json({ error: 'AI_RATE_LIMITED', message: '请求过于频繁,请稍后再试' });
|
|
70
|
+
}
|
|
71
|
+
next();
|
|
72
|
+
});
|
|
73
|
+
|
|
56
74
|
// 请求日志(联调排查用)
|
|
57
75
|
app.use((req, res, next) => {
|
|
58
76
|
res.on('finish', () => {
|
package/server/src/routes/ai.ts
CHANGED
|
@@ -1,10 +1,28 @@
|
|
|
1
1
|
// AI 解读路由:/api/ai/interpret(非流式)+ /api/ai/interpret/stream(SSE)
|
|
2
2
|
import { Router } from 'express';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import crypto from 'crypto';
|
|
6
|
+
import { fileURLToPath } from 'url';
|
|
3
7
|
import { buildMessages, buildReportMessages, buildStep1Messages, buildStep2Messages } from '../services/promptBuilder.js';
|
|
4
8
|
import { chatOnce, chatStream, activeProvider, providerStatus, lastFinishReason } from '../services/llmProvider.js';
|
|
5
9
|
import { getDivination, attachReport, markAiFailed } from '../services/divineStore.js';
|
|
6
10
|
import { verifyRelatives, fixFamilyRelatives } from '../services/relativesCheck.js';
|
|
7
11
|
|
|
12
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
|
|
14
|
+
// 请求 token → 用户名(与 divine.ts 同策略:有 token 以 token 为准,防 body 自报他人名)
|
|
15
|
+
function authedUsername(req: any): string | null {
|
|
16
|
+
const tk = String(req.headers['x-guanwei-token'] || '');
|
|
17
|
+
if (!tk) return null;
|
|
18
|
+
try {
|
|
19
|
+
const db = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'data', 'db.json'), 'utf-8'));
|
|
20
|
+
const user = db.users.find((u: any) => u.token && u.token.length === tk.length && crypto.timingSafeEqual(Buffer.from(u.token), Buffer.from(tk)));
|
|
21
|
+
return user ? user.username : null;
|
|
22
|
+
} catch { return null; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// 非流式:token 优先归属
|
|
8
26
|
const router = Router();
|
|
9
27
|
|
|
10
28
|
// Provider 健康状态
|
|
@@ -23,7 +41,8 @@ router.post('/interpret', async (req, res) => {
|
|
|
23
41
|
if (divineId) {
|
|
24
42
|
const rec = getDivination(divineId);
|
|
25
43
|
if (!rec) return res.status(400).json({ error: 'DIVINE_NOT_FOUND', message: '起占记录不存在,请重新起占' });
|
|
26
|
-
|
|
44
|
+
const authed = authedUsername(req);
|
|
45
|
+
if (authed ? authed !== rec.username : (username && rec.username !== username)) return res.status(403).json({ error: 'FORBIDDEN' });
|
|
27
46
|
resultRaw = rec.resultRaw;
|
|
28
47
|
recProfile = rec.profile;
|
|
29
48
|
} else {
|
|
@@ -87,7 +106,8 @@ router.post('/interpret/stream', async (req, res) => {
|
|
|
87
106
|
res.status(400).json({ error: 'DIVINE_NOT_FOUND', message: '起占记录不存在,请重新起占' });
|
|
88
107
|
return;
|
|
89
108
|
}
|
|
90
|
-
|
|
109
|
+
const authed = authedUsername(req);
|
|
110
|
+
if (authed ? authed !== rec.username : (username && rec.username !== username)) {
|
|
91
111
|
res.status(403).json({ error: 'FORBIDDEN' });
|
|
92
112
|
return;
|
|
93
113
|
}
|
|
@@ -13,6 +13,7 @@ import { allTarotSpreads } from '../../../shared/core/data/tarotSpreads.js';
|
|
|
13
13
|
import { createDivination, listDivinations, getDivination, deleteDivination } from '../services/divineStore.js';
|
|
14
14
|
import fs from 'fs';
|
|
15
15
|
import path from 'path';
|
|
16
|
+
import crypto from 'crypto';
|
|
16
17
|
import { fileURLToPath } from 'url';
|
|
17
18
|
|
|
18
19
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -21,24 +22,42 @@ const DB_FILE = path.join(__dirname, '..', 'data', 'db.json');
|
|
|
21
22
|
const router = Router();
|
|
22
23
|
const MINGPAN_ARTS = ['bazi', 'ziwei', 'astrology'];
|
|
23
24
|
|
|
24
|
-
// 宽松建档:前端本地注册的用户在此同步建档(与 users.ts upsert
|
|
25
|
+
// 宽松建档:前端本地注册的用户在此同步建档(与 users.ts upsert 同策略;建档即发 claimToken 防抢占)
|
|
25
26
|
function ensureUser(username: string): boolean {
|
|
26
27
|
if (!username || typeof username !== 'string') return false;
|
|
27
28
|
try {
|
|
28
29
|
const db = JSON.parse(fs.readFileSync(DB_FILE, 'utf-8'));
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
const exist = db.users.find((u: any) => u.username === username);
|
|
31
|
+
if (exist) return true;
|
|
32
|
+
const token = crypto.randomBytes(32).toString('hex');
|
|
33
|
+
db.users.push({ username, passHash: '', createdAt: Date.now(), profile: {}, samples: [], records: [], token });
|
|
31
34
|
fs.writeFileSync(DB_FILE, JSON.stringify(db, null, 2));
|
|
32
35
|
console.log('[divine] 自动建档:', username);
|
|
33
36
|
return true;
|
|
34
|
-
} catch
|
|
37
|
+
} catch (e: any) {
|
|
38
|
+
console.error('[divine] ensureUser 异常:', e?.message || e);
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 请求携带的 token → 对应用户名(有 token 则以其为准,堵「query/body 自报 username」越权)
|
|
44
|
+
function authedUsername(req: any): string | null {
|
|
45
|
+
const tk = String(req.headers['x-guanwei-token'] || '');
|
|
46
|
+
if (!tk) return null;
|
|
47
|
+
try {
|
|
48
|
+
const db = JSON.parse(fs.readFileSync(DB_FILE, 'utf-8'));
|
|
49
|
+
const user = db.users.find((u: any) => u.token && u.token.length === tk.length && crypto.timingSafeEqual(Buffer.from(u.token), Buffer.from(tk)));
|
|
50
|
+
return user ? user.username : null;
|
|
51
|
+
} catch { return null; }
|
|
35
52
|
}
|
|
36
53
|
|
|
37
54
|
// POST /api/divine —— 起占入库
|
|
38
55
|
router.post('/', (req, res) => {
|
|
39
56
|
const { artId, inputs, profile, question, username, profileId } = req.body || {};
|
|
57
|
+
const authed = authedUsername(req);
|
|
58
|
+
const owner = authed || String(username || ''); // 有 token 以 token 为准,防自报他人名
|
|
40
59
|
// 游客不允许
|
|
41
|
-
if (!ensureUser(
|
|
60
|
+
if (!ensureUser(owner)) {
|
|
42
61
|
return res.status(401).json({ error: 'UNAUTHORIZED', message: '请先入馆(登录)再起占' });
|
|
43
62
|
}
|
|
44
63
|
if (!artId || !inputs) return res.status(400).json({ error: '缺少必要参数' });
|
|
@@ -124,8 +143,11 @@ router.get('/', (req, res) => {
|
|
|
124
143
|
router.get('/:id', (req, res) => {
|
|
125
144
|
const rec = getDivination(req.params.id);
|
|
126
145
|
if (!rec) return res.status(404).json({ error: 'DIVINE_NOT_FOUND' });
|
|
127
|
-
// 归属校验:query username
|
|
128
|
-
|
|
146
|
+
// 归属校验:token 优先;无 token 时 query username 必须与记录一致(向后兼容本地旧前端)
|
|
147
|
+
const authed = authedUsername(req);
|
|
148
|
+
if (authed ? authed !== rec.username : String(req.query.username || '') !== rec.username) {
|
|
149
|
+
return res.status(403).json({ error: 'FORBIDDEN' });
|
|
150
|
+
}
|
|
129
151
|
res.json({ ...rec, resultRaw: rec.resultRaw, display: rec.display, report: rec.report || null });
|
|
130
152
|
});
|
|
131
153
|
|
|
@@ -21,6 +21,8 @@ interface DbUser {
|
|
|
21
21
|
profile: Record<string, unknown>;
|
|
22
22
|
samples: { id: string; name: string; profile: Record<string, unknown> }[];
|
|
23
23
|
records: Record<string, unknown>[];
|
|
24
|
+
/** 登录态 token(注册/登录时签发,云同步写接口须携带校验归属) */
|
|
25
|
+
token?: string;
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
interface Db { users: DbUser[] }
|
|
@@ -45,6 +47,18 @@ async function hashPassword(pw: string): Promise<string> {
|
|
|
45
47
|
return 'scrypt$' + salt.toString('hex') + '$' + key.toString('hex');
|
|
46
48
|
}
|
|
47
49
|
|
|
50
|
+
// 登录态 token:随机 32 字节 hex;写接口(profile/records/samples)须携带且归属匹配
|
|
51
|
+
function newToken(): string {
|
|
52
|
+
return crypto.randomBytes(32).toString('hex');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function tokenMatches(user: DbUser, token: string | undefined): boolean {
|
|
56
|
+
if (!token || !user.token) return false;
|
|
57
|
+
// 恒时比较,防时序侧信道
|
|
58
|
+
const a = Buffer.from(token), b = Buffer.from(user.token);
|
|
59
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
60
|
+
}
|
|
61
|
+
|
|
48
62
|
async function verifyPassword(pw: string, stored: string): Promise<boolean> {
|
|
49
63
|
if (!stored) return false;
|
|
50
64
|
if (stored.startsWith('scrypt$')) {
|
|
@@ -70,7 +84,7 @@ function upsertUser(username: string): DbUser {
|
|
|
70
84
|
const db = loadDb();
|
|
71
85
|
let user = db.users.find((x: any) => x.username === username);
|
|
72
86
|
if (!user) {
|
|
73
|
-
user = { username, passHash: '', createdAt: Date.now(), profile: {}, samples: [], records: [] };
|
|
87
|
+
user = { username, passHash: '', createdAt: Date.now(), profile: {}, samples: [], records: [], token: newToken() };
|
|
74
88
|
db.users.push(user);
|
|
75
89
|
saveDb(db);
|
|
76
90
|
console.log(`[users] 自动建档: ${username}`);
|
|
@@ -86,19 +100,24 @@ router.post('/register', async (req, res) => {
|
|
|
86
100
|
const db = loadDb();
|
|
87
101
|
const existing = db.users.find(u => u.username === name);
|
|
88
102
|
if (existing) {
|
|
89
|
-
//
|
|
103
|
+
// 占位账号(自动建档、无密语):升级须持有建档时发放的 claimToken,防任意抢占
|
|
90
104
|
if (!existing.passHash) {
|
|
105
|
+
const claimToken = String(req.body.claimToken || '');
|
|
106
|
+
if (!tokenMatches(existing, claimToken)) {
|
|
107
|
+
return res.status(409).json({ error: 'ACCOUNT_CLAIMED', message: '此名号已被自动建档占用,需持有建档凭据方可注册(或更换名号)' });
|
|
108
|
+
}
|
|
91
109
|
existing.passHash = await hashPassword(String(password));
|
|
110
|
+
existing.token = newToken();
|
|
92
111
|
if (profile) existing.profile = { ...existing.profile, ...profile };
|
|
93
112
|
saveDb(db);
|
|
94
|
-
return res.json({ ok: true, upgraded: true, user: { username: existing.username, profile: existing.profile, samples: existing.samples } });
|
|
113
|
+
return res.json({ ok: true, upgraded: true, token: existing.token, user: { username: existing.username, profile: existing.profile, samples: existing.samples } });
|
|
95
114
|
}
|
|
96
115
|
return res.status(400).json({ error: '此名号已有人用' });
|
|
97
116
|
}
|
|
98
|
-
const user: DbUser = { username: name, passHash: await hashPassword(String(password)), createdAt: Date.now(), profile: profile || {}, samples: [], records: [] };
|
|
117
|
+
const user: DbUser = { username: name, passHash: await hashPassword(String(password)), createdAt: Date.now(), profile: profile || {}, samples: [], records: [], token: newToken() };
|
|
99
118
|
db.users.push(user);
|
|
100
119
|
saveDb(db);
|
|
101
|
-
res.json({ ok: true, user: { username: user.username, profile: user.profile, samples: user.samples } });
|
|
120
|
+
res.json({ ok: true, token: user.token, user: { username: user.username, profile: user.profile, samples: user.samples } });
|
|
102
121
|
});
|
|
103
122
|
|
|
104
123
|
// 登录
|
|
@@ -115,18 +134,30 @@ router.post('/login', async (req, res) => {
|
|
|
115
134
|
saveDb(db);
|
|
116
135
|
console.log(`[users] 密码哈希已升级为 scrypt: ${user.username}`);
|
|
117
136
|
}
|
|
118
|
-
res.json({ ok: true, user: { username: user.username, profile: user.profile, samples: user.samples } });
|
|
137
|
+
res.json({ ok: true, token: user.token, user: { username: user.username, profile: user.profile, samples: user.samples } });
|
|
119
138
|
});
|
|
120
139
|
|
|
121
|
-
//
|
|
122
|
-
|
|
140
|
+
// ─── 写接口鉴权中间件:云同步写操作须携带本人 token(堵「知道 username 即可写任意档案」)───
|
|
141
|
+
function requireOwner(req: any, res: any, next: any): void {
|
|
142
|
+
const username = req.params.username;
|
|
143
|
+
const token = String(req.headers['x-guanwei-token'] || '');
|
|
144
|
+
const db = loadDb();
|
|
145
|
+
const user = db.users.find(u => u.username === username);
|
|
146
|
+
if (!user) return res.status(404).json({ error: '馆中无此人' });
|
|
147
|
+
if (!tokenMatches(user, token)) return res.status(401).json({ error: 'AUTH_REQUIRED', message: '请先入馆(登录)后再同步档案' });
|
|
148
|
+
(req as any)._dbUser = user;
|
|
149
|
+
next();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 档案读取/更新(读他人档案也须本人 token——防止知道 username 即可窥探)
|
|
153
|
+
router.get('/:username/profile', requireOwner, (req, res) => {
|
|
123
154
|
const db = loadDb();
|
|
124
155
|
const user = db.users.find(u => u.username === req.params.username);
|
|
125
156
|
if (!user) return res.status(404).json({ error: '馆中无此人' });
|
|
126
157
|
res.json({ profile: user.profile, samples: user.samples });
|
|
127
158
|
});
|
|
128
159
|
|
|
129
|
-
router.put('/:username/profile', (req, res) => {
|
|
160
|
+
router.put('/:username/profile', requireOwner, (req, res) => {
|
|
130
161
|
const db = loadDb();
|
|
131
162
|
const user = db.users.find(u => u.username === req.params.username);
|
|
132
163
|
if (!user) return res.status(404).json({ error: '馆中无此人' });
|
|
@@ -136,7 +167,7 @@ router.put('/:username/profile', (req, res) => {
|
|
|
136
167
|
});
|
|
137
168
|
|
|
138
169
|
// 示例档案增删/提升
|
|
139
|
-
router.post('/:username/samples', (req, res) => {
|
|
170
|
+
router.post('/:username/samples', requireOwner, (req, res) => {
|
|
140
171
|
const db = loadDb();
|
|
141
172
|
const user = db.users.find(u => u.username === req.params.username);
|
|
142
173
|
if (!user) return res.status(404).json({ error: '馆中无此人' });
|
|
@@ -146,7 +177,7 @@ router.post('/:username/samples', (req, res) => {
|
|
|
146
177
|
res.json({ ok: true, samples: user.samples });
|
|
147
178
|
});
|
|
148
179
|
|
|
149
|
-
router.delete('/:username/samples/:id', (req, res) => {
|
|
180
|
+
router.delete('/:username/samples/:id', requireOwner, (req, res) => {
|
|
150
181
|
const db = loadDb();
|
|
151
182
|
const user = db.users.find(u => u.username === req.params.username);
|
|
152
183
|
if (!user) return res.status(404).json({ error: '馆中无此人' });
|
|
@@ -156,7 +187,7 @@ router.delete('/:username/samples/:id', (req, res) => {
|
|
|
156
187
|
});
|
|
157
188
|
|
|
158
189
|
// 记录同步(按用户整表覆盖)
|
|
159
|
-
router.put('/:username/records', (req, res) => {
|
|
190
|
+
router.put('/:username/records', requireOwner, (req, res) => {
|
|
160
191
|
const db = loadDb();
|
|
161
192
|
const user = db.users.find(u => u.username === req.params.username);
|
|
162
193
|
if (!user) return res.status(404).json({ error: '馆中无此人' });
|
|
@@ -165,7 +196,7 @@ router.put('/:username/records', (req, res) => {
|
|
|
165
196
|
res.json({ ok: true, count: user.records.length });
|
|
166
197
|
});
|
|
167
198
|
|
|
168
|
-
router.get('/:username/records', (req, res) => {
|
|
199
|
+
router.get('/:username/records', requireOwner, (req, res) => {
|
|
169
200
|
const db = loadDb();
|
|
170
201
|
const user = db.users.find(u => u.username === req.params.username);
|
|
171
202
|
if (!user) return res.status(404).json({ error: '馆中无此人' });
|
|
@@ -49,10 +49,9 @@ export function matchDuanyu(artId: string, resultRaw: unknown, limit = 3): Duany
|
|
|
49
49
|
const hit = d.tags.filter(t => kws.includes(t)).length + (d.factors || []).filter(f => kws.includes(f)).length;
|
|
50
50
|
return { d, hit };
|
|
51
51
|
}).sort((a, b) => b.hit - a.hit);
|
|
52
|
-
// 至少命中 1
|
|
52
|
+
// 至少命中 1 个关键词才注入(避免无关引证);零命中不注入
|
|
53
53
|
const matched = scored.filter(s => s.hit > 0);
|
|
54
|
-
|
|
55
|
-
return picked.slice(0, limit).map(s => s.d);
|
|
54
|
+
return matched.slice(0, limit).map(s => s.d);
|
|
56
55
|
}
|
|
57
56
|
|
|
58
57
|
// 生成 prompt 引证段(仅 reviewed 条目;未校核 seed 一律不注入)
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
// 断语库
|
|
1
|
+
// 断语库 v3:结构化「原文 → 断语要点 → 适用因子」条目
|
|
2
2
|
// 定位:AI 解读的引证源(排盘 → 断语匹配 → 解读引用「《书·篇》:原文」),未来接考时校准
|
|
3
3
|
// 收录规范 / 版权核查 / 处理清单:见 docs/古籍参考库收录规范.md
|
|
4
|
-
// ✅
|
|
5
|
-
//
|
|
6
|
-
// 来源 URL 为 2026-08-20/23 检索核实的公有领域在线底本
|
|
4
|
+
// ✅ 全部 15 条已逐字校核(status: 'reviewed',来源为 ctext/维基文库/时点古籍 权威底本;小六壬为通行本)→ 已接入 AI prompt 引证
|
|
5
|
+
// 来源 URL 为 2026-08-20/24 检索核实的公有领域在线底本
|
|
7
6
|
|
|
8
7
|
export type DuanyuStatus = 'seed' | 'reviewed';
|
|
9
8
|
|
|
@@ -51,8 +50,8 @@ export const DUANYU: DuanyuEntry[] = [
|
|
|
51
50
|
original: '八字用神,专求月令。以日干配月令地支,而生克不同,格局分焉。',
|
|
52
51
|
duanyu: '格局派核心:用神专取月令,日干与月支生克关系定格局。',
|
|
53
52
|
factors: ['月令', '格局'], tags: ['用神', '格局'],
|
|
54
|
-
sources: ['
|
|
55
|
-
note: '
|
|
53
|
+
sources: ['https://zh.wikisource.org/zh-hans/%E5%AD%90%E5%B9%B3%E7%9C%9F%E8%A9%AE'],
|
|
54
|
+
note: '2026-08-24 已按维基文库《子平真詮》卷一校核:原文与通行本一致。', status: 'reviewed',
|
|
56
55
|
},
|
|
57
56
|
// ─── 六爻 liuyao ───
|
|
58
57
|
{
|
|
@@ -76,7 +75,7 @@ export const DUANYU: DuanyuEntry[] = [
|
|
|
76
75
|
duanyu: '梅花体用断诀:体为己、用为事——体克用事可成,用克体事有阻;体生用有耗失,用生体有进益;比和则诸事顺遂。',
|
|
77
76
|
factors: ['体用', '生克'], tags: ['体用', '生克'],
|
|
78
77
|
sources: ['https://ctext.org/wiki.pl?if=gb&chapter=475043', 'https://zh.wikisource.org/zh-hans/%E6%A2%85%E8%8A%B1%E6%98%93%E6%95%B8/%E5%8D%B7%E4%BA%8C'],
|
|
79
|
-
note: '
|
|
78
|
+
note: '2026-08-24 已按 ctext 卷二·体用论校核:断诀措辞与通行本一致(五句体用吉凶断)。', status: 'reviewed',
|
|
80
79
|
},
|
|
81
80
|
{
|
|
82
81
|
id: 'mhys-budong-01', bookId: 'meihua-yishu', art: 'meihua', chapter: '观梅占',
|
|
@@ -99,7 +98,7 @@ export const DUANYU: DuanyuEntry[] = [
|
|
|
99
98
|
duanyu: '三奇六仪布局:乙丙丁为三奇,六甲遁于六仪;阳遁仪顺奇逆、阴遁反之——局中排布的根本口诀。',
|
|
100
99
|
factors: ['三奇六仪', '阴阳遁'], tags: ['三奇', '六仪', '布局'],
|
|
101
100
|
sources: ['https://zh.wikisource.org/wiki/%E7%85%99%E6%B3%A2%E9%87%A3%E5%8F%9F%E6%AD%8C'],
|
|
102
|
-
note: '
|
|
101
|
+
note: '2026-08-24 已按维基文库《烟波钓叟歌》全文校核:四句口诀与底本一致。', status: 'reviewed',
|
|
103
102
|
},
|
|
104
103
|
// ─── 大六壬 liuren ───
|
|
105
104
|
{
|
|
@@ -131,8 +130,8 @@ export const DUANYU: DuanyuEntry[] = [
|
|
|
131
130
|
original: '大安身不动,留连事难成;速喜人便至,赤口官事凶;小吉人来喜,空亡事不长。',
|
|
132
131
|
duanyu: '小六壬六神断语总诀:大安主安顺、留连主迟滞、速喜主喜讯、赤口主口舌官非、小吉主吉庆、空亡主事不成。',
|
|
133
132
|
factors: ['六神', '掌诀'], tags: ['六神', '口诀'],
|
|
134
|
-
sources: ['
|
|
135
|
-
note: '
|
|
133
|
+
sources: ['(民间流传口诀,采录通行本;多版本并存)'],
|
|
134
|
+
note: '小六壬无传世成书古籍,属民间掌诀;本条目采录通行本(大安身不动/留连事难成…),另有版本作「大安事事昌」等,措辞差异不影响六神吉凶语义。', status: 'reviewed',
|
|
136
135
|
},
|
|
137
136
|
// ─── 周易(卦术总纲)───
|
|
138
137
|
{
|
package/src/utils/userStore.ts
CHANGED
|
@@ -44,7 +44,7 @@ export async function syncProfileToServer(username: string, profile: UserProfile
|
|
|
44
44
|
try {
|
|
45
45
|
await fetch(API + '/' + encodeURIComponent(username) + '/profile', {
|
|
46
46
|
method: 'PUT',
|
|
47
|
-
headers:
|
|
47
|
+
headers: syncHeaders(),
|
|
48
48
|
body: JSON.stringify({ profile, samples }),
|
|
49
49
|
});
|
|
50
50
|
} catch { /* 离线忽略 */ }
|
|
@@ -55,7 +55,7 @@ export async function pushRecordsToServer(username: string, records: unknown[]):
|
|
|
55
55
|
try {
|
|
56
56
|
await fetch(API + '/' + encodeURIComponent(username) + '/records', {
|
|
57
57
|
method: 'PUT',
|
|
58
|
-
headers:
|
|
58
|
+
headers: syncHeaders(),
|
|
59
59
|
body: JSON.stringify({ records }),
|
|
60
60
|
});
|
|
61
61
|
} catch { /* 离线忽略 */ }
|
|
@@ -64,7 +64,7 @@ export async function pushRecordsToServer(username: string, records: unknown[]):
|
|
|
64
64
|
export async function pullRecordsFromServer(username: string): Promise<unknown[] | null> {
|
|
65
65
|
if (!(await apiOk())) return null;
|
|
66
66
|
try {
|
|
67
|
-
const res = await fetch(API + '/' + encodeURIComponent(username) + '/records');
|
|
67
|
+
const res = await fetch(API + '/' + encodeURIComponent(username) + '/records', { headers: syncHeaders() });
|
|
68
68
|
if (!res.ok) return null;
|
|
69
69
|
const data = await res.json();
|
|
70
70
|
return data.records || null;
|
|
@@ -117,6 +117,8 @@ export function register(username: string, password: string, profile: UserProfil
|
|
|
117
117
|
users.push(user);
|
|
118
118
|
saveUsers(users);
|
|
119
119
|
setSession(name);
|
|
120
|
+
// 后端在线时换取云同步 token(离线纯本地也能用)
|
|
121
|
+
fetchServerToken(name, password);
|
|
120
122
|
return { ok: true, message: '入馆成功', user };
|
|
121
123
|
}
|
|
122
124
|
|
|
@@ -125,15 +127,60 @@ export function login(username: string, password: string): AuthResult {
|
|
|
125
127
|
const user = users.find(u => u.username === username.trim());
|
|
126
128
|
if (!user || user.passHash !== hashPassword(password)) return { ok: false, message: '名号或密语未合' };
|
|
127
129
|
setSession(user.username);
|
|
130
|
+
// 后端在线时换取云同步 token
|
|
131
|
+
fetchServerToken(user.username, password);
|
|
128
132
|
return { ok: true, message: '入馆成功', user };
|
|
129
133
|
}
|
|
130
134
|
|
|
135
|
+
// 向后端登录/注册换取云同步 token(失败静默——离线纯本地场景不受影响)
|
|
136
|
+
// 前端注册/登录是本地体系;后端 db.json 无此用户时自动注册(同密码)以获取 token
|
|
137
|
+
async function fetchServerToken(username: string, password: string): Promise<void> {
|
|
138
|
+
try {
|
|
139
|
+
let res = await fetch(API + '/login', {
|
|
140
|
+
method: 'POST',
|
|
141
|
+
headers: { 'Content-Type': 'application/json' },
|
|
142
|
+
body: JSON.stringify({ username, password }),
|
|
143
|
+
});
|
|
144
|
+
if (!res.ok) {
|
|
145
|
+
// 后端无此用户 → 注册(自动建档即可;注册接口对已有用户返回 400)
|
|
146
|
+
res = await fetch(API + '/register', {
|
|
147
|
+
method: 'POST',
|
|
148
|
+
headers: { 'Content-Type': 'application/json' },
|
|
149
|
+
body: JSON.stringify({ username, password }),
|
|
150
|
+
});
|
|
151
|
+
if (!res.ok) return;
|
|
152
|
+
}
|
|
153
|
+
const data = await res.json();
|
|
154
|
+
if (data.token) {
|
|
155
|
+
const sess = JSON.parse(localStorage.getItem(SESSION_KEY) || '{}');
|
|
156
|
+
sess.token = data.token;
|
|
157
|
+
localStorage.setItem(SESSION_KEY, JSON.stringify(sess));
|
|
158
|
+
}
|
|
159
|
+
} catch { /* 离线忽略 */ }
|
|
160
|
+
}
|
|
161
|
+
|
|
131
162
|
export function logout(): void {
|
|
132
163
|
localStorage.removeItem(SESSION_KEY);
|
|
133
164
|
}
|
|
134
165
|
|
|
135
|
-
function setSession(username: string): void {
|
|
136
|
-
localStorage.setItem(SESSION_KEY, JSON.stringify({ username, loginAt: Date.now() }));
|
|
166
|
+
function setSession(username: string, token?: string): void {
|
|
167
|
+
localStorage.setItem(SESSION_KEY, JSON.stringify({ username, token: token || '', loginAt: Date.now() }));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** 当前会话的云同步 token(登录/注册时后端签发) */
|
|
171
|
+
export function sessionToken(): string {
|
|
172
|
+
try {
|
|
173
|
+
const sess = JSON.parse(localStorage.getItem(SESSION_KEY) || 'null');
|
|
174
|
+
return sess?.token || '';
|
|
175
|
+
} catch { return ''; }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** 云同步请求头:带 token 则后端校验归属(写他人档案会被拒) */
|
|
179
|
+
function syncHeaders(): Record<string, string> {
|
|
180
|
+
const h: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
181
|
+
const tk = sessionToken();
|
|
182
|
+
if (tk) h['X-Guanwei-Token'] = tk;
|
|
183
|
+
return h;
|
|
137
184
|
}
|
|
138
185
|
|
|
139
186
|
export function currentUser(): User | null {
|