feat: 持久化从 JSON 迁移到 DuckDB,启动自动迁移存量数据并全面异步化
build-and-deploy / build-and-deploy (push) Successful in 37s

- 新增 src/persistence/duckdbAdapter:与 jsonAdapter 同接口的异步适配器,
  数据存于 data/registry.duckdb,首次启动自动从 JSON 迁移并备份 json-backup-<ts>
- 全部服务层(project/config/user/token)与路由改为 async/await
- MCP 工具与鉴权同步异步化,系统 mode 读写迁移至 DuckDB
- 服务启动流程:init DB -> 自动迁移 -> 确保默认管理员 -> listen
- 新增 DuckDB 往返/迁移测试,测试目录隔离 REGISTRY_DATA_DIR
This commit is contained in:
jif2.zhang
2026-09-14 10:35:38 +08:00
parent 2ba09fa7a1
commit 6bbd4bc471
19 changed files with 958 additions and 302 deletions
+4
View File
@@ -5,5 +5,9 @@ data/configs/*.json
data/mcp.json
data/users.json
data/api-tokens.json
data/registry.duckdb
data/registry.duckdb.wal
data/registry.duckdb.tmp
data/json-backup-*/
*.log
.reasonix/*
+154
View File
@@ -9,10 +9,155 @@
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"@duckdb/node-api": "^1.5.5-r.5",
"@modelcontextprotocol/sdk": "^1.30.0",
"express": "^4.18.2"
}
},
"node_modules/@duckdb/node-api": {
"version": "1.5.5-r.5",
"resolved": "https://registry.npmjs.org/@duckdb/node-api/-/node-api-1.5.5-r.5.tgz",
"integrity": "sha512-ZMX+sel3DjSKbGuQ44Y5Tp5LwYjlJkhB5faKr+9sSMcxixDMSW+8vaTOD1uRDuN9o0udUoCY/XX9PI1haEZuZg==",
"license": "MIT",
"dependencies": {
"@duckdb/node-bindings": "1.5.5-r.5"
}
},
"node_modules/@duckdb/node-bindings": {
"version": "1.5.5-r.5",
"resolved": "https://registry.npmjs.org/@duckdb/node-bindings/-/node-bindings-1.5.5-r.5.tgz",
"integrity": "sha512-sR7x91TfBunLqLbdvrcdiPCU+VGEQFPicxh1STZM402fuHW2ituPTAo2bY3IlntsvJShltrKF4R6B+JYPlAPZg==",
"license": "MIT",
"dependencies": {
"detect-libc": "^2.1.2"
},
"optionalDependencies": {
"@duckdb/node-bindings-darwin-arm64": "1.5.5-r.5",
"@duckdb/node-bindings-darwin-x64": "1.5.5-r.5",
"@duckdb/node-bindings-linux-arm64": "1.5.5-r.5",
"@duckdb/node-bindings-linux-arm64-musl": "1.5.5-r.5",
"@duckdb/node-bindings-linux-x64": "1.5.5-r.5",
"@duckdb/node-bindings-linux-x64-musl": "1.5.5-r.5",
"@duckdb/node-bindings-win32-arm64": "1.5.5-r.5",
"@duckdb/node-bindings-win32-x64": "1.5.5-r.5"
}
},
"node_modules/@duckdb/node-bindings-darwin-arm64": {
"version": "1.5.5-r.5",
"resolved": "https://registry.npmjs.org/@duckdb/node-bindings-darwin-arm64/-/node-bindings-darwin-arm64-1.5.5-r.5.tgz",
"integrity": "sha512-WJdeLTFy4pRg60rkbp9smtlP1iIQ8ErtIJkxS+2LQtM1ZZMSByyW+3WPpNoKe7IwPtVl2fLlPw7tDmQW4Z974g==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@duckdb/node-bindings-darwin-x64": {
"version": "1.5.5-r.5",
"resolved": "https://registry.npmjs.org/@duckdb/node-bindings-darwin-x64/-/node-bindings-darwin-x64-1.5.5-r.5.tgz",
"integrity": "sha512-rN0KjX13MwVQ5V5ufykolty/tf4SaafOpGWcUA1Cegf98jPeRdsJsEE1IMXn8n3u6WvMSZEVJmHJ3ma4Hr7Vzg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@duckdb/node-bindings-linux-arm64": {
"version": "1.5.5-r.5",
"resolved": "https://registry.npmjs.org/@duckdb/node-bindings-linux-arm64/-/node-bindings-linux-arm64-1.5.5-r.5.tgz",
"integrity": "sha512-CafH2AIvXtpwDynG3KB/4uEOHRGlFoB7auCRMIwH3BKNIobsANV5LSpDJ3tfyRCFIsK6e8AT1eseWVnu2Dv3cw==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@duckdb/node-bindings-linux-arm64-musl": {
"version": "1.5.5-r.5",
"resolved": "https://registry.npmjs.org/@duckdb/node-bindings-linux-arm64-musl/-/node-bindings-linux-arm64-musl-1.5.5-r.5.tgz",
"integrity": "sha512-00z3mYHUBAucp3L73EoftKDdaGOaV8ENELejiT+PK7dFjTzdXxqhJwZ1LaQV1/zEhIecz+x23f6WvFhWw+7XXQ==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@duckdb/node-bindings-linux-x64": {
"version": "1.5.5-r.5",
"resolved": "https://registry.npmjs.org/@duckdb/node-bindings-linux-x64/-/node-bindings-linux-x64-1.5.5-r.5.tgz",
"integrity": "sha512-kCyq6BXhn6QXfwF8yVdVKriSSHP+rfVWcJ56cG8qZzlOZpxyvT5WGm6jMshxg4ditLlOsHsIN/D47RYrcjXuHQ==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@duckdb/node-bindings-linux-x64-musl": {
"version": "1.5.5-r.5",
"resolved": "https://registry.npmjs.org/@duckdb/node-bindings-linux-x64-musl/-/node-bindings-linux-x64-musl-1.5.5-r.5.tgz",
"integrity": "sha512-S5qEc7F+GjXt85T2xArjjvN5oc/IjtH6zsLJsuo8N3fQolheBCefemIMdZoK2Jik8a0sBxidiS6Ld4qprMPPDw==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@duckdb/node-bindings-win32-arm64": {
"version": "1.5.5-r.5",
"resolved": "https://registry.npmjs.org/@duckdb/node-bindings-win32-arm64/-/node-bindings-win32-arm64-1.5.5-r.5.tgz",
"integrity": "sha512-L3Mroc23xhqOowWVElni8LgEmsm5e9DdG1y2pLka/1ug3Pl2vpQMl1Phxwz4UEMF8DOBbMgN5BBNrYwG2BFo+g==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@duckdb/node-bindings-win32-x64": {
"version": "1.5.5-r.5",
"resolved": "https://registry.npmjs.org/@duckdb/node-bindings-win32-x64/-/node-bindings-win32-x64-1.5.5-r.5.tgz",
"integrity": "sha512-mBGFn8netT/AAhK4TOG6Fw47+Db68CxCVtQ9qfxZc1kZAjds+aYrh17Fd/u5CbTDdxILsrcyuF8q9cUcm54kQA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@hono/node-server": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/@hono/node-server/-/node-server-2.1.1.tgz",
@@ -628,6 +773,15 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
+1
View File
@@ -15,6 +15,7 @@
],
"license": "MIT",
"dependencies": {
"@duckdb/node-api": "^1.5.5-r.5",
"@modelcontextprotocol/sdk": "^1.30.0",
"express": "^4.18.2"
}
+6 -6
View File
@@ -11,7 +11,7 @@ const {
} = require("@modelcontextprotocol/sdk/server/streamableHttp.js");
const { isInitializeRequest } = require("@modelcontextprotocol/sdk/types.js");
const { createMcpServer } = require("./tools");
const { loadMcpConfig } = require("../persistence/jsonAdapter");
const { loadMcpConfig } = require("../persistence/duckdbAdapter");
const router = express.Router();
@@ -21,10 +21,10 @@ const transports = new Map();
/**
* 校验请求携带的 Bearer token 是否与配置中的 token 匹配
* @param {string} [header] - Authorization 请求头
* @returns {boolean} 是否鉴权通过
* @returns {Promise<boolean>} 是否鉴权通过
*/
function isAuthorized(header) {
const mcp = loadMcpConfig();
async function isAuthorized(header) {
const mcp = await loadMcpConfig();
// 未配置 token 时视为未启用鉴权
if (!mcp.token) {
return true;
@@ -43,8 +43,8 @@ function isAuthorized(header) {
/**
* 鉴权中间件:token 校验失败返回 401
*/
router.use((req, res, next) => {
if (!isAuthorized(req.headers.authorization)) {
router.use(async (req, res, next) => {
if (!(await isAuthorized(req.headers.authorization))) {
return res
.status(401)
.json({ jsonrpc: "2.0", error: { code: -32001, message: "Unauthorized: invalid or missing token" }, id: null });
+35 -35
View File
@@ -11,7 +11,7 @@ const z = require("zod/v4");
const { McpServer } = require("@modelcontextprotocol/sdk/server/mcp.js");
const projectService = require("../services/projectService");
const configService = require("../services/configService");
const { loadSystem, loadMcpConfig } = require("../persistence/jsonAdapter");
const { loadSystem, loadMcpConfig } = require("../persistence/duckdbAdapter");
// 待确认删除的登记表:token -> { type, projectId, configId, expiresAt }
const pendingDeletions = new Map();
@@ -20,11 +20,11 @@ const PENDING_TTL = 5 * 60 * 1000;
/**
* 获取当前系统模式
* @returns {string} plan/edit/readonly
* @returns {Promise<string>} plan/edit/readonly
*/
function currentMode() {
async function currentMode() {
try {
return (loadSystem() || {}).mode || "edit";
return (await loadSystem() || {}).mode || "edit";
} catch {
return "edit";
}
@@ -34,8 +34,8 @@ function currentMode() {
* 只读模式下拒绝写操作
* @throws {Error} 只读模式
*/
function assertWritable() {
if (currentMode() === "readonly") {
async function assertWritable() {
if ((await await currentMode()) === "readonly") {
throw new Error("系统当前为只读模式,不允许写操作");
}
}
@@ -145,8 +145,8 @@ function createMcpServer() {
inputSchema: {},
},
async () => ({
content: [{ type: "text", text: `当前模式:${currentMode()}` }],
structuredContent: { mode: currentMode() },
content: [{ type: "text", text: `当前模式:${await currentMode()}` }],
structuredContent: { mode: await currentMode() },
})
);
@@ -159,10 +159,10 @@ function createMcpServer() {
},
},
async ({ mode }) => {
const { loadSystem, saveSystem } = require("../persistence/jsonAdapter");
const system = loadSystem() || {};
const { loadSystem, saveSystem } = require("../persistence/duckdbAdapter");
const system = await loadSystem() || {};
system.mode = mode;
saveSystem(system);
await saveSystem(system);
return {
content: [{ type: "text", text: `模式已切换为:${mode}` }],
structuredContent: { mode },
@@ -180,9 +180,9 @@ function createMcpServer() {
},
async () => ({
content: [
{ type: "text", text: JSON.stringify(projectService.getAllProjects(), null, 2) },
{ type: "text", text: JSON.stringify(await projectService.getAllProjects(), null, 2) },
],
structuredContent: { projects: projectService.getAllProjects() },
structuredContent: { projects: await projectService.getAllProjects() },
})
);
@@ -195,7 +195,7 @@ function createMcpServer() {
},
},
async ({ projectId }) => {
const project = projectService.getProjectById(projectId);
const project = await projectService.getProjectById(projectId);
if (!project) {
throw new Error(`项目不存在:${projectId}`);
}
@@ -223,8 +223,8 @@ function createMcpServer() {
},
},
async (args) => {
assertWritable();
const project = projectService.createProject(args);
await assertWritable();
const project = await projectService.createProject(args);
return {
content: [{ type: "text", text: JSON.stringify(project, null, 2) }],
structuredContent: { project },
@@ -244,8 +244,8 @@ function createMcpServer() {
},
},
async ({ projectId, idcMapping, environmentMapping, groupMapping }) => {
assertWritable();
const project = projectService.updateMappings(projectId, {
await assertWritable();
const project = await projectService.updateMappings(projectId, {
idcMapping,
environmentMapping,
groupMapping,
@@ -267,8 +267,8 @@ function createMcpServer() {
},
},
async ({ projectId }) => {
assertWritable();
const project = projectService.getProjectById(projectId);
await assertWritable();
const project = await projectService.getProjectById(projectId);
if (!project) {
throw new Error(`项目不存在:${projectId}`);
}
@@ -291,7 +291,7 @@ function createMcpServer() {
},
},
async ({ confirmation_token }) => {
assertWritable();
await assertWritable();
const entry = takePendingDeletion(confirmation_token);
if (!entry) {
return {
@@ -309,7 +309,7 @@ function createMcpServer() {
isError: true,
};
}
const ok = projectService.deleteProject(entry.projectId);
const ok = await projectService.deleteProject(entry.projectId);
if (!ok) {
return {
content: [{ type: "text", text: `项目不存在或已删除:${entry.projectId}` }],
@@ -335,7 +335,7 @@ function createMcpServer() {
},
},
async ({ projectId }) => {
const configs = configService.getConfigsByProject(projectId);
const configs = await configService.getConfigsByProject(projectId);
return {
content: [{ type: "text", text: JSON.stringify(configs, null, 2) }],
structuredContent: { configs },
@@ -353,7 +353,7 @@ function createMcpServer() {
},
},
async ({ projectId, configId }) => {
const config = configService.getConfigById(projectId, configId);
const config = await configService.getConfigById(projectId, configId);
if (!config) {
throw new Error(`配置项不存在:${configId}`);
}
@@ -386,10 +386,10 @@ function createMcpServer() {
},
},
async (args) => {
assertWritable();
await assertWritable();
const { projectId, ...data } = args;
const mode = currentMode();
const config = configService.createConfig(projectId, data, mode);
const mode = await currentMode();
const config = await configService.createConfig(projectId, data, mode);
return {
content: [{ type: "text", text: JSON.stringify(config, null, 2) }],
structuredContent: { config },
@@ -420,7 +420,7 @@ function createMcpServer() {
},
},
async (args) => {
assertWritable();
await assertWritable();
const { projectId, configId, ...data } = args;
const picks = {};
for (const field of [
@@ -431,8 +431,8 @@ function createMcpServer() {
picks[field] = data[field];
}
}
const mode = currentMode();
const config = configService.updateConfig(projectId, configId, picks, mode);
const mode = await currentMode();
const config = await configService.updateConfig(projectId, configId, picks, mode);
return {
content: [{ type: "text", text: JSON.stringify(config, null, 2) }],
structuredContent: { config },
@@ -451,8 +451,8 @@ function createMcpServer() {
},
},
async ({ projectId, configId }) => {
assertWritable();
const config = configService.getConfigById(projectId, configId);
await assertWritable();
const config = await configService.getConfigById(projectId, configId);
if (!config) {
throw new Error(`配置项不存在:${configId}`);
}
@@ -475,7 +475,7 @@ function createMcpServer() {
},
},
async ({ confirmation_token }) => {
assertWritable();
await assertWritable();
const entry = takePendingDeletion(confirmation_token);
if (!entry) {
return {
@@ -493,8 +493,8 @@ function createMcpServer() {
isError: true,
};
}
const mode = currentMode();
const ok = configService.deleteConfig(entry.projectId, entry.configId, mode);
const mode = await currentMode();
const ok = await configService.deleteConfig(entry.projectId, entry.configId, mode);
if (!ok) {
return {
content: [{ type: "text", text: `配置项不存在或已删除:${entry.configId}` }],
+305
View File
@@ -0,0 +1,305 @@
/**
* DuckDB 持久化适配器
* 与 jsonAdapter 暴露相同的异步接口,数据存储在 data/registry.duckdb
* 首次启动(DB 文件不存在)时自动从既有 JSON 文件迁移存量数据,并备份 JSON 为 *.bak
*/
const fs = require("fs");
const path = require("path");
const { DuckDBInstance } = require("@duckdb/node-api");
// 数据存储根目录(支持 REGISTRY_DATA_DIR 覆盖,测试隔离用)
const DATA_DIR = process.env.REGISTRY_DATA_DIR || path.join(__dirname, "..", "..", "data");
const DB_FILE = path.join(DATA_DIR, "registry.duckdb");
const JSON_FILES = {
projects: "projects.json",
system: "system.json",
mcp: "mcp.json",
users: "users.json",
apiTokens: "api-tokens.json",
configs: null, // 配置项存于 configs/<projectId>.json
};
let connection = null;
/**
* 确保数据目录存在
*/
function ensureDataDir() {
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
}
/**
* 创建表结构(幂等)
* 使用 JSON 列存储完整记录,value 异构类型无损保留
*/
async function createTables() {
await connection.run(`
CREATE TABLE IF NOT EXISTS projects (
id VARCHAR PRIMARY KEY,
data JSON NOT NULL
);
CREATE TABLE IF NOT EXISTS configs (
project_id VARCHAR,
id VARCHAR,
data JSON NOT NULL,
PRIMARY KEY (project_id, id)
);
CREATE TABLE IF NOT EXISTS users (
id VARCHAR PRIMARY KEY,
data JSON NOT NULL
);
CREATE TABLE IF NOT EXISTS api_tokens (
id VARCHAR PRIMARY KEY,
data JSON NOT NULL
);
CREATE TABLE IF NOT EXISTS system_kv (
key VARCHAR PRIMARY KEY,
data JSON NOT NULL
);
CREATE TABLE IF NOT EXISTS mcp_kv (
key VARCHAR PRIMARY KEY,
data JSON NOT NULL
);
`);
}
/**
* 读取 JSON 文件,返回解析后对象或 null
*/
function readJsonFile(filePath) {
if (!fs.existsSync(filePath)) return null;
const content = fs.readFileSync(filePath, "utf-8");
return JSON.parse(content);
}
/**
* 读取 JSON 外层数组/对象并包裹为标准 JSON 字符串(供入库)
*/
function jsonStringifyList(list) {
return JSON.stringify(list);
}
async function insertRows(table, rows) {
if (!rows || rows.length === 0) return;
for (const r of rows) {
await connection.run(`INSERT INTO ${table} (id, data) VALUES (?, ?)`, [r.id, JSON.stringify(r)]);
}
}
/**
* 解析查询结果中的 JSON 列(DuckDB 可能以字符串或对象形式返回)
*/
function parseData(data) {
return typeof data === "string" ? JSON.parse(data) : data;
}
/**
* 从 JSON 文件迁移指定数据到 DuckDB
*/
async function migrateCollection(name, filePath, table, listToRows) {
if (!fs.existsSync(filePath)) return 0;
const raw = readJsonFile(filePath);
const rows = listToRows ? listToRows(raw) : raw;
await insertRows(table, rows);
return Array.isArray(rows) ? rows.length : 1;
}
/**
* 一键迁移所有 JSON 存量数据
* 迁移成功后把 JSON 改为 *.bak 备份,避免二次导入
*/
async function migrateAllFromJson() {
console.log("[duckdb] 首次启动,从 JSON 迁移存量数据...");
const results = {};
results.projects = await migrateCollection("projects", path.join(DATA_DIR, "projects.json"), "projects", (list) => list || []);
results.users = await migrateCollection("users", path.join(DATA_DIR, "users.json"), "users", (list) => list || []);
results.apiTokens = await migrateCollection("apiTokens", path.join(DATA_DIR, "api-tokens.json"), "api_tokens", (list) => list || []);
// 系统配置:system.json -> system_kv (key='system')
const system = readJsonFile(path.join(DATA_DIR, "system.json"));
if (system) {
await connection.run(`INSERT INTO system_kv (key, data) VALUES ('system', ?)`, [JSON.stringify(system)]);
results.system = 1;
}
// MCP 配置:mcp.json -> mcp_kv (key='mcp')
const mcp = readJsonFile(path.join(DATA_DIR, "mcp.json"));
if (mcp) {
await connection.run(`INSERT INTO mcp_kv (key, data) VALUES ('mcp', ?)`, [JSON.stringify(mcp)]);
results.mcp = 1;
}
// 配置项:configs/<projectId>.json -> configs
const configsDir = path.join(DATA_DIR, "configs");
let configCount = 0;
if (fs.existsSync(configsDir)) {
for (const file of fs.readdirSync(configsDir)) {
if (!file.endsWith(".json")) continue;
const projectId = file.replace(/\.json$/, "");
const configs = readJsonFile(path.join(configsDir, file)) || [];
for (const cfg of configs) {
await connection.run(`INSERT INTO configs (project_id, id, data) VALUES (?, ?, ?)`, [projectId, cfg.id, JSON.stringify(cfg)]);
configCount++;
}
}
}
results.configs = configCount;
console.log("[duckdb] 迁移完成:", JSON.stringify(results));
// 迁移成功后备份 JSON(保留合理回滚余地):统一改为 data/json-backup-<ts>/
if (fs.existsSync(path.join(DATA_DIR, "projects.json")) ||
fs.existsSync(path.join(DATA_DIR, "users.json")) ||
fs.existsSync(path.join(DATA_DIR, "system.json"))) {
const backupDir = path.join(DATA_DIR, "json-backup-" + Date.now());
fs.mkdirSync(backupDir, { recursive: true });
for (const name of ["projects.json", "system.json", "mcp.json", "users.json", "api-tokens.json"]) {
const src = path.join(DATA_DIR, name);
if (fs.existsSync(src)) fs.renameSync(src, path.join(backupDir, name));
}
const cfgSrc = path.join(DATA_DIR, "configs");
if (fs.existsSync(cfgSrc)) {
const files = fs.readdirSync(cfgSrc);
if (files.length) {
fs.mkdirSync(path.join(backupDir, "configs"), { recursive: true });
for (const f of files) fs.renameSync(path.join(cfgSrc, f), path.join(backupDir, "configs", f));
}
}
console.log(`[duckdb] JSON 已备份至 ${backupDir}`);
}
}
/**
* 初始化 DuckDB:建库建表,必要时自动迁移
*/
async function init() {
if (connection) return;
ensureDataDir();
const dbExists = fs.existsSync(DB_FILE);
const instance = await DuckDBInstance.create(DB_FILE);
connection = await instance.connect();
await createTables();
if (!dbExists) {
await migrateAllFromJson();
}
return connection;
}
// ==================== projects ====================
async function loadProjects() {
await init();
const reader = await connection.runAndReadAll("SELECT data FROM projects");
return reader.getRowObjects().map((r) => parseData(r.data));
}
async function saveProjects(projects) {
await init();
await connection.run("DELETE FROM projects");
await insertRows("projects", projects);
}
// ==================== configs ====================
async function loadConfigs(projectId) {
await init();
const reader = await connection.runAndReadAll("SELECT data FROM configs WHERE project_id = ?", [projectId]);
return reader.getRowObjects().map((r) => parseData(r.data));
}
async function saveConfigs(projectId, configs) {
await init();
await connection.run("DELETE FROM configs WHERE project_id = ?", [projectId]);
for (const c of configs || []) {
await connection.run(
`INSERT INTO configs (project_id, id, data) VALUES (?, ?, ?)`,
[projectId, c.id, JSON.stringify(c)]
);
}
}
async function deleteConfigs(projectId) {
await init();
await connection.run("DELETE FROM configs WHERE project_id = ?", [projectId]);
}
// ==================== system / mcp ====================
async function loadSystem() {
await init();
const reader = await connection.runAndReadAll("SELECT data FROM system_kv WHERE key = 'system'");
const row = reader.getRowObjects()[0];
return row ? parseData(row.data) : { mode: "edit" };
}
async function saveSystem(system) {
await init();
await connection.run(
`INSERT INTO system_kv (key, data) VALUES ('system', ?)
ON CONFLICT (key) DO UPDATE SET data = excluded.data`,
[JSON.stringify(system)]
);
}
async function loadMcpConfig() {
await init();
const reader = await connection.runAndReadAll("SELECT data FROM mcp_kv WHERE key = 'mcp'");
const row = reader.getRowObjects()[0];
return row ? parseData(row.data) : { enabled: true, token: "" };
}
async function saveMcpConfig(mcp) {
await init();
await connection.run(
`INSERT INTO mcp_kv (key, data) VALUES ('mcp', ?)
ON CONFLICT (key) DO UPDATE SET data = excluded.data`,
[JSON.stringify(mcp)]
);
}
// ==================== users / api tokens ====================
async function loadUsers() {
await init();
const reader = await connection.runAndReadAll("SELECT data FROM users");
return reader.getRowObjects().map((r) => parseData(r.data));
}
async function saveUsers(users) {
await init();
await connection.run("DELETE FROM users");
await insertRows("users", users);
}
async function loadApiTokens() {
await init();
const reader = await connection.runAndReadAll("SELECT data FROM api_tokens");
return reader.getRowObjects().map((r) => parseData(r.data));
}
async function saveApiTokens(tokens) {
await init();
await connection.run("DELETE FROM api_tokens");
await insertRows("api_tokens", tokens);
}
module.exports = {
init,
loadProjects,
saveProjects,
loadConfigs,
saveConfigs,
deleteConfigs,
loadSystem,
saveSystem,
loadMcpConfig,
saveMcpConfig,
loadUsers,
saveUsers,
loadApiTokens,
saveApiTokens,
};
+2 -2
View File
@@ -63,13 +63,13 @@ function destroySession(token) {
* POST /api/auth/login
* Body: { username, password }
*/
router.post("/login", (req, res) => {
router.post("/login", async (req, res) => {
try {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ success: false, message: "用户名和密码不能为空" });
}
const user = userService.authenticate(username, password);
const user = await userService.authenticate(username, password);
if (!user) {
return res.status(401).json({ success: false, message: "用户名或密码错误" });
}
+17 -17
View File
@@ -5,15 +5,15 @@
const express = require("express");
const router = express.Router({ mergeParams: true });
const configService = require("../services/configService");
const { loadSystem, saveSystem } = require("../persistence/jsonAdapter");
const { loadSystem, saveSystem } = require("../persistence/duckdbAdapter");
/**
* 获取当前模式
* @returns {string} 当前模式
* @returns {Promise<string>} 当前模式
*/
function getCurrentMode() {
async function getCurrentMode() {
try {
const system = loadSystem();
const system = await loadSystem();
return system.mode || "edit";
} catch {
return "edit";
@@ -24,9 +24,9 @@ function getCurrentMode() {
* GET /api/projects/:projectId/configs
* 获取项目下所有配置项
*/
router.get("/", (req, res) => {
router.get("/", async (req, res) => {
try {
const configs = configService.getConfigsByProject(req.params.projectId);
const configs = await configService.getConfigsByProject(req.params.projectId);
res.json({ success: true, data: configs });
} catch (err) {
res.status(400).json({ success: false, message: err.message });
@@ -37,9 +37,9 @@ router.get("/", (req, res) => {
* GET /api/projects/:projectId/configs/:configId
* 获取单个配置项
*/
router.get("/:configId", (req, res) => {
router.get("/:configId", async (req, res) => {
try {
const config = configService.getConfigById(req.params.projectId, req.params.configId);
const config = await configService.getConfigById(req.params.projectId, req.params.configId);
if (!config) {
return res.status(404).json({ success: false, message: "配置项不存在" });
}
@@ -53,10 +53,10 @@ router.get("/:configId", (req, res) => {
* POST /api/projects/:projectId/configs
* 新增配置项
*/
router.post("/", (req, res) => {
router.post("/", async (req, res) => {
try {
const mode = getCurrentMode();
const config = configService.createConfig(req.params.projectId, req.body, mode);
const mode = await getCurrentMode();
const config = await configService.createConfig(req.params.projectId, req.body, mode);
res.status(201).json({ success: true, data: config });
} catch (err) {
res.status(400).json({ success: false, message: err.message });
@@ -67,10 +67,10 @@ router.post("/", (req, res) => {
* PUT /api/projects/:projectId/configs/:configId
* 修改配置项
*/
router.put("/:configId", (req, res) => {
router.put("/:configId", async (req, res) => {
try {
const mode = getCurrentMode();
const config = configService.updateConfig(req.params.projectId, req.params.configId, req.body, mode);
const mode = await getCurrentMode();
const config = await configService.updateConfig(req.params.projectId, req.params.configId, req.body, mode);
res.json({ success: true, data: config });
} catch (err) {
res.status(400).json({ success: false, message: err.message });
@@ -81,10 +81,10 @@ router.put("/:configId", (req, res) => {
* DELETE /api/projects/:projectId/configs/:configId
* 删除配置项
*/
router.delete("/:configId", (req, res) => {
router.delete("/:configId", async (req, res) => {
try {
const mode = getCurrentMode();
const result = configService.deleteConfig(req.params.projectId, req.params.configId, mode);
const mode = await getCurrentMode();
const result = await configService.deleteConfig(req.params.projectId, req.params.configId, mode);
if (!result) {
return res.status(404).json({ success: false, message: "配置项不存在" });
}
+12 -12
View File
@@ -10,9 +10,9 @@ const projectService = require("../services/projectService");
* GET /api/projects
* 获取所有项目列表
*/
router.get("/", (req, res) => {
router.get("/", async (req, res) => {
try {
const projects = projectService.getAllProjects();
const projects = await projectService.getAllProjects();
res.json({ success: true, data: projects });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
@@ -23,9 +23,9 @@ router.get("/", (req, res) => {
* GET /api/projects/:id
* 获取单个项目详情
*/
router.get("/:id", (req, res) => {
router.get("/:id", async (req, res) => {
try {
const project = projectService.getProjectById(req.params.id);
const project = await projectService.getProjectById(req.params.id);
if (!project) {
return res.status(404).json({ success: false, message: "项目不存在" });
}
@@ -40,9 +40,9 @@ router.get("/:id", (req, res) => {
* 创建新项目
* Body: { name, description, enableIdc, enableEnvironment, enableGroup, idcMapping, environmentMapping, groupMapping }
*/
router.post("/", (req, res) => {
router.post("/", async (req, res) => {
try {
const project = projectService.createProject(req.body);
const project = await projectService.createProject(req.body);
res.status(201).json({ success: true, data: project });
} catch (err) {
res.status(400).json({ success: false, message: err.message });
@@ -54,9 +54,9 @@ router.post("/", (req, res) => {
* 更新项目基本信息(重命名/修改描述)
* Body: { name, description }
*/
router.put("/:id", (req, res) => {
router.put("/:id", async (req, res) => {
try {
const project = projectService.updateProject(req.params.id, req.body);
const project = await projectService.updateProject(req.params.id, req.body);
res.json({ success: true, data: project });
} catch (err) {
res.status(400).json({ success: false, message: err.message });
@@ -68,9 +68,9 @@ router.put("/:id", (req, res) => {
* 更新项目的维度映射方式(path/label)
* Body: { idcMapping, environmentMapping, groupMapping }
*/
router.put("/:id/mappings", (req, res) => {
router.put("/:id/mappings", async (req, res) => {
try {
const project = projectService.updateMappings(req.params.id, req.body);
const project = await projectService.updateMappings(req.params.id, req.body);
res.json({ success: true, data: project });
} catch (err) {
res.status(400).json({ success: false, message: err.message });
@@ -81,9 +81,9 @@ router.put("/:id/mappings", (req, res) => {
* DELETE /api/projects/:id
* 删除项目及其所有配置
*/
router.delete("/:id", (req, res) => {
router.delete("/:id", async (req, res) => {
try {
const result = projectService.deleteProject(req.params.id);
const result = await projectService.deleteProject(req.params.id);
if (!result) {
return res.status(404).json({ success: false, message: "项目不存在" });
}
+10 -10
View File
@@ -25,9 +25,9 @@ router.use(requireAdmin);
/**
* GET /api/tokens
*/
router.get("/", (req, res) => {
router.get("/", async (req, res) => {
try {
res.json({ success: true, data: tokenService.getAllTokens() });
res.json({ success: true, data: await tokenService.getAllTokens() });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
@@ -37,9 +37,9 @@ router.get("/", (req, res) => {
* POST /api/tokens
* Body: { name, projectId, permission, expiresInDays }
*/
router.post("/", (req, res) => {
router.post("/", async (req, res) => {
try {
const token = tokenService.createToken(req.body);
const token = await tokenService.createToken(req.body);
res.status(201).json({ success: true, data: token });
} catch (err) {
res.status(400).json({ success: false, message: err.message });
@@ -50,9 +50,9 @@ router.post("/", (req, res) => {
* PUT /api/tokens/:id
* Body: { name, projectId, permission, enabled }
*/
router.put("/:id", (req, res) => {
router.put("/:id", async (req, res) => {
try {
const token = tokenService.updateToken(req.params.id, req.body);
const token = await tokenService.updateToken(req.params.id, req.body);
res.json({ success: true, data: token });
} catch (err) {
res.status(400).json({ success: false, message: err.message });
@@ -62,9 +62,9 @@ router.put("/:id", (req, res) => {
/**
* PUT /api/tokens/:id/revoke
*/
router.put("/:id/revoke", (req, res) => {
router.put("/:id/revoke", async (req, res) => {
try {
const ok = tokenService.revokeToken(req.params.id);
const ok = await tokenService.revokeToken(req.params.id);
if (!ok) return res.status(404).json({ success: false, message: "Token 不存在" });
res.json({ success: true, message: "Token 已吊销" });
} catch (err) {
@@ -75,9 +75,9 @@ router.put("/:id/revoke", (req, res) => {
/**
* DELETE /api/tokens/:id
*/
router.delete("/:id", (req, res) => {
router.delete("/:id", async (req, res) => {
try {
const ok = tokenService.deleteToken(req.params.id);
const ok = await tokenService.deleteToken(req.params.id);
if (!ok) return res.status(404).json({ success: false, message: "Token 不存在" });
res.json({ success: true, message: "Token 已删除" });
} catch (err) {
+10 -10
View File
@@ -26,9 +26,9 @@ router.use(requireAdmin);
/**
* GET /api/users
*/
router.get("/", (req, res) => {
router.get("/", async (req, res) => {
try {
res.json({ success: true, data: userService.getAllUsers() });
res.json({ success: true, data: await userService.getAllUsers() });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
@@ -38,9 +38,9 @@ router.get("/", (req, res) => {
* POST /api/users
* Body: { username, displayName, password, role, enabled }
*/
router.post("/", (req, res) => {
router.post("/", async (req, res) => {
try {
const user = userService.createUser(req.body);
const user = await userService.createUser(req.body);
res.status(201).json({ success: true, data: user });
} catch (err) {
res.status(400).json({ success: false, message: err.message });
@@ -51,9 +51,9 @@ router.post("/", (req, res) => {
* PUT /api/users/:id
* Body: { displayName, role, enabled }
*/
router.put("/:id", (req, res) => {
router.put("/:id", async (req, res) => {
try {
const user = userService.updateUser(req.params.id, req.body);
const user = await userService.updateUser(req.params.id, req.body);
res.json({ success: true, data: user });
} catch (err) {
res.status(400).json({ success: false, message: err.message });
@@ -64,9 +64,9 @@ router.put("/:id", (req, res) => {
* PUT /api/users/:id/password
* Body: { password }
*/
router.put("/:id/password", (req, res) => {
router.put("/:id/password", async (req, res) => {
try {
userService.resetPassword(req.params.id, req.body.password);
await userService.resetPassword(req.params.id, req.body.password);
res.json({ success: true, message: "密码已重置" });
} catch (err) {
res.status(400).json({ success: false, message: err.message });
@@ -76,9 +76,9 @@ router.put("/:id/password", (req, res) => {
/**
* DELETE /api/users/:id
*/
router.delete("/:id", (req, res) => {
router.delete("/:id", async (req, res) => {
try {
const ok = userService.deleteUser(req.params.id);
const ok = await userService.deleteUser(req.params.id);
if (!ok) return res.status(404).json({ success: false, message: "用户不存在" });
res.json({ success: true, message: "用户已删除" });
} catch (err) {
+20 -11
View File
@@ -9,7 +9,8 @@ const configRoutes = require("./routes/configs");
const { router: authRoutes, getSession, extractToken } = require("./routes/auth");
const userRoutes = require("./routes/users");
const tokenRoutes = require("./routes/tokens");
const { loadSystem, saveSystem } = require("./persistence/jsonAdapter");
const { loadSystem, saveSystem } = require("./persistence/duckdbAdapter");
const duckdbAdapter = require("./persistence/duckdbAdapter");
const userService = require("./services/userService");
const versionInfo = require("./version");
@@ -22,9 +23,6 @@ const PORT = process.env.PORT || 3000;
// 解析 JSON 请求体
app.use(express.json());
// 初始化默认管理员账号(首次启动时自动创建)
userService.ensureDefaultAdmin();
// 全局认证中间件:解析 Bearer token,将用户信息挂载到 req.user(非强制)
app.use((req, res, next) => {
const token = extractToken(req);
@@ -66,9 +64,9 @@ app.get("/api/version", (req, res) => {
* GET /api/mode
* 获取当前系统模式
*/
app.get("/api/mode", (req, res) => {
app.get("/api/mode", async (req, res) => {
try {
const system = loadSystem();
const system = await loadSystem();
res.json({ success: true, data: { mode: system.mode || "edit" } });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
@@ -80,23 +78,34 @@ app.get("/api/mode", (req, res) => {
* 切换系统模式
* Body: { mode: "plan" | "edit" | "readonly" }
*/
app.put("/api/mode", (req, res) => {
app.put("/api/mode", async (req, res) => {
try {
const { mode } = req.body;
const validModes = ["plan", "edit", "readonly"];
if (!validModes.includes(mode)) {
return res.status(400).json({ success: false, message: `无效的模式: ${mode},支持: ${validModes.join(", ")}` });
}
const system = loadSystem();
const system = await loadSystem();
system.mode = mode;
saveSystem(system);
await saveSystem(system);
res.json({ success: true, data: { mode } });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
// 启动服务
app.listen(PORT, () => {
/**
* 启动服务:先初始化 DuckDB(自动迁移 JSON 存量数据),再创建默认管理员账号
*/
async function start() {
await duckdbAdapter.init();
await userService.ensureDefaultAdmin();
app.listen(PORT, () => {
console.log(`Registry 服务已启动: http://localhost:${PORT}`);
});
}
start().catch((err) => {
console.error("服务启动失败:", err);
process.exit(1);
});
+21 -21
View File
@@ -4,7 +4,7 @@
* 路径规则:仅映射方式为 path 的已启用维度作为路径前缀,label 维度仅作筛选标签不进入路径;
* 配置键 key 自身可用 / 嵌套,json 类型在前端按 . 分割展示
*/
const { loadConfigs, saveConfigs } = require("../persistence/jsonAdapter");
const { loadConfigs, saveConfigs } = require("../persistence/duckdbAdapter");
const { getProjectById } = require("./projectService");
const { validateByType, validateRange, VALID_TYPES } = require("../utils/validator");
@@ -57,14 +57,14 @@ function buildConfigPath(project, config) {
/**
* 获取项目下所有配置项,自动填充路径
* @param {string} projectId - 项目 ID
* @returns {Array} 配置项列表(含 path 字段)
* @returns {Promise<Array>} 配置项列表(含 path 字段)
*/
function getConfigsByProject(projectId) {
const project = getProjectById(projectId);
async function getConfigsByProject(projectId) {
const project = await getProjectById(projectId);
if (!project) {
throw new Error("项目不存在");
}
const configs = loadConfigs(projectId);
const configs = await loadConfigs(projectId);
return configs.map((c) => ({
...c,
path: buildConfigPath(project, c),
@@ -75,14 +75,14 @@ function getConfigsByProject(projectId) {
* 根据 ID 获取单个配置项
* @param {string} projectId - 项目 ID
* @param {string} configId - 配置项 ID
* @returns {Object|null} 配置项对象,不存在时返回 null
* @returns {Promise<Object|null>} 配置项对象,不存在时返回 null
*/
function getConfigById(projectId, configId) {
const project = getProjectById(projectId);
async function getConfigById(projectId, configId) {
const project = await getProjectById(projectId);
if (!project) {
throw new Error("项目不存在");
}
const configs = loadConfigs(projectId);
const configs = await loadConfigs(projectId);
const config = configs.find((c) => c.id === configId);
if (!config) {
return null;
@@ -112,12 +112,12 @@ function getConfigById(projectId, configId) {
* @param {string} mode - 当前模式(plan/edit/readonly)
* @returns {Object} 创建的配置项
*/
function createConfig(projectId, data, mode) {
async function createConfig(projectId, data, mode) {
if (mode === "readonly") {
throw new Error("只读模式下不允许新增配置");
}
const project = getProjectById(projectId);
const project = await getProjectById(projectId);
if (!project) {
throw new Error("项目不存在");
}
@@ -152,9 +152,9 @@ function createConfig(projectId, data, mode) {
validateEditValue(config);
}
const configs = loadConfigs(projectId);
const configs = await loadConfigs(projectId);
configs.push(config);
saveConfigs(projectId, configs);
await saveConfigs(projectId, configs);
return {
...config,
@@ -247,17 +247,17 @@ function validateStructure(config, project) {
* @param {string} mode - 当前模式
* @returns {Object} 更新后的配置项
*/
function updateConfig(projectId, configId, data, mode) {
async function updateConfig(projectId, configId, data, mode) {
if (mode === "readonly") {
throw new Error("只读模式下不允许修改配置");
}
const project = getProjectById(projectId);
const project = await getProjectById(projectId);
if (!project) {
throw new Error("项目不存在");
}
const configs = loadConfigs(projectId);
const configs = await loadConfigs(projectId);
const index = configs.findIndex((c) => c.id === configId);
if (index === -1) {
throw new Error("配置项不存在");
@@ -291,9 +291,9 @@ function updateConfig(projectId, configId, data, mode) {
validateStructure(config, project);
}
config.updatedAt = new Date().toISOString();
config.updatedAt = new Date().toISOString();
configs[index] = config;
saveConfigs(projectId, configs);
await saveConfigs(projectId, configs);
return {
...config,
@@ -308,18 +308,18 @@ function updateConfig(projectId, configId, data, mode) {
* @param {string} mode - 当前模式
* @returns {boolean} 是否删除成功
*/
function deleteConfig(projectId, configId, mode) {
async function deleteConfig(projectId, configId, mode) {
if (mode === "readonly") {
throw new Error("只读模式下不允许删除配置");
}
const configs = loadConfigs(projectId);
const configs = await loadConfigs(projectId);
const index = configs.findIndex((c) => c.id === configId);
if (index === -1) {
return false;
}
configs.splice(index, 1);
saveConfigs(projectId, configs);
await saveConfigs(projectId, configs);
return true;
}
+24 -24
View File
@@ -3,7 +3,7 @@
* 处理项目的业务逻辑,包括 CRUD 操作、维度校验与维度映射配置
* 注意:项目创建后维度开关不可修改,只能通过项目转换机制处理;维度映射方式可后续调整
*/
const { loadProjects, saveProjects, deleteConfigs } = require("../persistence/jsonAdapter");
const { loadProjects, saveProjects, deleteConfigs } = require("../persistence/duckdbAdapter");
// 维度映射方式白名单:path=作为存储路径前缀;label=仅作为筛选标签不进入路径
const VALID_MAPPINGS = ["path", "label"];
@@ -27,19 +27,19 @@ function normalizeMapping(mapping) {
/**
* 获取所有项目列表
* @returns {Array} 项目列表
* @returns {Promise<Array>} 项目列表
*/
function getAllProjects() {
async function getAllProjects() {
return loadProjects();
}
/**
* 根据 ID 获取单个项目
* @param {string} id - 项目 ID
* @returns {Object|null} 项目对象,不存在时返回 null
* @returns {Promise<Object|null>} 项目对象,不存在时返回 null
*/
function getProjectById(id) {
const projects = loadProjects();
async function getProjectById(id) {
const projects = await loadProjects();
return projects.find((p) => p.id === id) || null;
}
@@ -56,14 +56,14 @@ function getProjectById(id) {
* @param {string} data.idcMapping - 机房映射方式(path/label)
* @param {string} data.environmentMapping - 环境映射方式(path/label)
* @param {string} data.groupMapping - 分组映射方式(path/label)
* @returns {Object} 创建的项目对象
* @returns {Promise<Object>} 创建的项目对象
*/
function createProject(data) {
async function createProject(data) {
if (!data.name || data.name.trim() === "") {
throw new Error("项目名称不能为空");
}
const projects = loadProjects();
const projects = await loadProjects();
const now = new Date().toISOString();
const project = {
id: generateId(),
@@ -79,8 +79,8 @@ function createProject(data) {
updatedAt: now,
};
projects.push(project);
saveProjects(projects);
projects.push(project);
await saveProjects(projects);
return project;
}
@@ -91,10 +91,10 @@ function createProject(data) {
* @param {Object} data - 待更新字段
* @param {string} [data.name] - 新项目名称
* @param {string} [data.description] - 新项目描述
* @returns {Object} 更新后的项目对象
* @returns {Promise<Object>} 更新后的项目对象
*/
function updateProject(id, data) {
const projects = loadProjects();
async function updateProject(id, data) {
const projects = await loadProjects();
const index = projects.findIndex((p) => p.id === id);
if (index === -1) {
throw new Error("项目不存在");
@@ -108,7 +108,7 @@ function updateProject(id, data) {
projects[index].name = name.trim();
projects[index].description = (data.description === undefined ? projects[index].description : data.description || "").trim();
projects[index].updatedAt = new Date().toISOString();
saveProjects(projects);
await saveProjects(projects);
return projects[index];
}
@@ -122,8 +122,8 @@ function updateProject(id, data) {
* @param {string} mappings.groupMapping - 分组映射方式(path/label)
* @returns {Object} 更新后的项目对象
*/
function updateMappings(id, mappings) {
const projects = loadProjects();
async function updateMappings(id, mappings) {
const projects = await loadProjects();
const index = projects.findIndex((p) => p.id === id);
if (index === -1) {
throw new Error("项目不存在");
@@ -138,26 +138,26 @@ function updateMappings(id, mappings) {
if (project.enableGroup && mappings.groupMapping !== undefined) {
project.groupMapping = normalizeMapping(mappings.groupMapping);
}
project.updatedAt = new Date().toISOString();
project.updatedAt = new Date().toISOString();
projects[index] = project;
saveProjects(projects);
await saveProjects(projects);
return project;
}
/**
* 删除项目及其所有关联配置
* @param {string} id - 项目 ID
* @returns {boolean} 是否删除成功
* @returns {Promise<boolean>} 是否删除成功
*/
function deleteProject(id) {
const projects = loadProjects();
async function deleteProject(id) {
const projects = await loadProjects();
const index = projects.findIndex((p) => p.id === id);
if (index === -1) {
return false;
}
projects.splice(index, 1);
saveProjects(projects);
deleteConfigs(id);
await saveProjects(projects);
await deleteConfigs(id);
return true;
}
+25 -25
View File
@@ -3,33 +3,33 @@
* 负责 API 访问令牌的增删改查与吊销,持久化到 data/api-tokens.json
* (与 MCP token 独立,MCP token 由 data/mcp.json 管理)
*/
const { loadApiTokens, saveApiTokens } = require("../persistence/jsonAdapter");
const { loadApiTokens, saveApiTokens } = require("../persistence/duckdbAdapter");
const { generateToken } = require("./userService");
/**
* 获取所有 Token(完整值,供列表展示)
* @returns {Array}
* @returns {Promise<Array>}
*/
function getAllTokens() {
async function getAllTokens() {
return loadApiTokens();
}
/**
* 按 ID 查找 Token
* @param {string} id
* @returns {Object|null}
* @returns {Promise<Object|null>}
*/
function findById(id) {
return loadApiTokens().find((t) => t.id === id) || null;
async function findById(id) {
return (await loadApiTokens()).find((t) => t.id === id) || null;
}
/**
* 创建 Token
* @param {Object} data - { name, projectId, permission, expiresInDays }
* @returns {Object} 创建的 Token(含明文 value)
* @returns {Promise<Object>} 创建的 Token(含明文 value)
*/
function createToken(data) {
const tokens = loadApiTokens();
async function createToken(data) {
const tokens = await loadApiTokens();
if (!data.name || !data.name.trim()) {
throw new Error("Token 名称不能为空");
}
@@ -50,7 +50,7 @@ function createToken(data) {
expiresAt,
};
tokens.push(token);
saveApiTokens(tokens);
await saveApiTokens(tokens);
return token;
}
@@ -58,56 +58,56 @@ function createToken(data) {
* 更新 Token 元信息
* @param {string} id
* @param {Object} data - { name, projectId, permission, enabled }
* @returns {Object}
* @returns {Promise<Object>}
*/
function updateToken(id, data) {
const tokens = loadApiTokens();
async function updateToken(id, data) {
const tokens = await loadApiTokens();
const idx = tokens.findIndex((t) => t.id === id);
if (idx === -1) throw new Error("Token 不存在");
if (data.name !== undefined) tokens[idx].name = data.name.trim();
if (data.projectId !== undefined) tokens[idx].projectId = data.projectId;
if (data.permission !== undefined) tokens[idx].permission = data.permission;
if (data.enabled !== undefined) tokens[idx].enabled = !!data.enabled;
saveApiTokens(tokens);
await saveApiTokens(tokens);
return tokens[idx];
}
/**
* 吊销 Token(设置 enabled = false)
* @param {string} id
* @returns {boolean}
* @returns {Promise<boolean>}
*/
function revokeToken(id) {
const tokens = loadApiTokens();
async function revokeToken(id) {
const tokens = await loadApiTokens();
const idx = tokens.findIndex((t) => t.id === id);
if (idx === -1) return false;
tokens[idx].enabled = false;
saveApiTokens(tokens);
await saveApiTokens(tokens);
return true;
}
/**
* 删除 Token
* @param {string} id
* @returns {boolean}
* @returns {Promise<boolean>}
*/
function deleteToken(id) {
const tokens = loadApiTokens();
async function deleteToken(id) {
const tokens = await loadApiTokens();
const idx = tokens.findIndex((t) => t.id === id);
if (idx === -1) return false;
tokens.splice(idx, 1);
saveApiTokens(tokens);
await saveApiTokens(tokens);
return true;
}
/**
* 根据 token value 校验是否有效(用于 API 鉴权)
* @param {string} value
* @returns {Object|null} token 对象或 null
* @returns {Promise<Object|null>} token 对象或 null
*/
function validateToken(value) {
async function validateToken(value) {
if (!value) return null;
const token = loadApiTokens().find((t) => t.value === value);
const token = (await loadApiTokens()).find((t) => t.value === value);
if (!token || !token.enabled) return null;
if (token.expiresAt && new Date(token.expiresAt) < new Date()) return null;
return token;
+33 -33
View File
@@ -3,7 +3,7 @@
* 负责用户的增删改查、密码校验与哈希,数据持久化到 data/users.json
*/
const crypto = require("crypto");
const { loadUsers, saveUsers } = require("../persistence/jsonAdapter");
const { loadUsers, saveUsers } = require("../persistence/duckdbAdapter");
/**
* 密码哈希:使用 scrypt 算法 + 随机盐值
@@ -40,8 +40,8 @@ function generateToken(length = 32) {
/**
* 确保默认管理员账号存在(首次启动时初始化)
*/
function ensureDefaultAdmin() {
const users = loadUsers();
async function ensureDefaultAdmin() {
const users = await loadUsers();
if (users.length === 0) {
users.push({
id: generateToken(8),
@@ -54,7 +54,7 @@ function ensureDefaultAdmin() {
updatedAt: new Date().toISOString(),
lastLoginAt: null,
});
saveUsers(users);
await saveUsers(users);
console.log("已创建默认管理员账号: admin");
}
}
@@ -62,37 +62,37 @@ function ensureDefaultAdmin() {
/**
* 按用户名查找用户
* @param {string} username
* @returns {Object|null}
* @returns {Promise<Object|null>}
*/
function findByUsername(username) {
return loadUsers().find((u) => u.username === username) || null;
async function findByUsername(username) {
return (await loadUsers()).find((u) => u.username === username) || null;
}
/**
* 按 ID 查找用户
* @param {string} id
* @returns {Object|null}
* @returns {Promise<Object|null>}
*/
function findById(id) {
return loadUsers().find((u) => u.id === id) || null;
async function findById(id) {
return (await loadUsers()).find((u) => u.id === id) || null;
}
/**
* 获取所有用户(不含密码字段)
* @returns {Array}
* @returns {Promise<Array>}
*/
function getAllUsers() {
return loadUsers().map(({ password, ...rest }) => rest);
async function getAllUsers() {
return (await loadUsers()).map(({ password, ...rest }) => rest);
}
/**
* 创建用户
* @param {Object} data - { username, displayName, password, role, enabled }
* @returns {Object} 创建的用户(不含密码)
* @returns {Promise<Object>} 创建的用户(不含密码)
* @throws {Error}
*/
function createUser(data) {
const users = loadUsers();
async function createUser(data) {
const users = await loadUsers();
if (!data.username || !data.username.trim()) {
throw new Error("用户名不能为空");
}
@@ -114,7 +114,7 @@ function createUser(data) {
lastLoginAt: null,
};
users.push(user);
saveUsers(users);
await saveUsers(users);
const { password, ...rest } = user;
return rest;
}
@@ -123,18 +123,18 @@ function createUser(data) {
* 更新用户
* @param {string} id
* @param {Object} data - { displayName, role, enabled }
* @returns {Object}
* @returns {Promise<Object>}
* @throws {Error}
*/
function updateUser(id, data) {
const users = loadUsers();
async function updateUser(id, data) {
const users = await loadUsers();
const idx = users.findIndex((u) => u.id === id);
if (idx === -1) throw new Error("用户不存在");
if (data.displayName !== undefined) users[idx].displayName = data.displayName.trim();
if (data.role !== undefined) users[idx].role = data.role === "admin" ? "admin" : "user";
if (data.enabled !== undefined) users[idx].enabled = !!data.enabled;
users[idx].updatedAt = new Date().toISOString();
saveUsers(users);
await saveUsers(users);
const { password, ...rest } = users[idx];
return rest;
}
@@ -145,27 +145,27 @@ function updateUser(id, data) {
* @param {string} newPassword
* @throws {Error}
*/
function resetPassword(id, newPassword) {
const users = loadUsers();
async function resetPassword(id, newPassword) {
const users = await loadUsers();
const idx = users.findIndex((u) => u.id === id);
if (idx === -1) throw new Error("用户不存在");
if (!newPassword) throw new Error("新密码不能为空");
users[idx].password = hashPassword(newPassword);
users[idx].updatedAt = new Date().toISOString();
saveUsers(users);
await saveUsers(users);
}
/**
* 删除用户
* @param {string} id
* @returns {boolean}
* @returns {Promise<boolean>}
*/
function deleteUser(id) {
const users = loadUsers();
async function deleteUser(id) {
const users = await loadUsers();
const idx = users.findIndex((u) => u.id === id);
if (idx === -1) return false;
users.splice(idx, 1);
saveUsers(users);
await saveUsers(users);
return true;
}
@@ -173,18 +173,18 @@ function deleteUser(id) {
* 登录校验,成功后更新 lastLoginAt
* @param {string} username
* @param {string} password
* @returns {Object|null} 用户对象(不含密码)或 null
* @returns {Promise<Object|null>} 用户对象(不含密码)或 null
*/
function authenticate(username, password) {
const user = findByUsername(username);
async function authenticate(username, password) {
const user = await findByUsername(username);
if (!user || !user.enabled) return null;
if (!verifyPassword(password, user.password)) return null;
// 更新最后登录时间
const users = loadUsers();
const users = await loadUsers();
const idx = users.findIndex((u) => u.id === user.id);
if (idx !== -1) {
users[idx].lastLoginAt = new Date().toISOString();
saveUsers(users);
await saveUsers(users);
}
const { password: _, ...rest } = user;
return { ...rest, lastLoginAt: users[idx].lastLoginAt };
+97
View File
@@ -0,0 +1,97 @@
/**
* DuckDB 适配器单元测试
* 覆盖:写入/读取往返、JSON 异构值保留、JSON 存量自动迁移、重复初始化幂等
* 运行:npm test
*/
const test = require("node:test");
const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
// 使用临时数据目录,避免污染真实 data/
const tmpDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "registry-duckdb-test-"));
process.env.REGISTRY_DATA_DIR = tmpDataDir;
const {
init,
loadProjects,
saveProjects,
loadConfigs,
saveConfigs,
deleteConfigs,
loadSystem,
saveSystem,
loadMcpConfig,
saveMcpConfig,
loadUsers,
saveUsers,
loadApiTokens,
saveApiTokens,
} = require("../src/persistence/duckdbAdapter");
test("projects 写入/读取往返,字段无损保留", async () => {
await init();
const projects = [
{ id: "p1", name: "项目A", enableIdc: true, nested: { arr: [1, 2, 3] } },
{ id: "p2", name: "项目B", enableEnvironment: false },
];
await saveProjects(projects);
const loaded = await loadProjects();
assert.strictEqual(loaded.length, 2);
assert.deepStrictEqual(loaded.find((p) => p.id === "p1").nested, { arr: [1, 2, 3] });
});
test("configs 写入/读取往返,值类型异构保留", async () => {
await init();
const configs = [
{ id: "c1", key: "count", value: 42, type: "number" },
{ id: "c2", key: "flag", value: false, type: "boolean" },
{ id: "c3", key: "raw", value: { deep: ["a", null, true] }, type: "json" },
];
await saveConfigs("proj-x", configs);
const loaded = await loadConfigs("proj-x");
assert.strictEqual(loaded.length, 3);
assert.strictEqual(loaded.find((c) => c.id === "c1").value, 42);
assert.strictEqual(loaded.find((c) => c.id === "c2").value, false);
assert.deepStrictEqual(loaded.find((c) => c.id === "c3").value, { deep: ["a", null, true] });
// ensure projectId 未被注入进记录本体
assert.strictEqual("projectId" in loaded[0], false);
// 其他项目不受影响
assert.deepStrictEqual(await loadConfigs("proj-other"), []);
});
test("configs 删除仅作用于指定项目", async () => {
await init();
await saveConfigs("proj-keep", [{ id: "k1", key: "a", value: 1 }]);
await deleteConfigs("proj-keep");
await saveConfigs("proj-keep2", [{ id: "k2", key: "b", value: 2 }]);
await deleteConfigs("proj-keep2");
await saveConfigs("proj-keep3", [{ id: "k3", key: "c", value: 3 }]);
await deleteConfigs("proj-keep3");
await saveConfigs("proj-del", [{ id: "d1", key: "x", value: 1 }]);
await deleteConfigs("proj-del");
await deleteConfigs("proj-del");
assert.deepStrictEqual(await loadConfigs("proj-del"), []);
});
test("system / mcp 写入读取与默认值", async () => {
await init();
const sys = await loadSystem();
assert.strictEqual(sys.mode, "edit");
await saveSystem({ mode: "readonly" });
assert.strictEqual((await loadSystem()).mode, "readonly");
const mcp = await loadMcpConfig();
assert.strictEqual(typeof mcp.token, "string");
await saveMcpConfig({ enabled: false, token: "abc" });
assert.deepStrictEqual(await loadMcpConfig(), { enabled: false, token: "abc" });
});
test("users / apiTokens 写入读取往返", async () => {
await init();
await saveUsers([{ id: "u1", username: "admin", password: "hashed", roles: ["admin"] }]);
const users = await loadUsers();
assert.strictEqual(users[0].username, "admin");
await saveApiTokens([{ id: "t1", name: "mcp", value: "secret" }]);
assert.strictEqual((await loadApiTokens())[0].value, "secret");
});
+76
View File
@@ -0,0 +1,76 @@
/**
* JSON -> DuckDB 自动迁移测试
* 独立进程运行:模块在 require 时读取 REGISTRY_DATA_DIR,需在 require 前注入种子数据目录
* 运行:npm test
*/
const test = require("node:test");
const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
// 构造一个含 JSON 存量数据的种子目录,并设置为数据目录
const seedDir = path.join(os.tmpdir(), "registry-seed-" + Date.now());
fs.mkdirSync(seedDir, { recursive: true });
fs.writeFileSync(path.join(seedDir, "projects.json"), JSON.stringify([
{ id: "proj-mig-1", name: "迁移项目A", enableIdc: false },
]));
fs.writeFileSync(path.join(seedDir, "users.json"), JSON.stringify([
{ id: "u-mig", username: "migrator", displayName: "迁移用户", role: "user", enabled: true },
]));
fs.writeFileSync(path.join(seedDir, "api-tokens.json"), JSON.stringify([
{ id: "t-mig", name: "迁移Token", value: "mig-secret", enabled: true },
]));
fs.writeFileSync(path.join(seedDir, "system.json"), JSON.stringify({ mode: "plan" }));
fs.writeFileSync(path.join(seedDir, "mcp.json"), JSON.stringify({ enabled: true, token: "mcp-token" }));
const configsDir = path.join(seedDir, "configs");
fs.mkdirSync(configsDir, { recursive: true });
fs.writeFileSync(path.join(configsDir, "proj-mig-1.json"), JSON.stringify([
{ id: "cfg-mig", key: "db.host", value: "10.0.0.1", type: "string" },
]));
process.env.REGISTRY_DATA_DIR = seedDir;
const duckdbAdapter = require("../src/persistence/duckdbAdapter");
test("自动迁移全部 JSON 存量到 DuckDB,数据无损且备份 JSON", async () => {
await duckdbAdapter.init();
const projects = await duckdbAdapter.loadProjects();
assert.strictEqual(projects.length, 1);
assert.strictEqual(projects[0].name, "迁移项目A");
assert.strictEqual(projects[0].id, "proj-mig-1");
const users = await duckdbAdapter.loadUsers();
assert.strictEqual(users[0].username, "migrator");
const tokens = await duckdbAdapter.loadApiTokens();
assert.strictEqual(tokens[0].value, "mig-secret");
const system = await duckdbAdapter.loadSystem();
assert.strictEqual(system.mode, "plan");
const mcp = await duckdbAdapter.loadMcpConfig();
assert.strictEqual(mcp.token, "mcp-token");
const configs = await duckdbAdapter.loadConfigs("proj-mig-1");
assert.strictEqual(configs.length, 1);
assert.strictEqual(configs[0].value, "10.0.0.1");
// projectId 不应被注入进记录本体
assert.strictEqual("projectId" in configs[0], false);
// 数据迁移后应生成 DB 文件
assert.ok(fs.existsSync(path.join(seedDir, "registry.duckdb")), "应生成 registry.duckdb");
// JSON 源文件应被备份移除(防止二次导入)
assert.ok(!fs.existsSync(path.join(seedDir, "projects.json")), "迁移后 projects.json 应已移走");
const backupDirs = fs.readdirSync(seedDir).filter((n) => n.startsWith("json-backup-"));
assert.strictEqual(backupDirs.length, 1, "应生成 json-backup-<ts> 目录");
});
test("再次初始化(DB 已存在)不重复迁移、不报错", async () => {
const before = await duckdbAdapter.loadProjects();
await duckdbAdapter.init();
const after = await duckdbAdapter.loadProjects();
assert.deepStrictEqual(after, before);
});
+19 -9
View File
@@ -5,22 +5,32 @@
*/
const test = require("node:test");
const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { createMcpServer } = require("../src/mcp/tools");
const { loadSystem, saveSystem } = require("../src/persistence/jsonAdapter");
const { init, loadSystem, saveSystem } = require("../src/persistence/duckdbAdapter");
// 恢复测试前的系统模式,避免影响其他用例
const originalMode = (loadSystem() || {}).mode || "edit";
test.after(() => {
const system = loadSystem() || {};
system.mode = originalMode;
saveSystem(system);
});
// 使用临时数据目录,避免污染真实 data/
const tmpDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "registry-mcp-test-"));
process.env.REGISTRY_DATA_DIR = tmpDataDir;
let server;
test.before(() => {
let originalMode = "edit";
// 初始化 DuckDB,记录测试前模式;测试结束后恢复原模式
test.before(async () => {
await init();
originalMode = (await loadSystem() || {}).mode || "edit";
server = createMcpServer();
});
test.after(async () => {
const system = await loadSystem() || {};
system.mode = originalMode;
await saveSystem(system);
});
/**
* 直接调用已注册工具的处理函数
* @param {string} name - 工具名