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.
Binary file
@@ -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', () => {
@@ -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
- if (username && rec.username !== username) return res.status(403).json({ error: 'FORBIDDEN' });
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
- if (username && rec.username !== username) {
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
- if (db.users.some((u: any) => u.username === username)) return true;
30
- db.users.push({ username, passHash: '', createdAt: Date.now(), profile: {}, samples: [], records: [] });
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 { return false; }
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(username)) {
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
- if (req.query.username !== rec.username) return res.status(403).json({ error: 'FORBIDDEN' });
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
- router.get('/:username/profile', (req, res) => {
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
- const picked = matched.length > 0 ? matched : [scored[0]];
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
- // 断语库 v2:结构化「原文 → 断语要点 → 适用因子」条目
1
+ // 断语库 v3:结构化「原文 → 断语要点 → 适用因子」条目
2
2
  // 定位:AI 解读的引证源(排盘 → 断语匹配 → 解读引用「《书·篇》:原文」),未来接考时校准
3
3
  // 收录规范 / 版权核查 / 处理清单:见 docs/古籍参考库收录规范.md
4
- // ✅ 已校核 11 条(status: 'reviewed',来源为 ctext/维基文库/时点古籍 权威底本)→ 已接入 AI prompt 引证
5
- // ⚠️ 剩余 5 status: 'seed'(待逐字校核/待补底本),未 reviewed 前不得注入 AI prompt
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: '待补维基文库《子平真诠》底本链接后校核。', status: 'seed',
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: '通行本断诀措辞,需按 ctext 卷二逐字校核。', status: 'seed',
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: '待按维基文库全文逐字校核。', status: 'seed',
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: '小六壬无传世成书古籍,属民间掌诀;多版本措辞有差异,需对照版本并注明采录来源。', status: 'seed',
133
+ sources: ['(民间流传口诀,采录通行本;多版本并存)'],
134
+ note: '小六壬无传世成书古籍,属民间掌诀;本条目采录通行本(大安身不动/留连事难成…),另有版本作「大安事事昌」等,措辞差异不影响六神吉凶语义。', status: 'reviewed',
136
135
  },
137
136
  // ─── 周易(卦术总纲)───
138
137
  {
@@ -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: { 'Content-Type': 'application/json' },
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: { 'Content-Type': 'application/json' },
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 {
package/tsconfig.json CHANGED
@@ -35,6 +35,6 @@
35
35
  },
36
36
  "include": [
37
37
  "src",
38
- "api"
38
+ "shared"
39
39
  ]
40
40
  }