feat: 持久化从 JSON 迁移到 DuckDB,启动自动迁移存量数据并全面异步化
build-and-deploy / build-and-deploy (push) Successful in 37s
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:
@@ -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/*
|
||||
Generated
+154
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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}` }],
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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
@@ -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 - 工具名
|
||||
|
||||
Reference in New Issue
Block a user