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/README.md +41 -5
- package/dist/assets/{index-DVd66cjI.js → index-Bzt3QHIz.js} +21 -21
- package/dist/index.html +1 -1
- package/package.json +5 -2
- package/packages/guanwei-api/package.json +1 -1
- package/packages/guanwei-api/src/mcp.ts +1 -1
- package/packages/guanwei-api/src/server.ts +23 -0
- package/server/package.json +1 -1
- package/server/src/index.ts +16 -2
- package/server/src/routes/ai.ts +6 -12
- package/server/src/routes/divine.ts +22 -28
- package/server/src/routes/users.ts +71 -127
- package/server/src/services/auth.ts +3 -6
- package/server/src/services/dataDir.ts +19 -4
- package/server/src/services/db.ts +131 -0
- package/server/src/services/divineStore.ts +6 -55
- package/server/src/services/usersStore.ts +221 -0
- package/src/pages/AuthPage.tsx +3 -0
- package/src/services/api.ts +22 -7
- package/src/utils/userStore.ts +1 -1
- package/scripts/check-boundary.mjs +0 -80
- package/scripts/check-package.mjs +0 -71
- package/scripts/credentials.mjs +0 -52
- package/scripts/github-history-cleanup.mjs +0 -140
- package/scripts/npm-release.mjs +0 -79
- package/scripts/preflight-release.sh +0 -29
- package/scripts/release.sh +0 -86
- package/server/src/services/usersDb.ts +0 -45
|
@@ -48,14 +48,25 @@ const LEGACY_DIRS = [
|
|
|
48
48
|
/** 解析数据文件路径(含旧目录一次性迁移) */
|
|
49
49
|
export function dataFile(name: string): string {
|
|
50
50
|
const target = path.join(DATA_DIR, name);
|
|
51
|
+
// SQLite WAL 模式下数据可能主要落在 -wal 文件里、主库文件本身很小;
|
|
52
|
+
// 比较体积时必须把 -wal/-shm 计入,否则会误判「目标库较小」而用旧库覆盖新库。
|
|
53
|
+
const sizeOf = (p: string): number => {
|
|
54
|
+
try {
|
|
55
|
+
let n = fs.statSync(p).size;
|
|
56
|
+
for (const suffix of ['-wal', '-shm']) {
|
|
57
|
+
try { n += fs.statSync(p + suffix).size; } catch { /* 无副文件 */ }
|
|
58
|
+
}
|
|
59
|
+
return n;
|
|
60
|
+
} catch { return 0; }
|
|
61
|
+
};
|
|
51
62
|
try {
|
|
52
63
|
const legacy = LEGACY_DIRS.map(d => path.join(d, name)).find(p => fs.existsSync(p) && fs.statSync(p).size > 0);
|
|
53
64
|
const legacyOk = !!legacy;
|
|
54
65
|
const targetExists = fs.existsSync(target);
|
|
55
|
-
const targetSize =
|
|
66
|
+
const targetSize = sizeOf(target);
|
|
56
67
|
let needFill = !targetExists || targetSize === 0;
|
|
57
68
|
// SQLite 库启发式:目标存在但显著小于旧库(如历史遗留的空壳库)→ 也迁移,避免真实记录被空库遮蔽
|
|
58
|
-
if (!needFill && /\.(db|sqlite|sqlite3)$/.test(name) && legacyOk && targetSize <
|
|
69
|
+
if (!needFill && /\.(db|sqlite|sqlite3)$/.test(name) && legacyOk && targetSize < sizeOf(legacy!) / 2) {
|
|
59
70
|
needFill = true;
|
|
60
71
|
}
|
|
61
72
|
if (legacyOk && needFill) {
|
|
@@ -66,6 +77,10 @@ export function dataFile(name: string): string {
|
|
|
66
77
|
console.log('[data] 目标库较小,已备份: ' + bak);
|
|
67
78
|
}
|
|
68
79
|
fs.copyFileSync(legacy!, target);
|
|
80
|
+
// 只复制主库:旧库的 WAL 副文件若一并留用会与新主库不匹配
|
|
81
|
+
for (const suffix of ['-wal', '-shm']) {
|
|
82
|
+
try { fs.rmSync(target + suffix, { force: true }); } catch { /* ignore */ }
|
|
83
|
+
}
|
|
69
84
|
console.log('[data] 已从旧路径迁移: ' + legacy + ' → ' + target + ' (' + fs.statSync(target).size + ' bytes)');
|
|
70
85
|
}
|
|
71
86
|
} catch (e: any) {
|
|
@@ -74,7 +89,7 @@ export function dataFile(name: string): string {
|
|
|
74
89
|
return target;
|
|
75
90
|
}
|
|
76
91
|
|
|
77
|
-
/**
|
|
92
|
+
/** 旧用户档案库(JSON):仅作一次性导入来源(见 services/usersStore.ensureInit) */
|
|
78
93
|
export const USERS_DB = process.env.GUANWEI_USERS_DB || dataFile('db.json');
|
|
79
|
-
/**
|
|
94
|
+
/** 统一数据库(SQLite):用户库 + 占卜记录 + 失败留档 */
|
|
80
95
|
export const DIVINE_DB = process.env.GUANWEI_DB_FILE || dataFile('guanwei.db');
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// 统一 SQLite 句柄(单一数据源)
|
|
2
|
+
//
|
|
3
|
+
// 背景(2026-09 W38 架构欠债 #4):用户库曾是 JSON(db.json)、占卜记录是 SQLite(guanwei.db),
|
|
4
|
+
// 两套存储两套写法:JSON 侧靠「进程内 Promise 串行 + 临时文件 rename」,SQLite 侧靠事务。
|
|
5
|
+
// 现合并为**同一个 SQLite 库**:users / divinations / ai_fail_logs / meta 四张表,
|
|
6
|
+
// 所有写操作走 BEGIN IMMEDIATE 事务,跨进程亦安全(busy_timeout + WAL)。
|
|
7
|
+
//
|
|
8
|
+
// 依赖:node:sqlite(Node 22 内置,零依赖)
|
|
9
|
+
import { createRequire } from 'node:module';
|
|
10
|
+
import { DIVINE_DB } from './dataDir.js';
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
|
|
14
|
+
// node:sqlite 用 createRequire 运行时加载:
|
|
15
|
+
// - 前端测试(vitest/jsdom)会沿 import 链把 server 模块也走 Vite 打包,静态 import 会被判为
|
|
16
|
+
// 「Cannot bundle built-in module "node:sqlite"」;require 形式可避免。运行时行为完全一致。
|
|
17
|
+
// - 顺带让「仅导入而不使用存储」的场景不加载 SQLite。
|
|
18
|
+
type DatabaseSync = import('node:sqlite').DatabaseSync;
|
|
19
|
+
type DatabaseSyncCtor = typeof import('node:sqlite').DatabaseSync;
|
|
20
|
+
|
|
21
|
+
let DatabaseSyncImpl: DatabaseSyncCtor | null = null;
|
|
22
|
+
function sqlite(): DatabaseSyncCtor {
|
|
23
|
+
if (!DatabaseSyncImpl) {
|
|
24
|
+
DatabaseSyncImpl = createRequire(import.meta.url)('node:sqlite').DatabaseSync as DatabaseSyncCtor;
|
|
25
|
+
}
|
|
26
|
+
return DatabaseSyncImpl;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let db: DatabaseSync | null = null;
|
|
30
|
+
|
|
31
|
+
/** 取库句柄(首次调用建表 + 迁移) */
|
|
32
|
+
export function getDb(): DatabaseSync {
|
|
33
|
+
if (db) return db;
|
|
34
|
+
fs.mkdirSync(path.dirname(DIVINE_DB), { recursive: true });
|
|
35
|
+
db = new (sqlite())(DIVINE_DB);
|
|
36
|
+
// WAL:读写不互斥(外部工具/测试可同时只读);busy_timeout:多进程时等待而非立刻 SQLITE_BUSY
|
|
37
|
+
try { db.exec('PRAGMA journal_mode = WAL'); } catch (e: any) { console.warn('[db] WAL 未启用:', e?.message || e); }
|
|
38
|
+
try { db.exec('PRAGMA busy_timeout = 5000'); } catch { /* 老版本忽略 */ }
|
|
39
|
+
try { db.exec('PRAGMA foreign_keys = ON'); } catch { /* 同上 */ }
|
|
40
|
+
db.exec(`
|
|
41
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
42
|
+
username TEXT PRIMARY KEY,
|
|
43
|
+
pass_hash TEXT NOT NULL DEFAULT '',
|
|
44
|
+
created_at INTEGER NOT NULL,
|
|
45
|
+
profile_json TEXT NOT NULL DEFAULT '{}',
|
|
46
|
+
samples_json TEXT NOT NULL DEFAULT '[]',
|
|
47
|
+
records_json TEXT NOT NULL DEFAULT '[]',
|
|
48
|
+
token TEXT,
|
|
49
|
+
token_expires INTEGER
|
|
50
|
+
);
|
|
51
|
+
CREATE INDEX IF NOT EXISTS idx_users_token ON users(token);
|
|
52
|
+
|
|
53
|
+
CREATE TABLE IF NOT EXISTS divinations (
|
|
54
|
+
id TEXT PRIMARY KEY,
|
|
55
|
+
username TEXT NOT NULL,
|
|
56
|
+
art_id TEXT NOT NULL,
|
|
57
|
+
kind TEXT NOT NULL,
|
|
58
|
+
question TEXT,
|
|
59
|
+
profile_id TEXT NOT NULL DEFAULT 'main',
|
|
60
|
+
profile_json TEXT,
|
|
61
|
+
params_json TEXT,
|
|
62
|
+
result_raw_json TEXT NOT NULL,
|
|
63
|
+
display_json TEXT NOT NULL,
|
|
64
|
+
report_json TEXT,
|
|
65
|
+
report_quality TEXT,
|
|
66
|
+
status TEXT NOT NULL DEFAULT 'divined',
|
|
67
|
+
created_at INTEGER NOT NULL,
|
|
68
|
+
updated_at INTEGER NOT NULL
|
|
69
|
+
);
|
|
70
|
+
CREATE INDEX IF NOT EXISTS idx_div_user_time ON divinations(username, created_at DESC);
|
|
71
|
+
|
|
72
|
+
CREATE TABLE IF NOT EXISTS ai_fail_logs (
|
|
73
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
74
|
+
art_id TEXT NOT NULL,
|
|
75
|
+
kind TEXT NOT NULL,
|
|
76
|
+
divine_id TEXT,
|
|
77
|
+
raw_output TEXT,
|
|
78
|
+
fail_reason TEXT,
|
|
79
|
+
created_at INTEGER NOT NULL
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
83
|
+
k TEXT PRIMARY KEY,
|
|
84
|
+
v TEXT NOT NULL
|
|
85
|
+
);
|
|
86
|
+
`);
|
|
87
|
+
// 老库迁移:补 divinations.profile_id 列(2026-08-20 档案隔离)——必须在引用该列的索引之前
|
|
88
|
+
try {
|
|
89
|
+
db.exec("ALTER TABLE divinations ADD COLUMN profile_id TEXT NOT NULL DEFAULT 'main'");
|
|
90
|
+
} catch (e: any) {
|
|
91
|
+
if (!/duplicate column/i.test(e.message || '')) console.error('[db] 迁移 profile_id 失败:', e.message);
|
|
92
|
+
}
|
|
93
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_div_user_profile ON divinations(username, profile_id, created_at DESC)');
|
|
94
|
+
return db;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 立即写事务:fn 内**不得 await**(node:sqlite 为同步 API)。
|
|
99
|
+
* BEGIN IMMEDIATE 立刻取写锁,避免「读后写」之间的竞态(并发注册/建档的唯一性由此保证)。
|
|
100
|
+
*/
|
|
101
|
+
export function withTx<T>(fn: () => T): T {
|
|
102
|
+
const d = getDb();
|
|
103
|
+
d.exec('BEGIN IMMEDIATE');
|
|
104
|
+
try {
|
|
105
|
+
const result = fn();
|
|
106
|
+
d.exec('COMMIT');
|
|
107
|
+
return result;
|
|
108
|
+
} catch (e) {
|
|
109
|
+
try { d.exec('ROLLBACK'); } catch { /* 回滚失败时以原异常为准 */ }
|
|
110
|
+
throw e;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 读取元信息(迁移标记等) */
|
|
115
|
+
export function getMeta(k: string): string | null {
|
|
116
|
+
try {
|
|
117
|
+
const row = getDb().prepare('SELECT v FROM meta WHERE k = ?').get(k) as { v?: string } | undefined;
|
|
118
|
+
return row?.v ?? null;
|
|
119
|
+
} catch { return null; }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 写入元信息 */
|
|
123
|
+
export function setMeta(k: string, v: string): void {
|
|
124
|
+
getDb().prepare('INSERT INTO meta (k, v) VALUES (?, ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v').run(k, v);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** 仅测试/运维用:关闭句柄(下次调用重新打开) */
|
|
128
|
+
export function closeDb(): void {
|
|
129
|
+
try { db?.close(); } catch { /* ignore */ }
|
|
130
|
+
db = null;
|
|
131
|
+
}
|
|
@@ -1,60 +1,10 @@
|
|
|
1
1
|
// 占卜数据存储(SQLite):起占记录 / AI 报告 / 失败留档 / 占卜历史
|
|
2
|
-
//
|
|
3
|
-
import {
|
|
4
|
-
import { DIVINE_DB } from './dataDir.js';
|
|
5
|
-
import fs from 'fs';
|
|
2
|
+
// 句柄与建表统一由 services/db 提供(与用户库同库,见 db.ts)
|
|
3
|
+
import { getDb, withTx } from './db.js';
|
|
6
4
|
import { randomUUID } from 'node:crypto';
|
|
7
|
-
import path from 'path';
|
|
8
|
-
import { fileURLToPath } from 'url';
|
|
9
5
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
let db: DatabaseSync | null = null;
|
|
14
|
-
|
|
15
|
-
export function getDb(): DatabaseSync {
|
|
16
|
-
if (db) return db;
|
|
17
|
-
fs.mkdirSync(path.dirname(DB_FILE), { recursive: true });
|
|
18
|
-
db = new DatabaseSync(DB_FILE);
|
|
19
|
-
db.exec(`
|
|
20
|
-
CREATE TABLE IF NOT EXISTS divinations (
|
|
21
|
-
id TEXT PRIMARY KEY,
|
|
22
|
-
username TEXT NOT NULL,
|
|
23
|
-
art_id TEXT NOT NULL,
|
|
24
|
-
kind TEXT NOT NULL,
|
|
25
|
-
question TEXT,
|
|
26
|
-
profile_id TEXT NOT NULL DEFAULT 'main',
|
|
27
|
-
profile_json TEXT,
|
|
28
|
-
params_json TEXT,
|
|
29
|
-
result_raw_json TEXT NOT NULL,
|
|
30
|
-
display_json TEXT NOT NULL,
|
|
31
|
-
report_json TEXT,
|
|
32
|
-
report_quality TEXT,
|
|
33
|
-
status TEXT NOT NULL DEFAULT 'divined',
|
|
34
|
-
created_at INTEGER NOT NULL,
|
|
35
|
-
updated_at INTEGER NOT NULL
|
|
36
|
-
);
|
|
37
|
-
CREATE INDEX IF NOT EXISTS idx_div_user_time ON divinations(username, created_at DESC);
|
|
38
|
-
|
|
39
|
-
CREATE TABLE IF NOT EXISTS ai_fail_logs (
|
|
40
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
41
|
-
art_id TEXT NOT NULL,
|
|
42
|
-
kind TEXT NOT NULL,
|
|
43
|
-
divine_id TEXT,
|
|
44
|
-
raw_output TEXT,
|
|
45
|
-
fail_reason TEXT,
|
|
46
|
-
created_at INTEGER NOT NULL
|
|
47
|
-
);
|
|
48
|
-
`);
|
|
49
|
-
// 老库迁移:补 profile_id 列(2026-08-20 档案隔离)——必须在引用该列的索引之前
|
|
50
|
-
try {
|
|
51
|
-
db.exec("ALTER TABLE divinations ADD COLUMN profile_id TEXT NOT NULL DEFAULT 'main'");
|
|
52
|
-
} catch (e: any) {
|
|
53
|
-
if (!/duplicate column/i.test(e.message || '')) console.error('[divineStore] 迁移 profile_id 失败:', e.message);
|
|
54
|
-
}
|
|
55
|
-
db.exec('CREATE INDEX IF NOT EXISTS idx_div_user_profile ON divinations(username, profile_id, created_at DESC)');
|
|
56
|
-
return db;
|
|
57
|
-
}
|
|
6
|
+
export { getDb };
|
|
7
|
+
export { withTx };
|
|
58
8
|
|
|
59
9
|
export interface DivineRecord {
|
|
60
10
|
id: string;
|
|
@@ -156,6 +106,7 @@ export function markAiFailed(id: string | null, artId: string, kind: string, rea
|
|
|
156
106
|
}
|
|
157
107
|
|
|
158
108
|
// 占卜历史分页(时间倒序,摘要字段)
|
|
109
|
+
// 同一毫秒内建立的记录(如冒烟/并发场景)以 rowid 兜底,保证分页顺序确定(原按 id 比较 uuid,顺序随机)
|
|
159
110
|
export function listDivinations(username: string, page = 1, pageSize = 20, profileId?: string): { list: { divineId: string; artId: string; question: string | null; createdAt: number; hasReport: boolean; status: string; profileId: string }[]; total: number } {
|
|
160
111
|
const d = getDb();
|
|
161
112
|
const pageN = Math.max(1, page);
|
|
@@ -163,7 +114,7 @@ export function listDivinations(username: string, page = 1, pageSize = 20, profi
|
|
|
163
114
|
const where = profileId ? 'WHERE username = ? AND profile_id = ?' : 'WHERE username = ?';
|
|
164
115
|
const params = profileId ? [username, profileId] : [username];
|
|
165
116
|
const totalRow = d.prepare('SELECT COUNT(*) AS c FROM divinations ' + where).get(...params) as any;
|
|
166
|
-
const rows = d.prepare('SELECT id, art_id, question, created_at, report_json, status, profile_id FROM divinations ' + where + ' ORDER BY created_at DESC,
|
|
117
|
+
const rows = d.prepare('SELECT id, art_id, question, created_at, report_json, status, profile_id FROM divinations ' + where + ' ORDER BY created_at DESC, rowid DESC LIMIT ? OFFSET ?')
|
|
167
118
|
.all(...params, size, (pageN - 1) * size) as any[];
|
|
168
119
|
return {
|
|
169
120
|
list: rows.map(r => ({
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// 用户库(SQLite):账号 / 出生档案 / 示例档案 / 云同步记录 / 登录态 token
|
|
2
|
+
//
|
|
3
|
+
// 历史(2026-09):原为 JSON 文件(db.json)+ 进程内 Promise 串行锁。合并到统一 SQLite 后:
|
|
4
|
+
// - 写操作走 BEGIN IMMEDIATE 事务:并发注册/建档不再互相覆盖,也不再依赖进程内锁
|
|
5
|
+
// - 与占卜记录同库:跨进程一致(WAL + busy_timeout),外部工具可只读查看
|
|
6
|
+
// - 旧 db.json 在 users 表为空时**一次性导入**(原文件保留,不删除,便于回滚)
|
|
7
|
+
import crypto from 'crypto';
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import { getDb, withTx, getMeta, setMeta } from './db.js';
|
|
10
|
+
import { USERS_DB } from './dataDir.js';
|
|
11
|
+
|
|
12
|
+
export interface Sample { id: string; name: string; profile: Record<string, unknown> }
|
|
13
|
+
export interface UserRow {
|
|
14
|
+
username: string;
|
|
15
|
+
passHash: string;
|
|
16
|
+
createdAt: number;
|
|
17
|
+
profile: Record<string, unknown>;
|
|
18
|
+
samples: Sample[];
|
|
19
|
+
records: Record<string, unknown>[];
|
|
20
|
+
/** 登录态 token(注册/登录签发);占位账号为建档凭据 claimToken */
|
|
21
|
+
token?: string;
|
|
22
|
+
tokenExpires?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const COLS = 'username, pass_hash, created_at, profile_json, samples_json, records_json, token, token_expires';
|
|
26
|
+
|
|
27
|
+
function parseJson<T>(s: unknown, fallback: T): T {
|
|
28
|
+
if (typeof s !== 'string' || !s) return fallback;
|
|
29
|
+
try {
|
|
30
|
+
const v = JSON.parse(s);
|
|
31
|
+
return (v ?? fallback) as T;
|
|
32
|
+
} catch { return fallback; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function rowToUser(r: any): UserRow {
|
|
36
|
+
return {
|
|
37
|
+
username: String(r.username),
|
|
38
|
+
passHash: String(r.pass_hash || ''),
|
|
39
|
+
createdAt: Number(r.created_at || 0),
|
|
40
|
+
profile: parseJson<Record<string, unknown>>(r.profile_json, {}),
|
|
41
|
+
samples: parseJson<Sample[]>(r.samples_json, []),
|
|
42
|
+
records: parseJson<Record<string, unknown>[]>(r.records_json, []),
|
|
43
|
+
token: r.token ? String(r.token) : undefined,
|
|
44
|
+
tokenExpires: r.token_expires == null ? undefined : Number(r.token_expires),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ─── token 工具(原 users.ts 逻辑,统一到此处,供 routes/auth 复用)───
|
|
49
|
+
export function newToken(): string {
|
|
50
|
+
return crypto.randomBytes(32).toString('hex');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 恒时比较 token(含过期校验):false = 无效 */
|
|
54
|
+
export function tokenMatches(user: Pick<UserRow, 'token' | 'tokenExpires'> | null | undefined, token: string | undefined): boolean {
|
|
55
|
+
if (!user || !token || !user.token) return false;
|
|
56
|
+
if (user.tokenExpires && Date.now() > user.tokenExpires) return false; // 过期即失效
|
|
57
|
+
const a = Buffer.from(token), b = Buffer.from(user.token);
|
|
58
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ─── 读 ───
|
|
62
|
+
export function getUser(username: string): UserRow | null {
|
|
63
|
+
ensureInit();
|
|
64
|
+
if (!username) return null;
|
|
65
|
+
const row = getDb().prepare(`SELECT ${COLS} FROM users WHERE username = ?`).get(username) as any;
|
|
66
|
+
return row ? rowToUser(row) : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function userCount(): number {
|
|
70
|
+
ensureInit();
|
|
71
|
+
return Number((getDb().prepare('SELECT COUNT(*) AS c FROM users').get() as any)?.c || 0);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** token → 用户(仅返回 token 完全匹配者;不匹配/不存在 → null) */
|
|
75
|
+
export function getUserByToken(token: string): UserRow | null {
|
|
76
|
+
ensureInit();
|
|
77
|
+
if (!token) return null;
|
|
78
|
+
const row = getDb().prepare(`SELECT ${COLS} FROM users WHERE token = ?`).get(token) as any;
|
|
79
|
+
if (!row) return null;
|
|
80
|
+
const user = rowToUser(row);
|
|
81
|
+
// 索引命中后再做一次恒时比较(防御性:避免长度差异等边信道)
|
|
82
|
+
return tokenMatches(user, token) ? user : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ─── 写 ───
|
|
86
|
+
export interface NewUser {
|
|
87
|
+
username: string; passHash?: string; profile?: Record<string, unknown>;
|
|
88
|
+
token?: string; tokenExpires?: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 新建账号(已存在 → false,不覆盖);占位账号亦经此建(passHash 为空串) */
|
|
92
|
+
export function createUserIfAbsent(u: NewUser): boolean {
|
|
93
|
+
ensureInit();
|
|
94
|
+
const res = getDb().prepare(
|
|
95
|
+
'INSERT OR IGNORE INTO users (username, pass_hash, created_at, profile_json, samples_json, records_json, token, token_expires) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
|
96
|
+
).run(
|
|
97
|
+
u.username, u.passHash ?? '', Date.now(),
|
|
98
|
+
JSON.stringify(u.profile ?? {}), '[]', '[]',
|
|
99
|
+
u.token ?? null, u.tokenExpires ?? null,
|
|
100
|
+
);
|
|
101
|
+
return Number(res.changes || 0) > 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 占位账号升级为正式账号:写入密语哈希 + 轮换 token(须在事务内调用) */
|
|
105
|
+
export function upgradePlaceholder(username: string, passHash: string, token: string, tokenExpires: number, profilePatch?: Record<string, unknown>): void {
|
|
106
|
+
const merged = profilePatch ? { ...(getUser(username)?.profile || {}), ...profilePatch } : null;
|
|
107
|
+
getDb().prepare('UPDATE users SET pass_hash = ?, token = ?, token_expires = ? WHERE username = ?')
|
|
108
|
+
.run(passHash, token, tokenExpires, username);
|
|
109
|
+
if (merged) setProfileJson(username, merged);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 轮换 token(登录成功时) */
|
|
113
|
+
export function rotateToken(username: string, token: string, tokenExpires: number): void {
|
|
114
|
+
getDb().prepare('UPDATE users SET token = ?, token_expires = ? WHERE username = ?').run(token, tokenExpires, username);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 升级密语哈希(旧格式登录命中时) */
|
|
118
|
+
export function setPassHash(username: string, passHash: string): void {
|
|
119
|
+
getDb().prepare('UPDATE users SET pass_hash = ? WHERE username = ?').run(passHash, username);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function setProfileJson(username: string, profile: Record<string, unknown>): void {
|
|
123
|
+
getDb().prepare('UPDATE users SET profile_json = ? WHERE username = ?').run(JSON.stringify(profile), username);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** 档案合并写(浅合并);返回新档案,用户不存在 → null */
|
|
127
|
+
export function mergeProfile(username: string, patch: Record<string, unknown>): Record<string, unknown> | null {
|
|
128
|
+
ensureInit();
|
|
129
|
+
return withTx(() => {
|
|
130
|
+
const u = getUser(username);
|
|
131
|
+
if (!u) return null;
|
|
132
|
+
const merged = { ...u.profile, ...patch };
|
|
133
|
+
setProfileJson(username, merged);
|
|
134
|
+
return merged;
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** 示例档案:追加 */
|
|
139
|
+
export function addSample(username: string, sample: Sample): Sample[] | null {
|
|
140
|
+
ensureInit();
|
|
141
|
+
return withTx(() => {
|
|
142
|
+
const u = getUser(username);
|
|
143
|
+
if (!u) return null;
|
|
144
|
+
const samples = [...u.samples, sample];
|
|
145
|
+
getDb().prepare('UPDATE users SET samples_json = ? WHERE username = ?').run(JSON.stringify(samples), username);
|
|
146
|
+
return samples;
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 示例档案:删除 */
|
|
151
|
+
export function removeSample(username: string, id: string): Sample[] | null {
|
|
152
|
+
ensureInit();
|
|
153
|
+
return withTx(() => {
|
|
154
|
+
const u = getUser(username);
|
|
155
|
+
if (!u) return null;
|
|
156
|
+
const samples = u.samples.filter(s => s.id !== id);
|
|
157
|
+
getDb().prepare('UPDATE users SET samples_json = ? WHERE username = ?').run(JSON.stringify(samples), username);
|
|
158
|
+
return samples;
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** 云同步记录:整表覆盖 */
|
|
163
|
+
export function setRecords(username: string, records: Record<string, unknown>[]): number | null {
|
|
164
|
+
ensureInit();
|
|
165
|
+
return withTx(() => {
|
|
166
|
+
const u = getUser(username);
|
|
167
|
+
if (!u) return null;
|
|
168
|
+
getDb().prepare('UPDATE users SET records_json = ? WHERE username = ?').run(JSON.stringify(records), username);
|
|
169
|
+
return records.length;
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ─── 旧 JSON 库一次性导入 ───
|
|
174
|
+
const IMPORT_MARK = 'users_imported_from_json';
|
|
175
|
+
let inited = false;
|
|
176
|
+
|
|
177
|
+
/** 幂等初始化:users 表为空且存在旧 db.json → 导入(原文件保留) */
|
|
178
|
+
export function ensureInit(): void {
|
|
179
|
+
if (inited) return;
|
|
180
|
+
inited = true;
|
|
181
|
+
try {
|
|
182
|
+
const imported = getMeta(IMPORT_MARK);
|
|
183
|
+
if (imported) return; // 本库已导入过
|
|
184
|
+
if (userCountRaw() > 0) { setMeta(IMPORT_MARK, 'skip:users-exist'); return; }
|
|
185
|
+
let raw: string;
|
|
186
|
+
try { raw = fs.readFileSync(USERS_DB, 'utf-8'); } catch { return; } // 无旧库:全新安装
|
|
187
|
+
const parsed = JSON.parse(raw);
|
|
188
|
+
const users: any[] = Array.isArray(parsed?.users) ? parsed.users : [];
|
|
189
|
+
if (!users.length) { setMeta(IMPORT_MARK, 'skip:json-empty'); return; }
|
|
190
|
+
let n = 0;
|
|
191
|
+
withTx(() => {
|
|
192
|
+
for (const u of users) {
|
|
193
|
+
if (!u || typeof u.username !== 'string' || !u.username) continue;
|
|
194
|
+
const ok = insertLegacy(u);
|
|
195
|
+
if (ok) n++;
|
|
196
|
+
}
|
|
197
|
+
setMeta(IMPORT_MARK, `imported:${n}:${USERS_DB}`);
|
|
198
|
+
});
|
|
199
|
+
console.log(`[usersStore] 已从旧 JSON 库导入 ${n} 个账号: ${USERS_DB}`);
|
|
200
|
+
} catch (e: any) {
|
|
201
|
+
console.error('[usersStore] 旧 JSON 库导入失败(跳过,不影响新库):', e?.message || e);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function userCountRaw(): number {
|
|
206
|
+
return Number((getDb().prepare('SELECT COUNT(*) AS c FROM users').get() as any)?.c || 0);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function insertLegacy(u: any): boolean {
|
|
210
|
+
const res = getDb().prepare(
|
|
211
|
+
'INSERT OR IGNORE INTO users (username, pass_hash, created_at, profile_json, samples_json, records_json, token, token_expires) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
|
212
|
+
).run(
|
|
213
|
+
String(u.username), String(u.passHash || ''), Number(u.createdAt || Date.now()),
|
|
214
|
+
JSON.stringify(u.profile && typeof u.profile === 'object' ? u.profile : {}),
|
|
215
|
+
JSON.stringify(Array.isArray(u.samples) ? u.samples : []),
|
|
216
|
+
JSON.stringify(Array.isArray(u.records) ? u.records : []),
|
|
217
|
+
u.token ? String(u.token) : null,
|
|
218
|
+
u.tokenExpires == null ? null : Number(u.tokenExpires),
|
|
219
|
+
);
|
|
220
|
+
return Number(res.changes || 0) > 0;
|
|
221
|
+
}
|
package/src/pages/AuthPage.tsx
CHANGED
|
@@ -93,6 +93,9 @@ export default function AuthPage() {
|
|
|
93
93
|
</div>
|
|
94
94
|
<div className="field"><label>名号</label><input className="input-line" placeholder="取一雅号" value={username} onChange={e => setUsername(e.target.value)} /></div>
|
|
95
95
|
<div className="field"><label>密语</label><input className="input-line" type="password" placeholder="四字以上" value={password} onChange={e => setPassword(e.target.value)} /></div>
|
|
96
|
+
<p className="muted" style={{ fontSize: '.78rem', marginTop: '-.4rem' }}>
|
|
97
|
+
本地档案仅作离线体验:口令只在本机校验(未上传服务端),<b>不具备真正的保密能力</b>;请勿在本地档案中填写敏感信息。
|
|
98
|
+
</p>
|
|
96
99
|
<button className="btn-divine" onClick={submit}>{mode === 'register' ? '入 馆' : '入 座'}</button>
|
|
97
100
|
{msg && <p className={"mt-3 " + (msgOk ? 'tag-cool' : 'tag-hot')}>{msg}</p>}
|
|
98
101
|
</div>
|
package/src/services/api.ts
CHANGED
|
@@ -6,13 +6,21 @@ const API_BASE = (import.meta.env.VITE_API_BASE as string | undefined) || '/api'
|
|
|
6
6
|
|
|
7
7
|
// 云同步 token(登录后 localStorage 会话中保存):所有需要归属校验的请求带上,
|
|
8
8
|
// 后端有 token 即以 token 解析用户,堵「自报 username」越权(H-NEW1 修复)
|
|
9
|
-
function authHeaders(): Record<string, string> {
|
|
9
|
+
function authHeaders(username?: string): Record<string, string> {
|
|
10
10
|
try {
|
|
11
11
|
const sess = JSON.parse(localStorage.getItem('guanwei_session') || 'null');
|
|
12
12
|
if (sess?.token) return { 'X-Guanwei-Token': sess.token };
|
|
13
|
+
// 本地占位账号(未注册):带上起占时下发的 claimToken,后端据此校验归属
|
|
14
|
+
const claim = (username && claimTokenOf(username)) || claimTokenOf(sess?.username || '') || currentClaimToken();
|
|
15
|
+
if (claim) return { 'X-Guanwei-Token': claim };
|
|
13
16
|
} catch { /* ignore */ }
|
|
14
17
|
return {};
|
|
15
18
|
}
|
|
19
|
+
|
|
20
|
+
/** 当前生效的占位账号凭据(单机本地流程通常只有一个) */
|
|
21
|
+
function currentClaimToken(): string {
|
|
22
|
+
try { return localStorage.getItem(CLAIM_CURRENT_KEY) || ''; } catch { return ''; }
|
|
23
|
+
}
|
|
16
24
|
// 诊断:全局暴露当前 API 基址
|
|
17
25
|
if (typeof window !== 'undefined') { (window as any).__API_BASE__ = API_BASE; }
|
|
18
26
|
console.log('[观微] API_BASE =', API_BASE);
|
|
@@ -256,7 +264,7 @@ export interface DivineResult {
|
|
|
256
264
|
export async function apiDivine(username: string, artId: string, inputs: unknown, profile?: unknown, question?: string, _profileId?: string): Promise<DivineResult> {
|
|
257
265
|
const res = await fetch(API_BASE + '/divine', {
|
|
258
266
|
method: 'POST',
|
|
259
|
-
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
|
267
|
+
headers: { 'Content-Type': 'application/json', ...authHeaders(username) },
|
|
260
268
|
body: JSON.stringify({ username, artId, inputs, profile, question }),
|
|
261
269
|
});
|
|
262
270
|
if (!res.ok) {
|
|
@@ -272,16 +280,23 @@ export async function apiDivine(username: string, artId: string, inputs: unknown
|
|
|
272
280
|
}
|
|
273
281
|
|
|
274
282
|
const CLAIM_KEY = (name: string) => 'guanwei_claim_' + name;
|
|
283
|
+
const CLAIM_CURRENT_KEY = 'guanwei_claim_current';
|
|
275
284
|
|
|
276
285
|
/** 保存/读取占位账号认领凭据(仅本地持有,随注册一次性使用) */
|
|
277
286
|
export function saveClaimToken(username: string, token: string): void {
|
|
278
|
-
try {
|
|
287
|
+
try {
|
|
288
|
+
localStorage.setItem(CLAIM_KEY(username), token);
|
|
289
|
+
localStorage.setItem(CLAIM_CURRENT_KEY, token); // 供无 username 上下文的请求(如 AI 解读)回退使用
|
|
290
|
+
} catch { /* ignore */ }
|
|
279
291
|
}
|
|
280
292
|
export function claimTokenOf(username: string): string {
|
|
281
293
|
try { return localStorage.getItem(CLAIM_KEY(username)) || ''; } catch { return ''; }
|
|
282
294
|
}
|
|
283
295
|
export function clearClaimToken(username: string): void {
|
|
284
|
-
try {
|
|
296
|
+
try {
|
|
297
|
+
localStorage.removeItem(CLAIM_KEY(username));
|
|
298
|
+
if (localStorage.getItem(CLAIM_CURRENT_KEY) === localStorage.getItem(CLAIM_KEY(username))) localStorage.removeItem(CLAIM_CURRENT_KEY);
|
|
299
|
+
} catch { /* ignore */ }
|
|
285
300
|
}
|
|
286
301
|
|
|
287
302
|
export interface DivineHistoryItem {
|
|
@@ -294,19 +309,19 @@ export interface DivineHistoryItem {
|
|
|
294
309
|
}
|
|
295
310
|
|
|
296
311
|
export async function apiDivineHistory(username: string, page = 1, pageSize = 20, profileId?: string): Promise<{ list: DivineHistoryItem[]; total: number }> {
|
|
297
|
-
const res = await fetch(API_BASE + '/divine?username=' + encodeURIComponent(username) + '&page=' + page + '&pageSize=' + pageSize + (profileId ? '&profileId=' + encodeURIComponent(profileId) : ''), { headers: authHeaders() });
|
|
312
|
+
const res = await fetch(API_BASE + '/divine?username=' + encodeURIComponent(username) + '&page=' + page + '&pageSize=' + pageSize + (profileId ? '&profileId=' + encodeURIComponent(profileId) : ''), { headers: authHeaders(username) });
|
|
298
313
|
if (!res.ok) return { list: [], total: 0 };
|
|
299
314
|
return res.json();
|
|
300
315
|
}
|
|
301
316
|
|
|
302
317
|
export async function apiDivineDetail(id: string, username: string): Promise<Record<string, unknown> | null> {
|
|
303
|
-
const res = await fetch(API_BASE + '/divine/' + id + '?username=' + encodeURIComponent(username), { headers: authHeaders() });
|
|
318
|
+
const res = await fetch(API_BASE + '/divine/' + id + '?username=' + encodeURIComponent(username), { headers: authHeaders(username) });
|
|
304
319
|
if (!res.ok) return null;
|
|
305
320
|
return res.json();
|
|
306
321
|
}
|
|
307
322
|
|
|
308
323
|
export async function apiDivineDelete(id: string, username: string): Promise<boolean> {
|
|
309
|
-
const res = await fetch(API_BASE + '/divine/' + id + '?username=' + encodeURIComponent(username), { method: 'DELETE', headers: authHeaders() });
|
|
324
|
+
const res = await fetch(API_BASE + '/divine/' + id + '?username=' + encodeURIComponent(username), { method: 'DELETE', headers: authHeaders(username) });
|
|
310
325
|
return res.ok;
|
|
311
326
|
}
|
|
312
327
|
|
package/src/utils/userStore.ts
CHANGED
|
@@ -134,7 +134,7 @@ export function login(username: string, password: string): AuthResult {
|
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
// 向后端登录/注册换取云同步 token(失败静默——离线纯本地场景不受影响)
|
|
137
|
-
//
|
|
137
|
+
// 前端注册/登录是本地体系;后端用户库(SQLite users 表)无此用户时自动注册(同密码)以获取 token
|
|
138
138
|
async function fetchServerToken(username: string, password: string): Promise<void> {
|
|
139
139
|
try {
|
|
140
140
|
let res = await fetch(API + '/login', {
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// 仓库边界守卫:明确区分「公开区 / 内部区 / 打包区」,并拦截越界
|
|
3
|
-
//
|
|
4
|
-
// 公开区(git 跟踪 → GitHub 可见) :src/ server/src/ shared/ packages/ scripts/ tests/ .github/ README 等
|
|
5
|
-
// 内部区(仅本地,git 忽略 → 不可见):internal/(规划、监控、审计、SOP、草稿、真实案例)
|
|
6
|
-
// 打包区(npm publish 内容) :package.json `files` 白名单(必须是公开区子集)
|
|
7
|
-
//
|
|
8
|
-
// 用法:node scripts/check-boundary.mjs 退出码 0=边界干净;1=越界
|
|
9
|
-
// 接入:.github/workflows/ci.yml、scripts/preflight-release.sh
|
|
10
|
-
import { execFileSync } from 'node:child_process';
|
|
11
|
-
import fs from 'node:fs';
|
|
12
|
-
import path from 'node:path';
|
|
13
|
-
|
|
14
|
-
const tracked = execFileSync('git', ['ls-files'], { encoding: 'utf8' }).split('\n').filter(Boolean);
|
|
15
|
-
|
|
16
|
-
// ── 1) 路径级:公开区不得出现内部/敏感路径 ──
|
|
17
|
-
const FORBIDDEN_PATH = [
|
|
18
|
-
[/^internal\//, '内部区目录(应仅本地存在)'],
|
|
19
|
-
[/^docs\/.*\.md$/, '内部文档(内部文档应放 internal/docs/;docs/ 只留对外素材)'],
|
|
20
|
-
[/(^|\/)\.env(\.|$)/, '环境变量文件'],
|
|
21
|
-
[/\.(db|sqlite|sqlite3)$/, '运行时数据库'],
|
|
22
|
-
[/(^|\/)db\.json$/, '用户档案库'],
|
|
23
|
-
[/(^|\/)(id_rsa|.*\.pem)$/, '私钥'],
|
|
24
|
-
[/(^|\/)(\.audit|audit-tmp)/, '审计临时文件'],
|
|
25
|
-
[/^tests\/tmp-test-/, '本地临时测试(可能含真实案例)'],
|
|
26
|
-
];
|
|
27
|
-
const pathViolations = [];
|
|
28
|
-
for (const f of tracked) {
|
|
29
|
-
if (/\.example$/.test(f)) continue; // *.env.example 为公开模板(无值),可入库
|
|
30
|
-
for (const [re, why] of FORBIDDEN_PATH) if (re.test(f)) pathViolations.push(`${f} ← ${why}`);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// ── 2) 内容级:公开区文件不得含密钥/隐私特征(跳过二进制与压缩产物)──
|
|
34
|
-
const KEY_PATTERNS = [
|
|
35
|
-
[/sk-[A-Za-z0-9_-]{20,}/, '疑似 LLM API key'],
|
|
36
|
-
[/AIza[0-9A-Za-z_-]{30,}/, '疑似 Google API key'],
|
|
37
|
-
[/gsk_[A-Za-z0-9]{40,}/, '疑似 Groq key'],
|
|
38
|
-
[/gh[pousr]_[A-Za-z0-9]{30,}/, '疑似 GitHub token'],
|
|
39
|
-
[/_authToken\s*=\s*(?!\$\{)[A-Za-z0-9_.\-]{16,}/, 'npm 认证 token(真实值)'],
|
|
40
|
-
];
|
|
41
|
-
const SKIP_EXT = /\.(png|jpe?g|gif|ico|webp|woff2?|ttf|otf|pdf|zip|gz|mp4|db)$/i;
|
|
42
|
-
const contentViolations = [];
|
|
43
|
-
for (const f of tracked) {
|
|
44
|
-
if (SKIP_EXT.test(f)) continue;
|
|
45
|
-
let text;
|
|
46
|
-
try { if (fs.statSync(f).size > 4 * 1024 * 1024) continue; text = fs.readFileSync(f, 'utf8'); } catch { continue; }
|
|
47
|
-
for (const [re, why] of KEY_PATTERNS) if (re.test(text)) contentViolations.push(`${f} ← ${why}`);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// ── 3) 打包区:npm files 白名单必须是公开区子集 ──
|
|
51
|
-
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
|
|
52
|
-
const files = pkg.files || [];
|
|
53
|
-
const BAD_ALLOW = [
|
|
54
|
-
[/^internal/, '内部区不得进 npm 包'],
|
|
55
|
-
[/^docs/, 'docs/ 仅 GitHub 素材,无需进包'],
|
|
56
|
-
[/^tests/, '测试不入包'],
|
|
57
|
-
[/^\.github/, 'CI 配置不入包'],
|
|
58
|
-
[/^server\/src\/data/, '运行时数据不入包'],
|
|
59
|
-
];
|
|
60
|
-
const allowViolations = files.filter(f => BAD_ALLOW.some(([re]) => re.test(f))).map(f => `files 白名单含 ${f}`);
|
|
61
|
-
|
|
62
|
-
// ── 报告 ──
|
|
63
|
-
const count = (prefix) => tracked.filter(f => f === prefix || f.startsWith(prefix + '/')).length;
|
|
64
|
-
const dist = Object.fromEntries(['src', 'shared', 'server', 'packages', 'scripts', 'tests', '.github']
|
|
65
|
-
.map(d => [d, count(d)]).filter(([, n]) => n > 0));
|
|
66
|
-
|
|
67
|
-
console.log('公开区(git 跟踪):', tracked.length, '个文件');
|
|
68
|
-
console.log(' 顶层分布:', JSON.stringify(dist));
|
|
69
|
-
console.log(' 内部区(仅本地): internal/ 下', fs.existsSync('internal')
|
|
70
|
-
? execFileSync('find', ['internal', '-type', 'f'], { encoding: 'utf8' }).split('\n').filter(Boolean).length
|
|
71
|
-
: 0, '个文件(git 忽略,永不公开)');
|
|
72
|
-
console.log('打包区(npm files 白名单):', files.length, '条 → 内容由 scripts/check-package.mjs 二次校验');
|
|
73
|
-
|
|
74
|
-
const all = [...pathViolations, ...contentViolations, ...allowViolations];
|
|
75
|
-
if (all.length) {
|
|
76
|
-
console.error('\n✗ 边界守卫未通过:');
|
|
77
|
-
for (const v of all.slice(0, 20)) console.error(' -', v);
|
|
78
|
-
process.exit(1);
|
|
79
|
-
}
|
|
80
|
-
console.log('\n✅ 边界干净:公开区无内部内容,打包区是公开区子集');
|