guanwei 1.3.4 → 1.3.5

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/index.html CHANGED
@@ -6,7 +6,7 @@
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
  <title>观微 · 玄学问道 —— 九术参详,观微知著</title>
8
8
  <meta name="description" content="以纸墨留白为骨,融四柱八字、紫微斗数、奇门遁甲、梅花易数、六爻、大六壬、小六壬、星盘、塔罗九术于一体。观微知著,以术问道。" />
9
- <script type="module" crossorigin src="/assets/index-DVd66cjI.js"></script>
9
+ <script type="module" crossorigin src="/assets/index-Bzt3QHIz.js"></script>
10
10
  <link rel="stylesheet" crossorigin href="/assets/index-hLV3S-Cj.css">
11
11
  </head>
12
12
  <body>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "guanwei",
3
3
  "private": false,
4
- "version": "1.3.4",
4
+ "version": "1.3.5",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "dev": "vite",
@@ -69,7 +69,10 @@
69
69
  "server/package.json",
70
70
  "shared",
71
71
  "packages",
72
- "scripts",
72
+ "scripts/guanwei",
73
+ "scripts/setup.sh",
74
+ "scripts/setup.bat",
75
+ "scripts/install.sh",
73
76
  "public",
74
77
  "index.html",
75
78
  "vite.config.ts",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "guanwei-api",
3
- "version": "1.3.4",
3
+ "version": "1.3.5",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "description": "观微排盘 API(/v1 · 开放分发,复用 shared/core 单一算法副本)",
@@ -66,7 +66,7 @@ function toolsCall(params: any): { result?: any; error?: { code: number; message
66
66
  // ─── 协议核心:处理单个 JSON-RPC 请求 → 返回响应对象(stdio 与 HTTP/SSE 共用)───
67
67
  export function handleMcpRequest(req: McpRequest): McpResponse | null {
68
68
  if (req.method === 'initialize') {
69
- return { jsonrpc: '2.0', id: req.id ?? null, result: { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'guanwei-mcp', version: '1.3.4' } } };
69
+ return { jsonrpc: '2.0', id: req.id ?? null, result: { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'guanwei-mcp', version: '1.3.5' } } };
70
70
  }
71
71
  if (req.method === 'notifications/initialized' || req.method === 'notifications/cancelled') {
72
72
  return null; // 通知无响应
@@ -39,6 +39,22 @@ app.use((req, res, next) => {
39
39
  next();
40
40
  });
41
41
 
42
+ // ─── 最小使用计数("水表"):只统计次数,不记录 IP / 参数 / 载荷,也不外发 ───
43
+ // 目的:自托管/试跑时能回答「有没有人用、用了哪一术」,而不引入任何遥测与隐私面。
44
+ // 关闭:GUANWEI_API_STATS=0;查看:本机 GET /v1/stats(非本机来源返回 404)
45
+ const STATS_ON = process.env.GUANWEI_API_STATS !== '0';
46
+ const stats = { startedAt: Date.now(), total: 0, byEndpoint: {} as Record<string, number>, byDay: {} as Record<string, number> };
47
+ app.use((req, res, next) => {
48
+ if (!STATS_ON) return next();
49
+ if (req.path === '/v1/stats') return next();
50
+ const key = (req.path.startsWith('/v1/chart') ? '/v1/chart' : req.path.startsWith('/mcp') ? '/mcp' : req.path);
51
+ const day = new Date().toISOString().slice(0, 10);
52
+ stats.total++;
53
+ stats.byEndpoint[key] = (stats.byEndpoint[key] || 0) + 1;
54
+ stats.byDay[day] = (stats.byDay[day] || 0) + 1;
55
+ next();
56
+ });
57
+
42
58
  // 请求日志(access log:谁在什么时间调了哪一术,Gate 1 需求验证数据)
43
59
  app.use((req, res, next) => {
44
60
  res.on('finish', () => {
@@ -49,6 +65,13 @@ app.use((req, res, next) => {
49
65
  next();
50
66
  });
51
67
 
68
+ app.get('/v1/stats', (req, res) => {
69
+ const raw = String(req.ip || req.socket?.remoteAddress || '').replace(/^::ffff:/, '');
70
+ const local = raw === '127.0.0.1' || raw === '::1';
71
+ if (!local) { res.status(404).json({ error: 'NOT_FOUND' }); return; }
72
+ res.json({ ...stats, uptimeSec: Math.round((Date.now() - stats.startedAt) / 1000), note: '仅计数,无 IP/参数/载荷;GUANWEI_API_STATS=0 可关闭' });
73
+ });
74
+
52
75
  app.get('/v1', (_req, res) => {
53
76
  res.json({ name: '观微排盘 API', version: 'v1', endpoints: ['POST /v1/chart', 'GET /v1/arts'], docs: 'https://github.com/RubyCcll/guanwei' });
54
77
  });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "guanwei-server",
3
- "version": "1.3.4",
3
+ "version": "1.3.5",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "tsx watch src/index.ts",
@@ -43,7 +43,8 @@ import aiRouter from './routes/ai.js';
43
43
  import usersRouter from './routes/users.js';
44
44
  import divineRouter from './routes/divine.js';
45
45
  import hourRouter from './routes/hour.js';
46
- import { DATA_DIR } from './services/dataDir.js';
46
+ import { DATA_DIR, DIVINE_DB } from './services/dataDir.js';
47
+ import { ensureInit as initUsersStore, userCount } from './services/usersStore.js';
47
48
 
48
49
  const app = express();
49
50
  const PORT = process.env.PORT || 3018;
@@ -94,6 +95,8 @@ function rateLimit(group: string, limit: { windowMs: number; max: number }) {
94
95
  }
95
96
  rec.count++;
96
97
  if (rec.count > limit.max) {
98
+ // 与开放 API 同口径:给出可退避的秒数(前端据此退避重试)
99
+ res.setHeader('Retry-After', String(Math.max(1, Math.ceil((rec.resetAt - now) / 1000))));
97
100
  return res.status(429).json({ error: 'RATE_LIMITED', message: '请求过于频繁,请稍后再试' });
98
101
  }
99
102
  next();
@@ -136,7 +139,10 @@ app.get('/api/health', (_req, res) => {
136
139
  // 轻量自检:数据目录可写 + LLM 配置状态(不含敏感值)
137
140
  let dataDirOk = true;
138
141
  try { fs.accessSync(DATA_DIR, fs.constants.W_OK); } catch { dataDirOk = false; }
139
- res.json({ status: dataDirOk ? 'ok' : 'degraded', dataDir: dataDirOk ? 'ok' : 'readonly', timestamp: Date.now() });
142
+ // 只报账号数量与存储类型,不泄露账号名(隐私口径)
143
+ let users: number | null = null;
144
+ try { users = userCount(); } catch { /* 库不可读时保持 null */ }
145
+ res.json({ status: dataDirOk ? 'ok' : 'degraded', dataDir: dataDirOk ? 'ok' : 'readonly', store: 'sqlite', users, timestamp: Date.now() });
140
146
  });
141
147
 
142
148
  app.use('/api/tarot', tarotRouter);
@@ -157,6 +163,14 @@ app.use((err: any, _req: express.Request, res: express.Response, _next: express.
157
163
  });
158
164
  });
159
165
 
166
+ // 启动即初始化统一存储:建表 + 旧 db.json 一次性导入(幂等;失败不阻塞启动)
167
+ try {
168
+ initUsersStore();
169
+ console.log(`[store] SQLite: ${DIVINE_DB}(账号 ${userCount()} 个)`);
170
+ } catch (e: any) {
171
+ console.error('[store] 初始化失败:', e?.message || e);
172
+ }
173
+
160
174
  app.listen(Number(PORT), HOST, () => {
161
175
  console.log(`
162
176
  🎴 观微后端服务已启动
@@ -1,7 +1,6 @@
1
1
  // AI 解读路由:/api/ai/interpret(非流式)+ /api/ai/interpret/stream(SSE)
2
2
  import { Router } from 'express';
3
- import { resolveTokenUser, allowAnonymous } from '../services/auth.js';
4
- import { readUsersDb } from '../services/usersDb.js';
3
+ import { resolveTokenUser } from '../services/auth.js';
5
4
  import path from 'path';
6
5
  import { fileURLToPath } from 'url';
7
6
  import { buildMessages, buildReportMessages, buildStep1Messages, buildStep2Messages } from '../services/promptBuilder.js';
@@ -21,15 +20,10 @@ function authedUsername(req: any): string | null {
21
20
 
22
21
  // 解读归属校验:正式账号须本人 token;占位账号仅限「可信内网来源」无 token 访问
23
22
  // (2026-09 修 P0-2:原实现 catch 分支 fail-open,读库失败即放行,配合无鉴权建档可跨用户读档案/耗 AI 额度)
24
- function canReadChart(req: any, recUsername: string, fallbackUsername: string): boolean {
23
+ function canReadChart(req: any, recUsername: string): boolean {
24
+ // 解读 = 读取排盘记录 + 消耗 owner 的 LLM 额度:一律须持凭据(2026-09 W38 收紧)
25
25
  const authed = authedUsername(req);
26
- if (authed) return authed === recUsername;
27
- if (!allowAnonymous(req)) return false; // 公网来源必须带 token
28
- if (fallbackUsername && fallbackUsername !== recUsername) return false; // 不得自报他人名号
29
- try {
30
- const user = readUsersDb().users.find((u: any) => u.username === recUsername);
31
- return !user || !user.passHash; // 占位账号放行(内网)
32
- } catch { return false; } // 读库失败一律拒绝(不再 fail-open)
26
+ return !!authed && authed === recUsername;
33
27
  }
34
28
 
35
29
  // 非流式:token 优先归属
@@ -51,7 +45,7 @@ router.post('/interpret', async (req, res) => {
51
45
  if (divineId) {
52
46
  const rec = getDivination(divineId);
53
47
  if (!rec) return res.status(400).json({ error: 'DIVINE_NOT_FOUND', message: '起占记录不存在,请重新起占' });
54
- if (!canReadChart(req, rec.username, String(username || ''))) return res.status(403).json({ error: 'FORBIDDEN' });
48
+ if (!canReadChart(req, rec.username)) return res.status(403).json({ error: 'FORBIDDEN' });
55
49
  resultRaw = rec.resultRaw;
56
50
  recProfile = rec.profile;
57
51
  } else {
@@ -132,7 +126,7 @@ router.post('/interpret/stream', async (req, res) => {
132
126
  res.status(400).json({ error: 'DIVINE_NOT_FOUND', message: '起占记录不存在,请重新起占' });
133
127
  return;
134
128
  }
135
- if (!canReadChart(req, rec.username, String(username || ''))) {
129
+ if (!canReadChart(req, rec.username)) {
136
130
  res.status(403).json({ error: 'FORBIDDEN' });
137
131
  return;
138
132
  }
@@ -3,37 +3,25 @@ import { Router } from 'express';
3
3
  import { chartCalc } from '../../../shared/core/engine/chart.js';
4
4
  import { createDivination, listDivinations, getDivination, deleteDivination } from '../services/divineStore.js';
5
5
  import { resolveTokenUser, allowAnonymous } from '../services/auth.js';
6
- import { readUsersDb, withUsersDb } from '../services/usersDb.js';
7
- import fs from 'fs';
8
- import path from 'path';
9
- import crypto from 'crypto';
10
- import { fileURLToPath } from 'url';
11
-
12
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
- // 用户档案库路径与读写统一由 services/usersDb 提供(默认 server/data/db.json,
14
- // 可用 GUANWEI_USERS_DB / GUANWEI_DATA_DIR 覆盖;原子写 + 进程内串行锁)
15
-
16
- // 用户库读写统一走 services/usersDb(进程内串行锁 + 临时文件原子替换)
17
- function loadUsersDb(): any { return readUsersDb(); }
6
+ import { getUser, createUserIfAbsent, newToken } from '../services/usersStore.js';
18
7
 
19
8
  const router = Router();
20
9
  const MINGPAN_ARTS = ['bazi', 'ziwei', 'astrology'];
21
10
 
22
- // 宽松建档:前端本地注册的用户在此同步建档(与 users.ts upsert 同策略;新建时发 claimToken 防抢占)
11
+ // 宽松建档:前端本地注册的用户在此同步建档(与 users.ts register 同策略;新建时发 claimToken 防抢占)
23
12
  // 返回 null=失败;否则 { existed, claimToken }
24
13
  // 安全(2026-09 修 P0-1):claimToken 只在「本次新建」时返回,绝不回吐已存在账号的 token——
25
14
  // 否则任何人只要知道 username 即可取走占位账号凭据并 register 抢占(含档案/起占历史)。
26
- async function ensureUser(username: string): Promise<{ existed: boolean; claimToken: string } | null> {
15
+ // 存储(W38 #4):INSERT OR IGNORE 原子建档,并发请求只有一个拿到 claimToken。
16
+ function ensureUser(username: string): { existed: boolean; claimToken: string } | null {
27
17
  if (!username || typeof username !== 'string') return null;
28
18
  try {
29
- return await withUsersDb((db) => {
30
- const exist = db.users.find((u: any) => u.username === username);
31
- if (exist) return { existed: true, claimToken: '' }; // 已存在账号绝不回吐 token(P0-1)
32
- const token = crypto.randomBytes(32).toString('hex');
33
- db.users.push({ username, passHash: '', createdAt: Date.now(), profile: {}, samples: [], records: [], token, tokenExpires: Date.now() + 30 * 24 * 3600 * 1000 });
34
- console.log('[divine] 自动建档:', username);
35
- return { existed: false, claimToken: token };
36
- });
19
+ if (getUser(username)) return { existed: true, claimToken: '' }; // 已存在账号绝不回吐 token(P0-1)
20
+ const token = newToken();
21
+ const created = createUserIfAbsent({ username, token, tokenExpires: Date.now() + 30 * 24 * 3600 * 1000 });
22
+ if (!created) return { existed: true, claimToken: '' };
23
+ console.log('[divine] 自动建档:', username);
24
+ return { existed: false, claimToken: token };
37
25
  } catch (e: any) {
38
26
  console.error('[divine] ensureUser 异常:', e?.message || e);
39
27
  return null;
@@ -51,8 +39,14 @@ function authedUsername(req: any): string | null {
51
39
  // · 目标为占位账号(passHash 空,自动建档/未正式注册)→ 允许(本地单机流程)
52
40
  // · 目标为正式账号(注册过,passHash 非空)→ 401(必须持本人 token,H-NEW1~3)
53
41
  // 返回 { owner, isPlaceholder };null = 校验失败(已 res 响应)
54
- function resolveOwner(req: any, res: any, fallbackUsername: string): { owner: string; isPlaceholder: boolean } | null {
42
+ function resolveOwner(req: any, res: any, fallbackUsername: string, opts: { requireToken?: boolean } = {}): { owner: string; isPlaceholder: boolean } | null {
55
43
  const authed = authedUsername(req);
44
+ // 读/删类操作(历史、详情、删除)一律须持凭据——占位账号亦然(2026-09 W38 收紧 #3 残余:
45
+ // 原实现允许同内网凭 username 匿名读他人起占历史)。本地流程由前端携带起占时下发的 claimToken。
46
+ if (!authed && opts.requireToken) {
47
+ res.status(401).json({ error: 'AUTH_REQUIRED', message: '读取档案/记录需携带本人凭据(本地流程请携带起占时下发的 claimToken)' });
48
+ return null;
49
+ }
56
50
  // 匿名来源限制(P0-3):公网来源必须持 token;仅回环/内网允许无 token 的本地流程
57
51
  if (!authed && !allowAnonymous(req)) {
58
52
  res.status(401).json({ error: 'AUTH_REQUIRED', message: '请先入馆(登录)后操作(公网访问不开放匿名起占)' });
@@ -62,7 +56,7 @@ function resolveOwner(req: any, res: any, fallbackUsername: string): { owner: st
62
56
  if (!owner) { res.status(401).json({ error: 'UNAUTHORIZED', message: '请先入馆(登录)' }); return null; }
63
57
  let user: any = null;
64
58
  try {
65
- user = loadUsersDb().users.find((u: any) => u.username === owner) || null;
59
+ user = getUser(owner);
66
60
  } catch { /* 读失败按占位处理 */ }
67
61
  const isPlaceholder = !user || !user.passHash;
68
62
  if (!authed && !isPlaceholder) {
@@ -79,7 +73,7 @@ router.post('/', async (req, res) => {
79
73
  const resolved = resolveOwner(req, res, String(username || ''));
80
74
  if (!resolved) return;
81
75
  const owner = resolved.owner;
82
- const ensured = await ensureUser(owner);
76
+ const ensured = ensureUser(owner);
83
77
  if (!ensured) {
84
78
  return res.status(401).json({ error: 'UNAUTHORIZED', message: '请先入馆(登录)再起占' });
85
79
  }
@@ -117,9 +111,9 @@ router.get('/', async (req, res) => {
117
111
  const username = String(req.query.username || '');
118
112
  const page = Number(req.query.page || 1);
119
113
  const pageSize = Number(req.query.pageSize || 20);
120
- const resolved = resolveOwner(req, res, username);
114
+ const resolved = resolveOwner(req, res, username, { requireToken: true });
121
115
  if (!resolved) return;
122
- if (!(await ensureUser(resolved.owner))) return res.status(401).json({ error: 'UNAUTHORIZED' });
116
+ if (!ensureUser(resolved.owner)) return res.status(401).json({ error: 'UNAUTHORIZED' });
123
117
  res.json(listDivinations(resolved.owner, page, pageSize, String(req.query.profileId || '')));
124
118
  });
125
119
 
@@ -128,7 +122,7 @@ router.get('/:id', (req, res) => {
128
122
  const rec = getDivination(req.params.id);
129
123
  if (!rec) return res.status(404).json({ error: 'DIVINE_NOT_FOUND' });
130
124
  // 归属校验:token 优先;无 token 仅占位账号可经 query username 访问
131
- const resolved = resolveOwner(req, res, String(req.query.username || ''));
125
+ const resolved = resolveOwner(req, res, String(req.query.username || ''), { requireToken: true });
132
126
  if (!resolved) return;
133
127
  // 归属不符统一返回 404(原 403/404 差异可枚举 divineId 存在性,2026-09 修 P1-7)
134
128
  if (resolved.owner !== rec.username) return res.status(404).json({ error: 'DIVINE_NOT_FOUND' });
@@ -1,15 +1,11 @@
1
- // 用户/档案/记录 API(本地 JSON 文件存储,供前后端联调)
1
+ // 用户/档案/记录 API(存储:统一 SQLite users 表,见 services/usersStore)
2
2
  import { Router } from 'express';
3
- import fs from 'fs';
4
- import path from 'path';
5
3
  import crypto from 'crypto';
6
- import { fileURLToPath } from 'url';
7
- import { USERS_DB } from '../services/dataDir.js';
8
- import { readUsersDb, writeUsersDb, withUsersDb } from '../services/usersDb.js';
9
-
10
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
- // 用户档案 JSON 库:默认 server/data/db.json(运行时目录,与源码分离,绝不入 npm 包)
12
- const DB_FILE = USERS_DB;
4
+ import {
5
+ getUser, createUserIfAbsent, upgradePlaceholder, rotateToken, setPassHash,
6
+ mergeProfile, addSample, removeSample, setRecords,
7
+ newToken, tokenMatches, type UserRow,
8
+ } from '../services/usersStore.js';
13
9
 
14
10
  function scryptAsync(pw: string, salt: Buffer, keylen: number): Promise<Buffer> {
15
11
  return new Promise((resolve, reject) => {
@@ -17,25 +13,6 @@ function scryptAsync(pw: string, salt: Buffer, keylen: number): Promise<Buffer>
17
13
  });
18
14
  }
19
15
 
20
- interface DbUser {
21
- username: string;
22
- passHash: string;
23
- createdAt: number;
24
- profile: Record<string, unknown>;
25
- samples: { id: string; name: string; profile: Record<string, unknown> }[];
26
- records: Record<string, unknown>[];
27
- /** 登录态 token(注册/登录时签发,云同步写接口须携带校验归属) */
28
- token?: string;
29
- /** token 过期时间(ms;30 天滚动,登录时轮换续期) */
30
- tokenExpires?: number;
31
- }
32
-
33
- interface Db { users: DbUser[] }
34
-
35
- // 读写统一走 services/usersDb:原子写(临时文件 + rename),避免半截文件把全库读成空库
36
- function loadDb(): Db { return readUsersDb() as Db; }
37
- function saveDb(db: Db): void { writeUsersDb(db); }
38
-
39
16
  // ─── 输入校验(2026-09 安全修复)───
40
17
  // profile/records 原样入库会被后续注入 AI prompt 并落盘;此处白名单化 + 拒绝原型污染键
41
18
  const DANGEROUS_KEYS = ['__proto__', 'constructor', 'prototype'];
@@ -93,18 +70,6 @@ async function hashPassword(pw: string): Promise<string> {
93
70
 
94
71
  const TOKEN_TTL = 30 * 24 * 3600 * 1000; // 30 天
95
72
 
96
- function newToken(): string {
97
- return crypto.randomBytes(32).toString('hex');
98
- }
99
-
100
- function tokenMatches(user: DbUser, token: string | undefined): boolean {
101
- if (!token || !user.token) return false;
102
- if (user.tokenExpires && Date.now() > user.tokenExpires) return false; // 过期即失效
103
- // 恒时比较,防时序侧信道
104
- const a = Buffer.from(token), b = Buffer.from(user.token);
105
- return a.length === b.length && crypto.timingSafeEqual(a, b);
106
- }
107
-
108
73
  async function verifyPassword(pw: string, stored: string): Promise<boolean> {
109
74
  if (!stored) return false;
110
75
  if (stored.startsWith('scrypt$')) {
@@ -122,22 +87,14 @@ async function verifyPassword(pw: string, stored: string): Promise<boolean> {
122
87
  return 'h' + h.toString(16) === stored;
123
88
  }
124
89
 
125
- const router = Router();
126
-
127
- // 注册
128
- // 云同步 upsert:本地账号(无密语)不存在时自动建档,避免因数据清理导致 404
129
- function upsertUser(username: string): DbUser {
130
- const db = loadDb();
131
- let user = db.users.find((x: any) => x.username === username);
132
- if (!user) {
133
- user = { username, passHash: '', createdAt: Date.now(), profile: {}, samples: [], records: [], token: newToken(), tokenExpires: Date.now() + TOKEN_TTL };
134
- db.users.push(user);
135
- saveDb(db);
136
- console.log(`[users] 自动建档: ${username}`);
137
- }
138
- return user;
90
+ /** 登录响应体里的用户视图(不含 token/密语) */
91
+ function publicUser(user: UserRow) {
92
+ return { username: user.username, profile: user.profile, samples: user.samples };
139
93
  }
140
94
 
95
+ const router = Router();
96
+
97
+ // 注册(正式注册 / 占位账号凭 claimToken 升级)
141
98
  router.post('/register', async (req, res) => {
142
99
  const { username, password, profile } = req.body;
143
100
  const name = String(username || '').trim();
@@ -145,122 +102,109 @@ router.post('/register', async (req, res) => {
145
102
  if (name.length > 24) return res.status(400).json({ error: '名号至多二十四字' });
146
103
  if (!password || String(password).length < 8) return res.status(400).json({ error: '密语至少八位' });
147
104
  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 } } };
164
- }
165
- return { status: 400, body: { error: '此名号已有人用' } };
105
+
106
+ const existing = getUser(name);
107
+ if (existing) {
108
+ if (existing.passHash) return res.status(400).json({ error: '此名号已有人用' });
109
+ // 占位账号(自动建档、无密语):升级须持有建档时发放的 claimToken,防任意抢占
110
+ if (!tokenMatches(existing, String(req.body.claimToken || ''))) {
111
+ return res.status(409).json({ error: 'ACCOUNT_CLAIMED', message: '此名号已被自动建档占用,需持有建档凭据方可注册(或更换名号)' });
166
112
  }
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);
113
+ const passHash = await hashPassword(String(password)); // 异步哈希在事务外完成
114
+ const token = newToken();
115
+ const expires = Date.now() + TOKEN_TTL;
116
+ const profileMerged = { ...existing.profile, ...(cleanProfile || {}) };
117
+ try {
118
+ upgradePlaceholder(name, passHash, token, expires, cleanProfile);
119
+ } catch (e: any) {
120
+ console.error('[users] 占位账号升级失败:', e?.message || e);
121
+ return res.status(500).json({ error: 'UPGRADE_FAILED' });
122
+ }
123
+ console.log(`[users] 占位账号已升级: ${name}`);
124
+ return res.json({ ok: true, upgraded: true, token, user: { username: name, profile: profileMerged, samples: existing.samples } });
125
+ }
126
+
127
+ const passHash = await hashPassword(String(password));
128
+ const token = newToken();
129
+ const expires = Date.now() + TOKEN_TTL;
130
+ const created = createUserIfAbsent({ username: name, passHash, profile: cleanProfile || {}, token, tokenExpires: expires });
131
+ if (!created) return res.status(400).json({ error: '此名号已有人用' }); // 并发下被抢先
132
+ res.json({ ok: true, token, user: { username: name, profile: cleanProfile || {}, samples: [] } });
172
133
  });
173
134
 
174
135
  // 登录
175
136
  router.post('/login', async (req, res) => {
176
137
  const { username, password } = req.body;
177
138
  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);
139
+ const user = getUser(name);
140
+ // 名号不存在也走一次等价 scrypt,避免时序差异泄露账号是否存在(P2-2)
141
+ const ok = await verifyPassword(String(password || ''), user ? user.passHash : 'scrypt$' + '0'.repeat(32) + '$' + '0'.repeat(128));
142
+ if (!user || !ok) return res.status(401).json({ error: '名号或密语未合' });
143
+ // 旧格式哈希命中后升级(哈希在事务外算,避免事务内 await)
144
+ const upgraded = user.passHash && !user.passHash.startsWith('scrypt$') ? await hashPassword(String(password)) : null;
145
+ if (upgraded) console.log(`[users] 密码哈希已升级为 scrypt: ${name}`);
146
+ // 登录成功 → 轮换 token(旧 token 即失效)
147
+ const token = newToken();
148
+ const expires = Date.now() + TOKEN_TTL;
149
+ const fresh = getUser(name);
150
+ if (upgraded) setPassHash(name, upgraded);
151
+ rotateToken(name, token, expires);
152
+ res.json({ ok: true, token, user: publicUser(fresh || user) });
194
153
  });
195
154
 
196
155
  // ─── 写接口鉴权中间件:云同步写操作须携带本人 token(堵「知道 username 即可写任意档案」)───
197
156
  function requireOwner(req: any, res: any, next: any): void {
198
157
  const username = req.params.username;
199
158
  const token = String(req.headers['x-guanwei-token'] || '');
200
- const db = loadDb();
201
- const user = db.users.find(u => u.username === username);
159
+ const user = getUser(username);
202
160
  if (!user) return res.status(404).json({ error: '馆中无此人' });
203
161
  if (!tokenMatches(user, token)) return res.status(401).json({ error: 'AUTH_REQUIRED', message: '请先入馆(登录)后再同步档案' });
204
- (req as any)._dbUser = user;
162
+ req._dbUser = user;
205
163
  next();
206
164
  }
207
165
 
208
166
  // 档案读取/更新(读他人档案也须本人 token——防止知道 username 即可窥探)
209
167
  router.get('/:username/profile', requireOwner, (req, res) => {
210
- const db = loadDb();
211
- const user = db.users.find(u => u.username === req.params.username);
168
+ const user = getUser(req.params.username);
212
169
  if (!user) return res.status(404).json({ error: '馆中无此人' });
213
170
  res.json({ profile: user.profile, samples: user.samples });
214
171
  });
215
172
 
216
173
  router.put('/:username/profile', requireOwner, (req, res) => {
217
- const db = loadDb();
218
- const user = db.users.find(u => u.username === req.params.username);
219
- if (!user) return res.status(404).json({ error: '馆中无此人' });
220
174
  const clean = sanitizeProfile(req.body.profile);
221
175
  if (!clean) return res.status(400).json({ error: 'BAD_PROFILE', message: '档案格式不合法(字段白名单/长度/数值范围校验未通过)' });
222
- user.profile = { ...user.profile, ...clean };
223
- saveDb(db);
224
- res.json({ ok: true, profile: user.profile });
176
+ const profile = mergeProfile(req.params.username, clean);
177
+ if (!profile) return res.status(404).json({ error: '馆中无此人' });
178
+ res.json({ ok: true, profile });
225
179
  });
226
180
 
227
- // 示例档案增删/提升
181
+ // 示例档案增删
228
182
  router.post('/:username/samples', requireOwner, (req, res) => {
229
- const db = loadDb();
230
- const user = db.users.find(u => u.username === req.params.username);
231
- if (!user) return res.status(404).json({ error: '馆中无此人' });
232
183
  const sample = { id: 's' + Date.now(), name: String(req.body.name || '未名档案').slice(0, 40), profile: sanitizeProfile(req.body.profile) || {} };
233
- user.samples.push(sample);
234
- saveDb(db);
235
- res.json({ ok: true, samples: user.samples });
184
+ const samples = addSample(req.params.username, sample);
185
+ if (!samples) return res.status(404).json({ error: '馆中无此人' });
186
+ res.json({ ok: true, samples });
236
187
  });
237
188
 
238
189
  router.delete('/:username/samples/:id', requireOwner, (req, res) => {
239
- const db = loadDb();
240
- const user = db.users.find(u => u.username === req.params.username);
241
- if (!user) return res.status(404).json({ error: '馆中无此人' });
242
- user.samples = user.samples.filter(s => s.id !== req.params.id);
243
- saveDb(db);
244
- res.json({ ok: true, samples: user.samples });
190
+ const samples = removeSample(req.params.username, req.params.id);
191
+ if (!samples) return res.status(404).json({ error: '馆中无此人' });
192
+ res.json({ ok: true, samples });
245
193
  });
246
194
 
247
195
  // 记录同步(按用户整表覆盖)
248
196
  router.put('/:username/records', requireOwner, (req, res) => {
249
- const db = loadDb();
250
- const user = db.users.find(u => u.username === req.params.username);
251
- if (!user) return res.status(404).json({ error: '馆中无此人' });
252
197
  const recs = sanitizeRecords(req.body.records);
253
198
  if (!recs) return res.status(400).json({ error: 'BAD_RECORDS', message: '记录格式不合法(条数上限 2000 / 单条 20KB / 禁止危险键)' });
254
- user.records = recs;
255
- saveDb(db);
256
- res.json({ ok: true, count: user.records.length });
199
+ const count = setRecords(req.params.username, recs);
200
+ if (count === null) return res.status(404).json({ error: '馆中无此人' });
201
+ res.json({ ok: true, count });
257
202
  });
258
203
 
259
204
  router.get('/:username/records', requireOwner, (req, res) => {
260
- const db = loadDb();
261
- const user = db.users.find(u => u.username === req.params.username);
205
+ const user = getUser(req.params.username);
262
206
  if (!user) return res.status(404).json({ error: '馆中无此人' });
263
207
  res.json({ records: user.records });
264
208
  });
265
209
 
266
- export default router;
210
+ export default router;
@@ -4,19 +4,16 @@
4
4
  // - 三处路由各自实现 authedUsername,口径漂移:ai.ts 漏校验 tokenExpires(过期 token 在 AI 链路永久可用)
5
5
  // - 旧记录可能没有 tokenExpires 字段(1.3.3 前签发)→ 原逻辑视作「永不过期」;
6
6
  // 这些 token 已随 npm 1.3.2 包公开泄漏,故一律按失效处理(用户重新登录即可获新 token)
7
- import crypto from 'crypto';
8
- import { readUsersDb } from './usersDb.js';
7
+ import { getUserByToken, type UserRow } from './usersStore.js';
9
8
 
10
- export interface TokenUser { username: string; user: any }
9
+ export interface TokenUser { username: string; user: UserRow }
11
10
 
12
11
  /** 请求携带的 token → 用户(无 token / 不匹配 / 已过期 → null) */
13
12
  export function resolveTokenUser(req: any): TokenUser | null {
14
13
  const tk = String(req.headers['x-guanwei-token'] || '');
15
14
  if (!tk) return null;
16
15
  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)));
16
+ const user = getUserByToken(tk); // 存储:统一 SQLite(含恒时比较)
20
17
  if (!user) return null;
21
18
  // 过期即失效;无 tokenExpires 的旧记录(含已泄漏 token)同样视为失效
22
19
  if (!user.tokenExpires || Date.now() > user.tokenExpires) return null;