guanwei 1.2.6 → 1.2.7
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-CjLUvkh9.js → index-O2O9peYE.js} +21 -21
- package/dist/assets/index-O2O9peYE.js.map +1 -0
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/src/data/db.json +90 -348
- package/server/src/data/guanwei.db +0 -0
- package/server/src/index.ts +31 -15
- package/server/src/routes/ai.ts +16 -4
- package/server/src/routes/divine.ts +58 -23
- package/server/src/routes/users.ts +12 -3
- package/src/services/api.ts +19 -6
- package/dist/assets/index-CjLUvkh9.js.map +0 -1
package/server/src/routes/ai.ts
CHANGED
|
@@ -22,6 +22,20 @@ function authedUsername(req: any): string | null {
|
|
|
22
22
|
} catch { return null; }
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
|
|
26
|
+
// 解读归属校验(与 divine.resolveOwner 同策略):正式账号须本人 token,占位账号兼容无 token
|
|
27
|
+
function canReadChart(req: any, recUsername: string, fallbackUsername: string): boolean {
|
|
28
|
+
const authed = authedUsername(req);
|
|
29
|
+
if (authed) return authed === recUsername;
|
|
30
|
+
// 无 token:仅当目标是占位账号(未正式注册)时允许 fallback username
|
|
31
|
+
if (fallbackUsername && fallbackUsername !== recUsername) return false;
|
|
32
|
+
try {
|
|
33
|
+
const db = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'data', 'db.json'), 'utf-8'));
|
|
34
|
+
const user = db.users.find((u: any) => u.username === recUsername);
|
|
35
|
+
return !user || !user.passHash; // 占位账号放行
|
|
36
|
+
} catch { return true; }
|
|
37
|
+
}
|
|
38
|
+
|
|
25
39
|
// 非流式:token 优先归属
|
|
26
40
|
const router = Router();
|
|
27
41
|
|
|
@@ -41,8 +55,7 @@ router.post('/interpret', async (req, res) => {
|
|
|
41
55
|
if (divineId) {
|
|
42
56
|
const rec = getDivination(divineId);
|
|
43
57
|
if (!rec) return res.status(400).json({ error: 'DIVINE_NOT_FOUND', message: '起占记录不存在,请重新起占' });
|
|
44
|
-
|
|
45
|
-
if (authed ? authed !== rec.username : (username && rec.username !== username)) return res.status(403).json({ error: 'FORBIDDEN' });
|
|
58
|
+
if (!canReadChart(req, rec.username, String(username || ''))) return res.status(403).json({ error: 'FORBIDDEN' });
|
|
46
59
|
resultRaw = rec.resultRaw;
|
|
47
60
|
recProfile = rec.profile;
|
|
48
61
|
} else {
|
|
@@ -106,8 +119,7 @@ router.post('/interpret/stream', async (req, res) => {
|
|
|
106
119
|
res.status(400).json({ error: 'DIVINE_NOT_FOUND', message: '起占记录不存在,请重新起占' });
|
|
107
120
|
return;
|
|
108
121
|
}
|
|
109
|
-
|
|
110
|
-
if (authed ? authed !== rec.username : (username && rec.username !== username)) {
|
|
122
|
+
if (!canReadChart(req, rec.username, String(username || ''))) {
|
|
111
123
|
res.status(403).json({ error: 'FORBIDDEN' });
|
|
112
124
|
return;
|
|
113
125
|
}
|
|
@@ -23,20 +23,21 @@ const router = Router();
|
|
|
23
23
|
const MINGPAN_ARTS = ['bazi', 'ziwei', 'astrology'];
|
|
24
24
|
|
|
25
25
|
// 宽松建档:前端本地注册的用户在此同步建档(与 users.ts upsert 同策略;建档即发 claimToken 防抢占)
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
// 返回 null=失败;否则 { existed: 是否已存在, claimToken: 该账号当前 token(新建时返回,供前端保存用于后续注册升级)}
|
|
27
|
+
function ensureUser(username: string): { existed: boolean; claimToken: string } | null {
|
|
28
|
+
if (!username || typeof username !== 'string') return null;
|
|
28
29
|
try {
|
|
29
30
|
const db = JSON.parse(fs.readFileSync(DB_FILE, 'utf-8'));
|
|
30
31
|
const exist = db.users.find((u: any) => u.username === username);
|
|
31
|
-
if (exist) return true;
|
|
32
|
+
if (exist) return { existed: true, claimToken: exist.token || '' };
|
|
32
33
|
const token = crypto.randomBytes(32).toString('hex');
|
|
33
|
-
db.users.push({ username, passHash: '', createdAt: Date.now(), profile: {}, samples: [], records: [], token });
|
|
34
|
+
db.users.push({ username, passHash: '', createdAt: Date.now(), profile: {}, samples: [], records: [], token, tokenExpires: Date.now() + 30 * 24 * 3600 * 1000 });
|
|
34
35
|
fs.writeFileSync(DB_FILE, JSON.stringify(db, null, 2));
|
|
35
36
|
console.log('[divine] 自动建档:', username);
|
|
36
|
-
return
|
|
37
|
+
return { existed: false, claimToken: token };
|
|
37
38
|
} catch (e: any) {
|
|
38
39
|
console.error('[divine] ensureUser 异常:', e?.message || e);
|
|
39
|
-
return
|
|
40
|
+
return null;
|
|
40
41
|
}
|
|
41
42
|
}
|
|
42
43
|
|
|
@@ -51,15 +52,41 @@ function authedUsername(req: any): string | null {
|
|
|
51
52
|
} catch { return null; }
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
// 归属校验统一入口:
|
|
56
|
+
// - 带有效 token → 以 token 用户为准(堵自报 username 越权)
|
|
57
|
+
// - 无 token:
|
|
58
|
+
// · 目标为占位账号(passHash 空,自动建档/未正式注册)→ 允许(本地单机流程)
|
|
59
|
+
// · 目标为正式账号(注册过,passHash 非空)→ 401(必须持本人 token,H-NEW1~3)
|
|
60
|
+
// 返回 { owner, isPlaceholder };null = 校验失败(已 res 响应)
|
|
61
|
+
function resolveOwner(req: any, res: any, fallbackUsername: string): { owner: string; isPlaceholder: boolean } | null {
|
|
62
|
+
const authed = authedUsername(req);
|
|
63
|
+
let owner = authed || String(fallbackUsername || '');
|
|
64
|
+
if (!owner) { res.status(401).json({ error: 'UNAUTHORIZED', message: '请先入馆(登录)' }); return null; }
|
|
65
|
+
let user: any = null;
|
|
66
|
+
try {
|
|
67
|
+
const db = JSON.parse(fs.readFileSync(DB_FILE, 'utf-8'));
|
|
68
|
+
user = db.users.find((u: any) => u.username === owner) || null;
|
|
69
|
+
} catch { /* 读失败按占位处理 */ }
|
|
70
|
+
const isPlaceholder = !user || !user.passHash;
|
|
71
|
+
if (!authed && !isPlaceholder) {
|
|
72
|
+
// 无 token 但目标是正式账号 → 拒绝(防自报他人 username 越权)
|
|
73
|
+
res.status(401).json({ error: 'AUTH_REQUIRED', message: '请先入馆(登录)后操作' });
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
return { owner, isPlaceholder };
|
|
77
|
+
}
|
|
78
|
+
|
|
54
79
|
// POST /api/divine —— 起占入库
|
|
55
80
|
router.post('/', (req, res) => {
|
|
56
81
|
const { artId, inputs, profile, question, username, profileId } = req.body || {};
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
82
|
+
const resolved = resolveOwner(req, res, String(username || ''));
|
|
83
|
+
if (!resolved) return;
|
|
84
|
+
const owner = resolved.owner;
|
|
85
|
+
const ensured = ensureUser(owner);
|
|
86
|
+
if (!ensured) {
|
|
61
87
|
return res.status(401).json({ error: 'UNAUTHORIZED', message: '请先入馆(登录)再起占' });
|
|
62
88
|
}
|
|
89
|
+
const claimToken = ensured.claimToken; // 新建占位账号时返回,供前端保存(L-NEW1)
|
|
63
90
|
if (!artId || !inputs) return res.status(400).json({ error: '缺少必要参数' });
|
|
64
91
|
|
|
65
92
|
let resultRaw: unknown;
|
|
@@ -120,41 +147,49 @@ router.post('/', (req, res) => {
|
|
|
120
147
|
|
|
121
148
|
const kind = MINGPAN_ARTS.includes(artId) ? 'mingpan' : 'zhanwen';
|
|
122
149
|
const rec = createDivination({
|
|
123
|
-
username, artId, kind,
|
|
150
|
+
username: owner, artId, kind,
|
|
124
151
|
question: question || undefined,
|
|
125
152
|
profileId: profileId || 'main',
|
|
126
153
|
profile: profile || undefined,
|
|
127
154
|
params: inputs,
|
|
128
155
|
resultRaw,
|
|
129
156
|
});
|
|
130
|
-
|
|
157
|
+
const authed = authedUsername(req);
|
|
158
|
+
res.json({
|
|
159
|
+
ok: true, divineId: rec.id, resultRaw, display: rec.display,
|
|
160
|
+
// L-NEW1:请求未带 token(本地占位流程)→ 返回 claimToken,前端保存后注册可升级;带 token 则无需
|
|
161
|
+
claimToken: authed ? undefined : ensured.claimToken,
|
|
162
|
+
});
|
|
131
163
|
});
|
|
132
164
|
|
|
133
|
-
// GET /api/divine?username=&page=&pageSize= ——
|
|
165
|
+
// GET /api/divine?username=&page=&pageSize= —— 占卜历史(H-NEW2:正式账号须本人 token)
|
|
134
166
|
router.get('/', (req, res) => {
|
|
135
167
|
const username = String(req.query.username || '');
|
|
136
168
|
const page = Number(req.query.page || 1);
|
|
137
169
|
const pageSize = Number(req.query.pageSize || 20);
|
|
138
|
-
|
|
139
|
-
|
|
170
|
+
const resolved = resolveOwner(req, res, username);
|
|
171
|
+
if (!resolved) return;
|
|
172
|
+
if (!ensureUser(resolved.owner)) return res.status(401).json({ error: 'UNAUTHORIZED' });
|
|
173
|
+
res.json(listDivinations(resolved.owner, page, pageSize, String(req.query.profileId || '')));
|
|
140
174
|
});
|
|
141
175
|
|
|
142
|
-
// GET /api/divine/:id —— 详情(排盘 + AI
|
|
176
|
+
// GET /api/divine/:id —— 详情(排盘 + AI 报告;正式账号须本人 token)
|
|
143
177
|
router.get('/:id', (req, res) => {
|
|
144
178
|
const rec = getDivination(req.params.id);
|
|
145
179
|
if (!rec) return res.status(404).json({ error: 'DIVINE_NOT_FOUND' });
|
|
146
|
-
// 归属校验:token 优先;无 token
|
|
147
|
-
const
|
|
148
|
-
if (
|
|
149
|
-
|
|
150
|
-
}
|
|
180
|
+
// 归属校验:token 优先;无 token 仅占位账号可经 query username 访问
|
|
181
|
+
const resolved = resolveOwner(req, res, String(req.query.username || ''));
|
|
182
|
+
if (!resolved) return;
|
|
183
|
+
if (resolved.owner !== rec.username) return res.status(403).json({ error: 'FORBIDDEN' });
|
|
151
184
|
res.json({ ...rec, resultRaw: rec.resultRaw, display: rec.display, report: rec.report || null });
|
|
152
185
|
});
|
|
153
186
|
|
|
154
|
-
// DELETE /api/divine/:id?username= ——
|
|
187
|
+
// DELETE /api/divine/:id?username= —— 删除(H-NEW3:正式账号须本人 token)
|
|
155
188
|
router.delete('/:id', (req, res) => {
|
|
156
189
|
const username = String(req.query.username || '');
|
|
157
|
-
const
|
|
190
|
+
const resolved = resolveOwner(req, res, username);
|
|
191
|
+
if (!resolved) return;
|
|
192
|
+
const ok = deleteDivination(req.params.id, resolved.owner);
|
|
158
193
|
if (!ok) return res.status(404).json({ error: 'DIVINE_NOT_FOUND' });
|
|
159
194
|
res.json({ ok: true });
|
|
160
195
|
});
|
|
@@ -23,6 +23,8 @@ interface DbUser {
|
|
|
23
23
|
records: Record<string, unknown>[];
|
|
24
24
|
/** 登录态 token(注册/登录时签发,云同步写接口须携带校验归属) */
|
|
25
25
|
token?: string;
|
|
26
|
+
/** token 过期时间(ms;30 天滚动,登录时轮换续期) */
|
|
27
|
+
tokenExpires?: number;
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
interface Db { users: DbUser[] }
|
|
@@ -47,13 +49,15 @@ async function hashPassword(pw: string): Promise<string> {
|
|
|
47
49
|
return 'scrypt$' + salt.toString('hex') + '$' + key.toString('hex');
|
|
48
50
|
}
|
|
49
51
|
|
|
50
|
-
|
|
52
|
+
const TOKEN_TTL = 30 * 24 * 3600 * 1000; // 30 天
|
|
53
|
+
|
|
51
54
|
function newToken(): string {
|
|
52
55
|
return crypto.randomBytes(32).toString('hex');
|
|
53
56
|
}
|
|
54
57
|
|
|
55
58
|
function tokenMatches(user: DbUser, token: string | undefined): boolean {
|
|
56
59
|
if (!token || !user.token) return false;
|
|
60
|
+
if (user.tokenExpires && Date.now() > user.tokenExpires) return false; // 过期即失效
|
|
57
61
|
// 恒时比较,防时序侧信道
|
|
58
62
|
const a = Buffer.from(token), b = Buffer.from(user.token);
|
|
59
63
|
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
@@ -84,7 +88,7 @@ function upsertUser(username: string): DbUser {
|
|
|
84
88
|
const db = loadDb();
|
|
85
89
|
let user = db.users.find((x: any) => x.username === username);
|
|
86
90
|
if (!user) {
|
|
87
|
-
user = { username, passHash: '', createdAt: Date.now(), profile: {}, samples: [], records: [], token: newToken() };
|
|
91
|
+
user = { username, passHash: '', createdAt: Date.now(), profile: {}, samples: [], records: [], token: newToken(), tokenExpires: Date.now() + TOKEN_TTL };
|
|
88
92
|
db.users.push(user);
|
|
89
93
|
saveDb(db);
|
|
90
94
|
console.log(`[users] 自动建档: ${username}`);
|
|
@@ -108,13 +112,14 @@ router.post('/register', async (req, res) => {
|
|
|
108
112
|
}
|
|
109
113
|
existing.passHash = await hashPassword(String(password));
|
|
110
114
|
existing.token = newToken();
|
|
115
|
+
existing.tokenExpires = Date.now() + TOKEN_TTL;
|
|
111
116
|
if (profile) existing.profile = { ...existing.profile, ...profile };
|
|
112
117
|
saveDb(db);
|
|
113
118
|
return res.json({ ok: true, upgraded: true, token: existing.token, user: { username: existing.username, profile: existing.profile, samples: existing.samples } });
|
|
114
119
|
}
|
|
115
120
|
return res.status(400).json({ error: '此名号已有人用' });
|
|
116
121
|
}
|
|
117
|
-
const user: DbUser = { username: name, passHash: await hashPassword(String(password)), createdAt: Date.now(), profile: profile || {}, samples: [], records: [], token: newToken() };
|
|
122
|
+
const user: DbUser = { username: name, passHash: await hashPassword(String(password)), createdAt: Date.now(), profile: profile || {}, samples: [], records: [], token: newToken(), tokenExpires: Date.now() + TOKEN_TTL };
|
|
118
123
|
db.users.push(user);
|
|
119
124
|
saveDb(db);
|
|
120
125
|
res.json({ ok: true, token: user.token, user: { username: user.username, profile: user.profile, samples: user.samples } });
|
|
@@ -134,6 +139,10 @@ router.post('/login', async (req, res) => {
|
|
|
134
139
|
saveDb(db);
|
|
135
140
|
console.log(`[users] 密码哈希已升级为 scrypt: ${user.username}`);
|
|
136
141
|
}
|
|
142
|
+
// 登录成功 → 轮换 token(旧 token 即失效;防泄露长期有效)
|
|
143
|
+
user.token = newToken();
|
|
144
|
+
user.tokenExpires = Date.now() + TOKEN_TTL;
|
|
145
|
+
saveDb(db);
|
|
137
146
|
res.json({ ok: true, token: user.token, user: { username: user.username, profile: user.profile, samples: user.samples } });
|
|
138
147
|
});
|
|
139
148
|
|
package/src/services/api.ts
CHANGED
|
@@ -3,6 +3,16 @@ import type { InterpretationResult } from '@core/engine/tarotEngine';
|
|
|
3
3
|
|
|
4
4
|
// 开发环境直连后端(规避 proxy/IPv6 组合的 SSE 不确定性);生产同域 /api
|
|
5
5
|
const API_BASE = (import.meta.env.VITE_API_BASE as string | undefined) || '/api';
|
|
6
|
+
|
|
7
|
+
// 云同步 token(登录后 localStorage 会话中保存):所有需要归属校验的请求带上,
|
|
8
|
+
// 后端有 token 即以 token 解析用户,堵「自报 username」越权(H-NEW1 修复)
|
|
9
|
+
function authHeaders(): Record<string, string> {
|
|
10
|
+
try {
|
|
11
|
+
const sess = JSON.parse(localStorage.getItem('guanwei_session') || 'null');
|
|
12
|
+
if (sess?.token) return { 'X-Guanwei-Token': sess.token };
|
|
13
|
+
} catch { /* ignore */ }
|
|
14
|
+
return {};
|
|
15
|
+
}
|
|
6
16
|
// 诊断:全局暴露当前 API 基址
|
|
7
17
|
if (typeof window !== 'undefined') { (window as any).__API_BASE__ = API_BASE; }
|
|
8
18
|
console.log('[观微] API_BASE =', API_BASE);
|
|
@@ -11,6 +21,7 @@ async function request<T>(endpoint: string, options: RequestInit = {}): Promise<
|
|
|
11
21
|
const res = await fetch(`${API_BASE}${endpoint}`, {
|
|
12
22
|
headers: {
|
|
13
23
|
'Content-Type': 'application/json',
|
|
24
|
+
...authHeaders(),
|
|
14
25
|
...options.headers,
|
|
15
26
|
},
|
|
16
27
|
...options,
|
|
@@ -169,7 +180,7 @@ export async function aiInterpretStream(
|
|
|
169
180
|
try {
|
|
170
181
|
const res = await fetch(API_BASE + '/ai/interpret/stream', {
|
|
171
182
|
method: 'POST',
|
|
172
|
-
headers: { 'Content-Type': 'application/json' },
|
|
183
|
+
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
|
173
184
|
body: JSON.stringify(params),
|
|
174
185
|
signal: controller.signal,
|
|
175
186
|
});
|
|
@@ -238,12 +249,14 @@ export interface DivineResult {
|
|
|
238
249
|
divineId: string;
|
|
239
250
|
resultRaw: unknown;
|
|
240
251
|
display?: unknown;
|
|
252
|
+
/** 起占建档返回的 claimToken(本地占位账号注册升级用,L-NEW1) */
|
|
253
|
+
claimToken?: string;
|
|
241
254
|
}
|
|
242
255
|
|
|
243
256
|
export async function apiDivine(username: string, artId: string, inputs: unknown, profile?: unknown, question?: string, _profileId?: string): Promise<DivineResult> {
|
|
244
257
|
const res = await fetch(API_BASE + '/divine', {
|
|
245
258
|
method: 'POST',
|
|
246
|
-
headers: { 'Content-Type': 'application/json' },
|
|
259
|
+
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
|
247
260
|
body: JSON.stringify({ username, artId, inputs, profile, question }),
|
|
248
261
|
});
|
|
249
262
|
if (!res.ok) {
|
|
@@ -264,19 +277,19 @@ export interface DivineHistoryItem {
|
|
|
264
277
|
}
|
|
265
278
|
|
|
266
279
|
export async function apiDivineHistory(username: string, page = 1, pageSize = 20, profileId?: string): Promise<{ list: DivineHistoryItem[]; total: number }> {
|
|
267
|
-
const res = await fetch(API_BASE + '/divine?username=' + encodeURIComponent(username) + '&page=' + page + '&pageSize=' + pageSize + (profileId ? '&profileId=' + encodeURIComponent(profileId) : ''));
|
|
280
|
+
const res = await fetch(API_BASE + '/divine?username=' + encodeURIComponent(username) + '&page=' + page + '&pageSize=' + pageSize + (profileId ? '&profileId=' + encodeURIComponent(profileId) : ''), { headers: authHeaders() });
|
|
268
281
|
if (!res.ok) return { list: [], total: 0 };
|
|
269
282
|
return res.json();
|
|
270
283
|
}
|
|
271
284
|
|
|
272
285
|
export async function apiDivineDetail(id: string, username: string): Promise<Record<string, unknown> | null> {
|
|
273
|
-
const res = await fetch(API_BASE + '/divine/' + id + '?username=' + encodeURIComponent(username));
|
|
286
|
+
const res = await fetch(API_BASE + '/divine/' + id + '?username=' + encodeURIComponent(username), { headers: authHeaders() });
|
|
274
287
|
if (!res.ok) return null;
|
|
275
288
|
return res.json();
|
|
276
289
|
}
|
|
277
290
|
|
|
278
291
|
export async function apiDivineDelete(id: string, username: string): Promise<boolean> {
|
|
279
|
-
const res = await fetch(API_BASE + '/divine/' + id + '?username=' + encodeURIComponent(username), { method: 'DELETE' });
|
|
292
|
+
const res = await fetch(API_BASE + '/divine/' + id + '?username=' + encodeURIComponent(username), { method: 'DELETE', headers: authHeaders() });
|
|
280
293
|
return res.ok;
|
|
281
294
|
}
|
|
282
295
|
|
|
@@ -330,7 +343,7 @@ export async function apiHourInfer(params: {
|
|
|
330
343
|
}): Promise<HourInferResult> {
|
|
331
344
|
const res = await fetch(API_BASE + '/hour-infer', {
|
|
332
345
|
method: 'POST',
|
|
333
|
-
headers: { 'Content-Type': 'application/json' },
|
|
346
|
+
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
|
334
347
|
body: JSON.stringify(params),
|
|
335
348
|
});
|
|
336
349
|
if (!res.ok) {
|