Skip to content

Commit 216d9c3

Browse files
committed
feat(sovereignty): 世界之书导出/恢复与数据主权页——worldExport纯函数+BDD测试、主权IPC(校验先行+事务幂等导入+FTS重建)、我的世界设置区块(阶段D)
1 parent 2a3359f commit 216d9c3

7 files changed

Lines changed: 703 additions & 1 deletion

File tree

‎client/electron/main.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { initializeFTS, rebuildIndex, collectIndexableData } from './db/fts5Sear
3434
import { resolveDbPath } from './db/storageConfig.js';
3535
import { registerMigrationHandlers } from './db/migration.js';
3636
import { registerImportHandlers } from './importHandlers.js';
37+
import { registerSovereigntyHandlers } from './sovereigntyHandlers.js';
3738
import { loadEnvironment } from './envLoader.js';
3839
import { installCspPolicy } from './cspPolicy.js';
3940
import { registerDisplayMediaHandler } from './displayMediaHandler.js';
@@ -177,6 +178,8 @@ if (!gotTheLock) {
177178
registerStorageIpcHandlers();
178179
// 知识入籍 IPC(PDF 解析/URL 抓取/入籍记录,详见 importHandlers.ts)
179180
registerImportHandlers();
181+
// 世界主权 IPC(世界之书导出/恢复,详见 sovereigntyHandlers.ts)
182+
registerSovereigntyHandlers();
180183
// 知识星座 IPC(只读图谱聚合,详见 db/knowledgeQueries.ts)
181184
registerKnowledgeIpcHandlers();
182185
// MCP 学习记忆服务器应用内授权开关(详见 memoryServerConsent.ts)

‎client/electron/preload.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,12 +127,15 @@ const ALLOWED_CHANNELS = [
127127
'memory_server:set_consent',
128128
// 阶段 C:访问审计(最近 50 条)+ 宿主配置(三步引导第一步)
129129
'memory_server:get_access_log',
130-
'memory_server:get_host_config',
130+
'memory_server:get_host_config',
131131
// 知识入籍 IPC channel(阶段 A:PDF 解析/URL 抓取/入籍记录)
132132
'import:parse-pdf',
133133
'import:fetch-url',
134134
'import:get-settling-records',
135135
'import:add-settling-record',
136+
// 世界主权 IPC channel(阶段 D:世界之书导出/恢复)
137+
'sovereignty:export-world',
138+
'sovereignty:import-world',
136139
// 知识星座 IPC channel(阶段 B:只读图谱聚合)
137140
'knowledge:get-graph',
138141
] as const;
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
/**
2+
* 世界主权 IPC handlers(sovereignty:*,阶段 D 信任问题)
3+
* World sovereignty IPC handlers (export / restore)
4+
*
5+
* @ai-context: 「世界之书」的落盘与恢复。导出=知识图谱摘要
6+
* (memoryQueries.queryKnowledgeGraph,摘要级)+ 世界快照(world_snapshots
7+
* 单行 latest)+ 入籍记录(imports)+ 十张学习表完整行(恢复层),
8+
* 复用 worldExport 纯函数组装——bundle 不含任何 AI 密钥/网关配置。
9+
* 恢复=validateWorldImport Result 校验(版本/结构/节点≤5000/表白名单/
10+
* 总行数)→ 按白名单顺序重排 → 整体事务内循环 importTable
11+
* (INSERT OR REPLACE 幂等,better-sqlite3 嵌套事务自动降级 savepoint,
12+
* 任一表失败整体回滚)→ FTS 索引重建让搜索立即覆盖恢复内容。
13+
*
14+
* @ai-context: Export bundles the graph summary, world snapshot, settling
15+
* records and ten whitelisted tables (never any secrets). Restore validates
16+
* first, then imports in one transaction (idempotent INSERT OR REPLACE),
17+
* then rebuilds the FTS index.
18+
*/
19+
import { dialog } from 'electron';
20+
import { writeFile, readFile } from 'fs/promises';
21+
import { safeHandle } from './ipcUtils.js';
22+
import { logger } from './logger.js';
23+
import { getConnection } from './db/sqliteService.js';
24+
import { importTable } from './db/migration.js';
25+
import { queryKnowledgeGraph } from './mcp/memoryQueries.js';
26+
import { collectIndexableData, rebuildIndex } from './db/fts5Search.js';
27+
import {
28+
buildWorldExport,
29+
validateWorldImport,
30+
WORLD_TABLE_WHITELIST,
31+
type WorldTableBundle,
32+
} from '../src/features/sovereignty/lib/worldExport.js';
33+
34+
/** 快照单行 id(与 useWorldSnapshotSync 的 SNAPSHOT_ID 对齐) / Snapshot row id */
35+
const SNAPSHOT_ID = 'latest';
36+
/** 入籍记录导出上限(叙述层防超大响应;恢复层 tables.imports 不受限) */
37+
const MAX_RECORDS = 5000;
38+
39+
/** 恢复层表白名单(父表在前,满足外键约束;与 worldExport 白名单同源) */
40+
const EXPORT_TABLES = WORLD_TABLE_WHITELIST;
41+
42+
/** 读取单表全量行(表名来自编译期白名单,非用户输入) / Read all rows of a whitelisted table */
43+
function readTableRows(db: ReturnType<typeof getConnection>, table: string): Array<Record<string, unknown>> {
44+
return db.prepare(`SELECT * FROM "${table}"`).all() as Array<Record<string, unknown>>;
45+
}
46+
47+
/**
48+
* 注册世界主权 IPC handlers(app ready 后调用一次)
49+
*/
50+
export function registerSovereigntyHandlers(): void {
51+
/**
52+
* sovereignty:export-world — 导出世界之书到 JSON 文件。
53+
* 保存对话框由主进程弹出;导出内容不含任何密钥/配置。
54+
*/
55+
safeHandle('sovereignty:export-world', async () => {
56+
try {
57+
const db = getConnection();
58+
59+
// 叙述层:图谱摘要(摘要级:概念前 60 字 + 档位 + 关联数)
60+
const graph = queryKnowledgeGraph(db, 100);
61+
// 叙述层:世界快照(world_snapshots 单行 latest 的 payload 对象)
62+
const snapshotRow = db.prepare(
63+
`SELECT id, payload FROM world_snapshots WHERE id = ?`,
64+
).get(SNAPSHOT_ID) as { payload: string } | undefined;
65+
// 叙述层:入籍记录(倒序,上限防超大)
66+
const records = db.prepare(
67+
`SELECT id, source, raw_name, concept_count, settled_at
68+
FROM imports ORDER BY settled_at DESC LIMIT ?`,
69+
).all(MAX_RECORDS) as Array<Record<string, unknown>>;
70+
// 恢复层:十张白名单表完整行(外键顺序)
71+
const tables: WorldTableBundle[] = EXPORT_TABLES.map((table) => ({
72+
table,
73+
rows: readTableRows(db, table),
74+
}));
75+
76+
const bundle = buildWorldExport(graph, snapshotRow ?? null, records, {
77+
exportedAt: new Date().toISOString(),
78+
tables,
79+
});
80+
81+
const defaultName = `entropy-world-${new Date().toISOString().slice(0, 10)}.json`;
82+
const result = await dialog.showSaveDialog({
83+
title: '导出我的世界',
84+
defaultPath: defaultName,
85+
filters: [
86+
{ name: '世界之书', extensions: ['json'] },
87+
{ name: '所有文件', extensions: ['*'] },
88+
],
89+
});
90+
if (result.canceled || !result.filePath) {
91+
return { success: false, canceled: true, path: null };
92+
}
93+
94+
await writeFile(result.filePath, JSON.stringify(bundle, null, 2), 'utf-8');
95+
const rowCount = tables.reduce((s, t) => s + t.rows.length, 0);
96+
const nodeCount = Array.isArray(bundle.graph.nodes) ? bundle.graph.nodes.length : 0;
97+
logger.info(`[Sovereignty] World exported to ${result.filePath} (${rowCount} rows, ${nodeCount} nodes)`);
98+
return { success: true, canceled: false, path: result.filePath };
99+
} catch (err) {
100+
const msg = err instanceof Error ? err.message : String(err);
101+
logger.error('[Sovereignty] Export failed', err);
102+
return { success: false, canceled: false, path: null, error: `世界导出失败:${msg}` };
103+
}
104+
});
105+
106+
/**
107+
* sovereignty:import-world — 从 JSON 文件恢复世界。
108+
* 校验先行(Result 模式),通过后整体事务幂等导入。
109+
*/
110+
safeHandle('sovereignty:import-world', async () => {
111+
const result = await dialog.showOpenDialog({
112+
title: '恢复我的世界',
113+
filters: [
114+
{ name: '世界之书', extensions: ['json'] },
115+
{ name: '所有文件', extensions: ['*'] },
116+
],
117+
properties: ['openFile'],
118+
});
119+
if (result.canceled || result.filePaths.length === 0) {
120+
return { success: false, canceled: true };
121+
}
122+
123+
try {
124+
const text = await readFile(result.filePaths[0], 'utf-8');
125+
let parsed: unknown;
126+
try {
127+
parsed = JSON.parse(text);
128+
} catch {
129+
return { success: false, error: '文件不是有效的 JSON,无法恢复' };
130+
}
131+
132+
const check = validateWorldImport(parsed);
133+
if (!check.ok) {
134+
logger.warn(`[Sovereignty] Import rejected: ${check.error}`);
135+
return { success: false, error: check.error };
136+
}
137+
138+
// 按白名单顺序重排(防御手改文件打乱顺序破坏外键约束)
139+
const ordered = [...check.bundle.tables].sort(
140+
(a, b) => EXPORT_TABLES.indexOf(a.table) - EXPORT_TABLES.indexOf(b.table),
141+
);
142+
143+
const db = getConnection();
144+
let rowsImported = 0;
145+
// 整体事务:importTable 内部嵌套事务自动降级 savepoint,任一表失败整体回滚
146+
const txn = db.transaction((tables: WorldTableBundle[]) => {
147+
for (const t of tables) {
148+
rowsImported += importTable(t.table, t.rows);
149+
}
150+
});
151+
txn(ordered);
152+
153+
// FTS 索引重建:让搜索立即覆盖恢复的笔记/卡片
154+
try {
155+
rebuildIndex(collectIndexableData(db));
156+
} catch (ftsErr) {
157+
logger.warn(`[Sovereignty] FTS rebuild after restore failed: ${ftsErr instanceof Error ? ftsErr.message : String(ftsErr)}`);
158+
}
159+
160+
logger.info(`[Sovereignty] World restored from ${result.filePaths[0]} (${rowsImported} rows)`);
161+
return {
162+
success: true,
163+
rowsImported,
164+
tables: ordered.map((t) => ({ table: t.table, rows: t.rows.length })),
165+
};
166+
} catch (err) {
167+
const msg = err instanceof Error ? err.message : String(err);
168+
logger.error('[Sovereignty] Import failed', err);
169+
return { success: false, error: `世界恢复失败:${msg}` };
170+
}
171+
});
172+
}

0 commit comments

Comments
 (0)