guanwei 1.2.5 → 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.
@@ -23,20 +23,21 @@ const router = Router();
23
23
  const MINGPAN_ARTS = ['bazi', 'ziwei', 'astrology'];
24
24
 
25
25
  // 宽松建档:前端本地注册的用户在此同步建档(与 users.ts upsert 同策略;建档即发 claimToken 防抢占)
26
- function ensureUser(username: string): boolean {
27
- if (!username || typeof username !== 'string') return false;
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 true;
37
+ return { existed: false, claimToken: token };
37
38
  } catch (e: any) {
38
39
  console.error('[divine] ensureUser 异常:', e?.message || e);
39
- return false;
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 authed = authedUsername(req);
58
- const owner = authed || String(username || ''); // 有 token 以 token 为准,防自报他人名
59
- // 游客不允许
60
- if (!ensureUser(owner)) {
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
- res.json({ ok: true, divineId: rec.id, resultRaw, display: rec.display });
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
- if (!ensureUser(username)) return res.status(401).json({ error: 'UNAUTHORIZED' });
139
- res.json(listDivinations(username, page, pageSize, String(req.query.profileId || '')));
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 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
- }
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 ok = deleteDivination(req.params.id, username);
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
- // 登录态 token:随机 32 字节 hex;写接口(profile/records/samples)须携带且归属匹配
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
 
@@ -295,6 +295,8 @@ export function buildStep2Messages(
295
295
  '【盘面事实(原始排盘,引用须一致)】',
296
296
  chartBrief(artId, resultRaw),
297
297
  '',
298
+ nowFact(),
299
+ '',
298
300
  lifeEventsNote(profile),
299
301
  '',
300
302
  semantic ? '【问题语义分析】' + JSON.stringify(semantic, null, 1) : '',
@@ -317,6 +319,7 @@ function systemPrompt(artId: string): string {
317
319
  '',
318
320
  '【语言规范】现代白话,温润克制,不作绝对化断言;引经据典附出处;结尾附免责声明。',
319
321
  '输出结构:严格输出合法 json 对象(字段名不可更改)。',
322
+ nowFact(),
320
323
  '【可引古籍】' + (p?.classics || ''),
321
324
  ].join('\n');
322
325
  }
@@ -333,6 +336,8 @@ export function buildMessages(
333
336
  '【排盘结果】',
334
337
  JSON.stringify(resultRaw, null, 1),
335
338
  '',
339
+ nowFact(),
340
+ '',
336
341
  semantic ? '【问题语义分析】' + JSON.stringify(semantic, null, 1) : '',
337
342
  '',
338
343
  '请按语言规范生成解读。',
@@ -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) {