From cd9e75790feacff2ce451c63f3fa1a9b41a76755 Mon Sep 17 00:00:00 2001 From: dongzp <975303544@qq.com> Date: Thu, 4 Jun 2026 12:13:28 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=E6=95=B0=E6=8D=AE=E5=BA=93?= =?UTF-8?q?=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 89 ++- admin.html | 371 ++++++++++--- config.json | 17 +- index.api.ts | 1225 ++--------------------------------------- mock/api-list.json | 57 -- mock/test.txt | 1 + src/admin-handlers.ts | 485 ++++++++++++++++ src/api-list.ts | 52 ++ src/config.ts | 112 ++++ src/constants.ts | 7 + src/db.ts | 293 ++++++++++ src/mock-files.ts | 95 ++++ src/proxy.ts | 211 +++++++ src/route-matching.ts | 29 + src/state.ts | 18 + src/types.ts | 59 ++ src/utils.ts | 43 ++ tsconfig.json | 2 +- 18 files changed, 1795 insertions(+), 1371 deletions(-) create mode 100644 mock/test.txt create mode 100644 src/admin-handlers.ts create mode 100644 src/api-list.ts create mode 100644 src/config.ts create mode 100644 src/constants.ts create mode 100644 src/db.ts create mode 100644 src/mock-files.ts create mode 100644 src/proxy.ts create mode 100644 src/route-matching.ts create mode 100644 src/state.ts create mode 100644 src/types.ts create mode 100644 src/utils.ts diff --git a/README.md b/README.md index 458f5a0..0351fbb 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,20 @@ ## API Proxy Mock -基于 Node.js 的轻量级 **HTTP 代理 + 本地 Mock**:路由与 mock 文件路径映射存储在 `sqlite3`,mock 内容仍保存在 `mock/` 目录文件中,其余请求按配置(HTTP/HTTPS)转发到真实后端。 +基于 Node.js 的轻量级 **HTTP 代理 + 本地 Mock**:所有配置(路由映射、服务器参数、接口列表)统一存储在 SQLite,mock 内容保存在 `mock/` 目录文件中,其余请求按配置(HTTP/HTTPS)转发到真实后端。通过 `__admin` 管理面板进行可视化管理。 ### 功能说明 - **代理转发**:未命中 Mock 的请求会转发到 `config.targetHost`(由 `targetHttps` 决定 HTTP/HTTPS,`targetPort` 可配)。 - **本地 Mock**:命中路由时直接读取 `mock` 目录下的文件作为响应体。 -- **SQLite 映射**:路由列表、接口列表存储在 `mock/mock-mappings.sqlite3`,数据库只保存路由与文件路径/目录关系。 +- **SQLite 存储**:路由列表、接口列表、服务器配置全部存储在 `data/mock-mappings.sqlite3`。 - **Mock 总开关**:`mockEnabled` 为 `false` 时**不拦截**任何 Mock 路由,全部走代理。 -- **热更新**:`reloadOnChange` 为 `true` 时监听 `config.json` 变更并自动重载;也可通过管理接口手动重载。 - **Mock 响应**:带简单 CORS 头,以及 `X-Mock-Source`、`X-Mock-Timestamp` 便于排查。 +- **管理面板**:访问 `/__admin` 进入 Element UI 可视化管理界面。 ### 环境要求 - Node.js(建议 18+) -- 依赖见 `package.json`:`typescript`、`ts-node`、`@types/node`(仅开发/类型) +- 依赖见 `package.json`:`typescript`、`ts-node`、`@types/node`(仅开发/类型)、`sqlite3` ### 安装与启动 @@ -22,7 +22,7 @@ npm install ``` -推荐使用 npm 脚本(使用项目内 `tsconfig.json`,避免 `tsc .\某文件.ts` 触发 TS5112): +推荐使用 npm 脚本: ```bash npm run dev @@ -42,65 +42,33 @@ npx ts-node --project tsconfig.json ./index.api.ts npm run typecheck ``` -启动成功后,控制台会输出本地监听地址、目标主机与配置文件路径等。 +启动成功后,控制台会输出本地监听地址、目标主机等。 -### 配置文件 `config.json` +### 数据存储 -与 `index.api.ts` 同目录。若首次运行不存在,程序会生成一份默认配置。 +所有配置统一存储在 SQLite 数据库 `data/mock-mappings.sqlite3` 中: -基础结构示例: - -```json -{ - "routes": { - "/api/example": "mock/example.txt" - }, - "config": { - "mockEnabled": true, - "cacheConfig": true, - "reloadOnChange": true, - "defaultContentType": "application/json", - "proxyPort": 8877, - "targetHost": "example.com", - "targetPort": 443, - "targetHttps": true - } -} -``` - -#### `routes` - -`routes` 仍会出现在接口返回中用于兼容旧面板;实际持久化以 sqlite 为准。 - -| 说明 | | +| 表名 | 用途 | | --- | --- | -| **key** | 请求路径,只匹配 **pathname**(不含域名;查询串不参与匹配),例如 `"/api/user/info"`。 | -| **value** | 相对项目根目录(与 `index.api.ts` 同级)的文件路径,例如 `"mock/user.txt"`。 | -| **注释** | 以 **`#`** 开头的 key 视为注释,**不参与** Mock。例如 `"#/api/old": "mock/x.txt"` 会被忽略。 | +| `route_mappings` | 路由 → mock 文件映射 + 状态码 | +| `api_list` | 接口名称清单 | +| `mock_files` | mock 文件路径 + 别名 | +| `server_config` | 服务器配置(端口、目标主机等) | -#### `config` - -| 字段 | 说明 | -| --- | --- | -| `mockEnabled` | 可选。为 `false` 时关闭 Mock,所有请求走代理;缺省为 `true`。 | -| `cacheConfig` | 保留字段;当前实现中未参与逻辑,可忽略或与旧配置兼容。 | -| `reloadOnChange` | 是否监视 `config.json` 文件变化并自动重新加载。 | -| `defaultContentType` | Mock 成功时的 `Content-Type`,常用 `"application/json"`。 | -| `proxyPort` | 本机 HTTP 代理监听端口。 | -| `targetHost` | 上游主机名(不含协议与路径)。 | -| `targetPort` | 可选。上游端口;当 `targetHttps=true` 缺省为 `443`,当 `targetHttps=false` 缺省为 `80`。 | -| `targetHttps` | 可选。是否使用 HTTPS 连接上游;`true` 为 HTTPS,`false` 为 HTTP。缺省为 `true`。 | +首次启动时,如果 `mock/api-list.json` 存在,会自动迁移导入到数据库。 ### 管理接口 -将 `` 换为 `config.proxyPort` 中的值: +将 `` 换为实际监听端口: | 方法 | 路径 | 说明 | | --- | --- | --- | | `GET` | `http://localhost:/__config` | 查看当前路由与配置 | -| `POST` | `http://localhost:/__config` | 保存 `config.json`(包含 `routes` 与 `config`) | -| `POST` | `http://localhost:/__reload-config` | 手动重新加载 `config.json` | +| `POST` | `http://localhost:/__config` | 保存配置(服务器配置 + 路由) | +| `POST` | `http://localhost:/__reload-config` | 从数据库重新加载配置 | | `POST` | `http://localhost:/__routes` | 动态新增单个路由与 mock 文件 | +| `GET` | `http://localhost:/__api-list` | 获取接口列表 | +| `GET/POST/DELETE` | `http://localhost:/__mock-files` | 管理 mock 文件 | | `GET` | `http://localhost:/__admin` | 配置管理页面(Element UI) | #### `POST /__routes` 请求示例 @@ -114,6 +82,21 @@ npm run typecheck } ``` +#### `POST /__config` 请求示例 + +```json +{ + "config": { + "proxyPort": 8879, + "targetHost": "192.168.3.9", + "targetPort": 8092, + "targetHttps": false, + "defaultContentType": "application/json", + "mockEnabled": true + } +} +``` + ### TypeScript 与编译说明 - 项目根目录包含 `tsconfig.json`,请使用 **`tsc -p .`** 或 **`npm run typecheck`** 做整项目检查。 @@ -122,6 +105,6 @@ npm run typecheck ### 目录说明 - `index.api.ts`:服务入口。 -- `config.json`:路由与运行参数。 +- `src/`:模块化源码(types、db、config、proxy、admin-handlers 等)。 - `mock/`:Mock 响应文件(文本内容原样返回,按需自行写成 JSON 等)。 -- `mock/mock-mappings.sqlite3`:路由/接口与 mock 文件路径映射(不保存 mock 内容)。 +- `data/mock-mappings.sqlite3`:SQLite 数据库(路由映射、接口列表、服务器配置)。 diff --git a/admin.html b/admin.html index 39560ad..8060e63 100644 --- a/admin.html +++ b/admin.html @@ -30,18 +30,15 @@ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); } .top-nav-inner { - max-width: 1200px; + max-width: 1320px; margin: 0 auto; - padding: 10px 16px; + padding: 14px 16px; } - .actions { - display: flex; - gap: 8px; - } - @media (max-width: 1200px) { - .actions { - flex-wrap: wrap; - } + .top-nav-title { + font-size: 18px; + font-weight: 600; + color: #303133; + letter-spacing: 1px; } .small-text { color: #909399; @@ -60,11 +57,7 @@
-
- 保存配置到 config.json - 重载服务内存配置 - 刷新页面数据 -
+ 企业级数据Mock管理系统
@@ -73,7 +66,10 @@
路由配置(routes) - 新增路由 +
+ + 新增路由 +
@@ -98,7 +94,7 @@ @@ -121,11 +117,11 @@ layout="total, prev, pager, next" :current-page="routePagination.currentPage" :page-size="routePagination.pageSize" - :total="form.routes.length" + :total="filteredRoutes.length" @current-change="handleRoutePageChange" > -
- 说明:关闭“启用”后会以 # 注释路由,不参与 mock 命中;切换后请点击“保存配置到 config.json”生效到文件。 +
+ 说明:关闭”启用”后该路由不参与 mock 命中,切换后自动生效。
@@ -134,7 +130,10 @@
Mock 数据配置 - 新增 Mock 文件 +
+ + 新增 Mock 文件 +
@@ -165,29 +164,78 @@ layout="total, prev, pager, next" :current-page="mockFilePagination.currentPage" :page-size="mockFilePagination.pageSize" - :total="mockFiles.length" + :total="filteredMockFiles.length" @current-change="handleMockFilePageChange" >
+ + +
+ 接口列表 +
+ + 新增接口 +
+
+ + + + + + + + + + + + +
+
+ - - - - - - + - + + + @@ -196,12 +244,13 @@ v-model="form.config.proxyPort" :min="1" :max="65535" + @change="saveServerConfig" > - + @@ -210,12 +259,13 @@ v-model="form.config.targetPort" :min="1" :max="65535" + @change="saveServerConfig" > - + @@ -320,10 +370,12 @@ + > + + 保存 Mock 文件 + + + + + + + + + + + + 取消 + 确定 + +
@@ -351,7 +422,18 @@ return { activeMainTab: "routes", apiList: [], + apiPagination: { currentPage: 1, pageSize: 10 }, + apiDialog: { + visible: false, + mode: "create", + editingIndex: -1, + form: { name: "", route: "", originalRoute: "" }, + }, mockFiles: [], + contentTypeOptions: [ + "application/json", + "application/x-www-form-urlencoded", + ], commonStatusCodes: [ { value: 200, label: "200 OK" }, { value: 201, label: "201 Created" }, @@ -368,6 +450,9 @@ { value: 502, label: "502 Bad Gateway" }, { value: 503, label: "503 Service Unavailable" }, ], + routeSearch: "", + mockFileSearch: "", + apiSearch: "", routePagination: { currentPage: 1, pageSize: 10, @@ -397,7 +482,6 @@ apiName: "", selectedApiRoute: "", originalRoute: "", - originalRawRoute: "", route: "", filePath: "", statusCode: 200, @@ -408,13 +492,18 @@ visible: false, mode: "create", form: { - filePath: "mock/", + fileName: "", alias: "", content: "", }, }, }; }, + watch: { + routeSearch: function () { this.routePagination.currentPage = 1; }, + mockFileSearch: function () { this.mockFilePagination.currentPage = 1; }, + apiSearch: function () { this.apiPagination.currentPage = 1; }, + }, created: async function () { await this.loadApiList(); await this.loadMockFiles(); @@ -456,22 +545,20 @@ return item.route === route; }); }, - toRouteArray: function (routesObj, routeStatusesObj) { + toRouteArray: function (routesObj, routeStatusesObj, routeEnabledMap, routeApiNameMap) { var self = this; - return Object.keys(routesObj || {}).map(function (rawRoute) { - var enabled = !rawRoute.startsWith("#"); - var route = enabled ? rawRoute : rawRoute.replace(/^#+/, ""); - var matched = self.findApiByRoute(route); + return Object.keys(routesObj || {}).map(function (route) { + var storedName = (routeApiNameMap || {})[route] || ""; + var matched = storedName ? null : self.findApiByRoute(route); return { - rawRoute: rawRoute, route: route, - filePath: routesObj[rawRoute], + filePath: routesObj[route], statusCode: self.normalizeStatusCode( - routeStatusesObj && routeStatusesObj[rawRoute], + routeStatusesObj && routeStatusesObj[route], ), - apiName: matched ? matched.name : "", + apiName: storedName || (matched ? matched.name : ""), selectedApiRoute: matched ? matched.route : "", - enabled: enabled, + enabled: (routeEnabledMap || {})[route] !== false, }; }); }, @@ -481,8 +568,7 @@ var route = (item.route || "").trim(); var filePath = (item.filePath || "").trim(); if (route && filePath) { - var routeKey = item.enabled === false ? "#" + route : route; - obj[routeKey] = filePath; + obj[route] = filePath; } }); return obj; @@ -493,24 +579,42 @@ (routeArray || []).forEach(function (item) { var route = (item.route || "").trim(); if (!route) return; - var routeKey = item.enabled === false ? "#" + route : route; - var statusCode = self.normalizeStatusCode(item.statusCode); - obj[routeKey] = statusCode; + obj[route] = self.normalizeStatusCode(item.statusCode); }); return obj; }, - removeRoute: function (index) { + toRouteEnabledMap: function (routeArray) { + var obj = {}; + (routeArray || []).forEach(function (item) { + var route = (item.route || "").trim(); + if (route) { + obj[route] = item.enabled !== false; + } + }); + return obj; + }, + toRouteApiNameMap: function (routeArray) { + var obj = {}; + (routeArray || []).forEach(function (item) { + var route = (item.route || "").trim(); + if (route) { + obj[route] = item.apiName || ""; + } + }); + return obj; + }, + removeRoute: async function (index) { var actualIndex = (this.routePagination.currentPage - 1) * this.routePagination.pageSize + index; this.form.routes.splice(actualIndex, 1); + await this.saveRoutes(); }, getDefaultRouteForm: function () { return { apiName: "", selectedApiRoute: "", originalRoute: "", - originalRawRoute: "", route: "", filePath: "", statusCode: 200, @@ -549,7 +653,6 @@ apiName: matched ? matched.name : item.apiName || "", selectedApiRoute: matched ? matched.route : "", originalRoute: item.route || "", - originalRawRoute: item.rawRoute || item.route || "", route: item.route || "", filePath: item.filePath || "mock/", statusCode: this.normalizeStatusCode(item.statusCode), @@ -559,7 +662,7 @@ }, getDefaultMockFileForm: function () { return { - filePath: "mock/", + fileName: "", alias: "", content: "", }; @@ -570,9 +673,11 @@ this.mockFileDialog.visible = true; }, openMockFileDialogForEdit: function (item) { + var full = item.filePath || ""; + var name = full.indexOf("mock/") === 0 ? full.slice(5) : full; this.mockFileDialog.mode = "edit"; this.mockFileDialog.form = { - filePath: item.filePath || "mock/", + fileName: name, alias: String(item.alias || ""), content: String(item.content || ""), }; @@ -593,16 +698,22 @@ } }, submitMockFileDialog: async function () { - if (!this.mockFileDialog.form.filePath) { - this.$message.error("Mock 文件路径不能为空"); + var fileName = (this.mockFileDialog.form.fileName || "").trim(); + if (!fileName) { + this.$message.error("文件名不能为空"); return; } + if (fileName.indexOf("/") !== -1 || fileName.indexOf("\\") !== -1) { + this.$message.error("文件名不能包含路径分隔符"); + return; + } + var filePath = "mock/" + fileName; try { var resp = await fetch("/__mock-files", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - filePath: this.mockFileDialog.form.filePath, + filePath: filePath, alias: this.mockFileDialog.form.alias, content: this.mockFileDialog.form.content, }), @@ -663,17 +774,20 @@ this.form.routes = this.toRouteArray( data.routes || {}, data.routeStatuses || {}, + data.routeEnabledMap || {}, + data.routeApiNameMap || {}, ); this.routePagination.currentPage = 1; } catch (err) { this.$message.error("加载配置失败: " + err.message); } }, - saveConfig: async function () { + saveRoutes: async function () { var payload = { - config: this.form.config, routes: this.toRouteObject(this.form.routes), routeStatuses: this.toRouteStatusObject(this.form.routes), + routeEnabledMap: this.toRouteEnabledMap(this.form.routes), + routeApiNameMap: this.toRouteApiNameMap(this.form.routes), }; try { var resp = await fetch("/__config", { @@ -685,22 +799,23 @@ if (!resp.ok || data.success === false) { throw new Error(data.error || "保存失败"); } - this.$message.success("配置已保存"); } catch (err) { - this.$message.error("保存失败: " + err.message); + this.$message.error("保存路由失败: " + err.message); } }, - reloadConfig: async function () { + saveServerConfig: async function () { try { - var resp = await fetch("/__reload-config", { method: "POST" }); + var resp = await fetch("/__config", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ config: this.form.config }), + }); var data = await resp.json(); if (!resp.ok || data.success === false) { - throw new Error(data.error || "重载失败"); + throw new Error(data.error || "保存失败"); } - this.$message.success("配置已重载"); - this.loadConfig(); } catch (err) { - this.$message.error("重载失败: " + err.message); + this.$message.error("保存配置失败: " + err.message); } }, submitRouteDialog: async function () { @@ -738,13 +853,127 @@ this.$message.error("创建失败: " + err.message); } }, + handleApiPageChange: function (page) { + this.apiPagination.currentPage = page; + }, + openApiDialogForCreate: function () { + this.apiDialog.mode = "create"; + this.apiDialog.editingIndex = -1; + this.apiDialog.form = { name: "", route: "", originalRoute: "" }; + this.apiDialog.visible = true; + }, + openApiDialogForEdit: function (index) { + var actualIndex = + (this.apiPagination.currentPage - 1) * this.apiPagination.pageSize + index; + var item = this.apiList[actualIndex]; + this.apiDialog.mode = "edit"; + this.apiDialog.editingIndex = actualIndex; + this.apiDialog.form = { + name: item.name, + route: item.route, + originalRoute: item.route, + }; + this.apiDialog.visible = true; + }, + submitApiDialog: async function () { + if (!this.apiDialog.form.name) { + this.$message.error("接口名称不能为空"); + return; + } + if (!this.apiDialog.form.route || !this.apiDialog.form.route.startsWith("/")) { + this.$message.error("请求路径必须以 / 开头"); + return; + } + try { + var resp = await fetch("/__api-list", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(this.apiDialog.form), + }); + var data = await resp.json(); + if (!resp.ok || data.success === false) { + throw new Error(data.error || "操作失败"); + } + this.$message.success(this.apiDialog.mode === "edit" ? "接口已修改" : "接口已添加"); + this.apiDialog.visible = false; + await this.loadApiList(); + } catch (err) { + this.$message.error("操作失败: " + err.message); + } + }, + removeApiItem: async function (index) { + var actualIndex = + (this.apiPagination.currentPage - 1) * this.apiPagination.pageSize + index; + var item = this.apiList[actualIndex]; + try { + await this.$confirm("确定删除接口「" + item.name + "」?", "提示", { + confirmButtonText: "确定", + cancelButtonText: "取消", + type: "warning", + }); + var resp = await fetch("/__api-list", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ route: item.route }), + }); + var data = await resp.json(); + if (!resp.ok || data.success === false) { + throw new Error(data.error || "删除失败"); + } + this.$message.success("接口已删除"); + await this.loadApiList(); + } catch (err) { + if (err !== "cancel") { + this.$message.error("删除失败: " + err.message); + } + } + }, }, computed: { + filteredRoutes: function () { + var q = (this.routeSearch || "").toLowerCase().trim(); + var list = this.form.routes; + if (q) { + list = list.filter(function (item) { + return ( + (item.route || "").toLowerCase().indexOf(q) !== -1 || + (item.apiName || "").toLowerCase().indexOf(q) !== -1 || + (item.filePath || "").toLowerCase().indexOf(q) !== -1 + ); + }); + } + return list.slice().sort(function (a, b) { + return (b.enabled !== false ? 1 : 0) - (a.enabled !== false ? 1 : 0); + }); + }, + filteredMockFiles: function () { + var q = (this.mockFileSearch || "").toLowerCase().trim(); + if (!q) return this.mockFiles; + return this.mockFiles.filter(function (item) { + return ( + (item.filePath || "").toLowerCase().indexOf(q) !== -1 || + (item.alias || "").toLowerCase().indexOf(q) !== -1 + ); + }); + }, + filteredApiList: function () { + var q = (this.apiSearch || "").toLowerCase().trim(); + if (!q) return this.apiList; + return this.apiList.filter(function (item) { + return ( + (item.route || "").toLowerCase().indexOf(q) !== -1 || + (item.name || "").toLowerCase().indexOf(q) !== -1 + ); + }); + }, pagedRoutes: function () { - return this.getPagedData(this.form.routes, this.routePagination); + return this.getPagedData(this.filteredRoutes, this.routePagination); }, pagedMockFiles: function () { - return this.getPagedData(this.mockFiles, this.mockFilePagination); + return this.getPagedData(this.filteredMockFiles, this.mockFilePagination); + }, + pagedApiList: function () { + return this.getPagedData(this.filteredApiList, this.apiPagination); }, }, }); diff --git a/config.json b/config.json index 66d5285..37f42cf 100644 --- a/config.json +++ b/config.json @@ -1,22 +1,13 @@ { "routes": { - "#/api1/account/bind": "mock/basic-error.json", - "/api2/admin/banner/list": "mock/banner.txt", - "/api2/home/ad/list": "mock/basic-error.json" - }, - "routeStatuses": { - "#/api1/account/bind": 500, - "/api2/admin/banner/list": 200, - "/api2/home/ad/list": 200 + "/api2/test": "mock/test.txt" }, "config": { - "mockEnabled": true, "cacheConfig": true, "reloadOnChange": true, "defaultContentType": "application/json", - "proxyPort": 8879, - "targetHost": "192.168.3.9", - "targetPort": 8092, - "targetHttps": false + "proxyPort": 443, + "targetHost": "localhost", + "targetPort": 443 } } \ No newline at end of file diff --git a/index.api.ts b/index.api.ts index 56bc8df..8c3e989 100644 --- a/index.api.ts +++ b/index.api.ts @@ -1,1191 +1,64 @@ -import * as http from "http"; -import * as https from "https"; import * as fs from "fs"; import * as path from "path"; -import * as zlib from "zlib"; -import sqlite3 from "sqlite3"; - -// 配置文件路径 -const CONFIG_FILE = path.join(__dirname, "config.json"); -const API_LIST_FILE = path.join(__dirname, "mock", "api-list.json"); -const MOCK_DIR = path.join(__dirname, "mock"); -const DATA_DIR = path.join(__dirname, "data"); -const DB_FILE = path.join(DATA_DIR, "mock-mappings.sqlite3"); -const LEGACY_DB_FILE = path.join(MOCK_DIR, "mock-mappings.sqlite3"); - -// 定义类型 -interface RouteConfig { - [route: string]: string; -} - -interface RouteStatusConfig { - [route: string]: number; -} - -interface AppConfig { - /** 为 false 时所有请求走代理,不命中 mock 路由;缺省为 true */ - mockEnabled?: boolean; - cacheConfig: boolean; - reloadOnChange: boolean; - defaultContentType: string; - proxyPort: number; - targetHost: string; - /** 代理转发的目标端口;缺省 HTTPS 为 443,HTTP 为 80 */ - targetPort?: number; - /** 为 false 时用 HTTP 连接上游;缺省 true(HTTPS) */ - targetHttps?: boolean; -} - -interface ConfigFile { - routes: RouteConfig; - routeStatuses?: RouteStatusConfig; - config: AppConfig; -} - -interface ApiListItem { - name: string; - route: string; -} - -interface MockFileItem { - filePath: string; - alias: string; - content: string; -} - -interface RouteRow { - route_key: string; - file_path: string; - status_code: number; -} - -interface MockFileRow { - file_path: string; - alias: string; -} - -// 存储当前的路由配置 -let MOCK_ROUTES: RouteConfig = {}; -let RAW_ROUTES: RouteConfig = {}; -let MOCK_ROUTE_STATUSES: RouteStatusConfig = {}; -let RAW_ROUTE_STATUSES: RouteStatusConfig = {}; -let CONFIG: AppConfig = { - cacheConfig: true, - reloadOnChange: true, - defaultContentType: "application/json", - proxyPort: 443, - targetHost: "localhost", - targetPort: 443, -}; -let DB: sqlite3.Database; - -// 过滤掉以 # 开头的路由(视为注释,不参与 mock) -function filterActiveRoutes(routes: RouteConfig): RouteConfig { - const filtered: RouteConfig = {}; - for (const [route, filePath] of Object.entries(routes)) { - if (route.startsWith("#")) continue; - filtered[route] = filePath; - } - return filtered; -} - -function filterActiveRouteStatuses(statuses: RouteStatusConfig): RouteStatusConfig { - const filtered: RouteStatusConfig = {}; - for (const [route, statusCode] of Object.entries(statuses)) { - if (route.startsWith("#")) continue; - filtered[route] = statusCode; - } - return filtered; -} - -function normalizeStatusCode(input: unknown, fallback = 200): number { - const numeric = - typeof input === "number" ? input : Number.parseInt(String(input ?? ""), 10); - if (Number.isInteger(numeric) && numeric >= 100 && numeric <= 599) { - return numeric; - } - return fallback; -} - -function isTargetHttps(): boolean { - return CONFIG.targetHttps !== false; -} - -function getTargetPort(): number { - if (CONFIG.targetPort != null) return CONFIG.targetPort; - return isTargetHttps() ? 443 : 80; -} - -function upstreamRequest( - options: http.RequestOptions, - callback: (proxyRes: http.IncomingMessage) => void, -): http.ClientRequest { - return isTargetHttps() - ? https.request(options, callback) - : http.request(options, callback); -} - -function dbRun(sql: string, params: unknown[] = []): Promise { - return new Promise((resolve, reject) => { - DB.run(sql, params, (error) => { - if (error) { - reject(error); - return; - } - resolve(); - }); - }); -} - -function dbAll(sql: string, params: unknown[] = []): Promise { - return new Promise((resolve, reject) => { - DB.all(sql, params, (error, rows) => { - if (error) { - reject(error); - return; - } - resolve((rows as T[]) || []); - }); - }); -} - -function openDatabase(): Promise { - return new Promise((resolve, reject) => { - DB = new sqlite3.Database(DB_FILE, (error) => { - if (error) { - reject(error); - return; - } - resolve(); - }); - }); -} - -async function initDatabase(): Promise { - fs.mkdirSync(MOCK_DIR, { recursive: true }); - fs.mkdirSync(DATA_DIR, { recursive: true }); - if (!fs.existsSync(DB_FILE) && fs.existsSync(LEGACY_DB_FILE)) { - fs.copyFileSync(LEGACY_DB_FILE, DB_FILE); - } - await openDatabase(); - await dbRun(` - CREATE TABLE IF NOT EXISTS route_mappings ( - route_key TEXT PRIMARY KEY, - file_path TEXT NOT NULL, - status_code INTEGER NOT NULL DEFAULT 200 - ) - `); - await dbRun( - "ALTER TABLE route_mappings ADD COLUMN status_code INTEGER NOT NULL DEFAULT 200", - ).catch(() => { - // ignore when column already exists - }); - await dbRun(` - CREATE TABLE IF NOT EXISTS api_list ( - route TEXT PRIMARY KEY, - name TEXT NOT NULL - ) - `); - await dbRun(` - CREATE TABLE IF NOT EXISTS mock_files ( - file_path TEXT PRIMARY KEY, - alias TEXT NOT NULL DEFAULT '' - ) - `); - await dbRun("ALTER TABLE mock_files ADD COLUMN alias TEXT NOT NULL DEFAULT ''").catch( - () => { - // ignore when column already exists - }, - ); - // 清理不该出现在 mock 列表中的系统文件 - await dbRun( - "DELETE FROM mock_files WHERE file_path = ? OR file_path LIKE ?", - ["mock/api-list.json", "%.sqlite3"], - ); -} - -async function saveRoutesToDb( - routes: RouteConfig, - routeStatuses: RouteStatusConfig = {}, -): Promise { - await dbRun("DELETE FROM route_mappings"); - for (const [routeKey, filePath] of Object.entries(routes)) { - const statusCode = normalizeStatusCode(routeStatuses[routeKey], 200); - await dbRun( - "INSERT INTO route_mappings(route_key, file_path, status_code) VALUES(?, ?, ?)", - [routeKey, filePath, statusCode], - ); - await dbRun("INSERT OR IGNORE INTO mock_files(file_path) VALUES(?)", [ - filePath, - ]); - } -} - -async function loadRoutesFromDb(): Promise<{ - routes: RouteConfig; - routeStatuses: RouteStatusConfig; -}> { - const rows = await dbAll( - "SELECT route_key, file_path, status_code FROM route_mappings ORDER BY route_key ASC", - ); - const routes: RouteConfig = {}; - const routeStatuses: RouteStatusConfig = {}; - for (const row of rows) { - routes[row.route_key] = row.file_path; - routeStatuses[row.route_key] = normalizeStatusCode(row.status_code, 200); - } - return { routes, routeStatuses }; -} - -async function upsertApiListToDb(list: ApiListItem[]): Promise { - await dbRun("DELETE FROM api_list"); - for (const item of list) { - await dbRun("INSERT INTO api_list(route, name) VALUES(?, ?)", [ - item.route, - item.name, - ]); - } -} - -async function loadApiListFromDb(): Promise { - const rows = await dbAll<{ route: string; name: string }>( - "SELECT route, name FROM api_list ORDER BY route ASC", - ); - return rows.map((row) => ({ route: row.route, name: row.name })); -} - -async function upsertMockFilePathToDb(filePath: string, alias?: string): Promise { - if (typeof alias === "string") { - await dbRun( - "INSERT INTO mock_files(file_path, alias) VALUES(?, ?) ON CONFLICT(file_path) DO UPDATE SET alias = excluded.alias", - [filePath, alias], - ); - return; - } - await dbRun("INSERT OR IGNORE INTO mock_files(file_path, alias) VALUES(?, '')", [ - filePath, - ]); -} - -async function removeMockFilePathFromDb(filePath: string): Promise { - await dbRun("DELETE FROM mock_files WHERE file_path = ?", [filePath]); -} - -async function loadMockFilePathsFromDb(): Promise { - const rows = await dbAll( - "SELECT file_path, alias FROM mock_files ORDER BY file_path ASC", - ); - return rows.map((row) => ({ - file_path: row.file_path, - alias: String(row.alias || ""), - })); -} - -// 加载配置文件 -async function loadConfig() { - try { - if (!fs.existsSync(CONFIG_FILE)) { - console.warn(`[CONFIG] 配置文件不存在: ${CONFIG_FILE}`); - console.warn(`[CONFIG] 正在创建默认配置文件...`); - - // 创建默认配置 - const defaultConfig: ConfigFile = { - routes: { - "/api2/test": "mock/test.txt", - }, - config: { - cacheConfig: true, - reloadOnChange: true, - defaultContentType: "application/json", - proxyPort: 443, - targetHost: "localhost", - targetPort: 443, - }, - }; - - // 确保mock目录存在 - const mockDir = path.join(__dirname, "mock"); - if (!fs.existsSync(mockDir)) { - fs.mkdirSync(mockDir, { recursive: true }); - } - - // 保存配置文件 - fs.writeFileSync( - CONFIG_FILE, - JSON.stringify(defaultConfig, null, 2), - "utf-8", - ); - console.log(`[CONFIG] 已创建默认配置文件: ${CONFIG_FILE}`); - - // 加载配置,路由最终以 sqlite 为准 - const configData: ConfigFile = JSON.parse( - fs.readFileSync(CONFIG_FILE, "utf-8"), - ); - CONFIG = configData.config || CONFIG; - await saveRoutesToDb(configData.routes || {}, configData.routeStatuses || {}); - } else { - const configData: ConfigFile = JSON.parse( - fs.readFileSync(CONFIG_FILE, "utf-8"), - ); - CONFIG = configData.config || CONFIG; - const dbMappings = await loadRoutesFromDb(); - if (Object.keys(dbMappings.routes).length === 0 && configData.routes) { - await saveRoutesToDb(configData.routes, configData.routeStatuses || {}); - } - } - - const dbMappings = await loadRoutesFromDb(); - RAW_ROUTES = dbMappings.routes; - RAW_ROUTE_STATUSES = dbMappings.routeStatuses; - MOCK_ROUTES = filterActiveRoutes(RAW_ROUTES); - MOCK_ROUTE_STATUSES = filterActiveRouteStatuses(RAW_ROUTE_STATUSES); - console.log( - `[CONFIG] 配置已加载(sqlite 路由 ${Object.keys(MOCK_ROUTES).length} 条)${CONFIG.mockEnabled !== false ? "" : "(mock 已关闭,全部走代理)"}`, - ); - - // 验证mock文件是否存在 - validateMockFiles(); - } catch (error) { - console.error(`[CONFIG] 加载配置文件失败:`, error); - // 使用默认配置 - MOCK_ROUTES = { - "/api2/user/list": "mock/user_list.txt", - }; - RAW_ROUTES = { ...MOCK_ROUTES }; - RAW_ROUTE_STATUSES = {}; - MOCK_ROUTE_STATUSES = {}; - CONFIG = { - cacheConfig: true, - reloadOnChange: true, - defaultContentType: "application/json", - proxyPort: 9443, - targetHost: "devrmtapp.resmart.cn", - targetPort: 443, - }; - } -} - -function buildCurrentConfigFile(): ConfigFile { - return { - routes: { ...RAW_ROUTES }, - routeStatuses: { ...RAW_ROUTE_STATUSES }, - config: { ...CONFIG }, - }; -} - -async function saveConfigFile(nextConfig: ConfigFile): Promise { - // routes 持久化到 sqlite,config 仍使用 config.json - await saveRoutesToDb(nextConfig.routes || {}, nextConfig.routeStatuses || {}); - fs.writeFileSync(CONFIG_FILE, JSON.stringify(nextConfig, null, 2), "utf-8"); - const dbMappings = await loadRoutesFromDb(); - RAW_ROUTES = dbMappings.routes; - RAW_ROUTE_STATUSES = dbMappings.routeStatuses; - MOCK_ROUTES = filterActiveRoutes(RAW_ROUTES); - MOCK_ROUTE_STATUSES = filterActiveRouteStatuses(RAW_ROUTE_STATUSES); - CONFIG = nextConfig.config || CONFIG; -} - -function loadApiListFromJson(): ApiListItem[] { - if (!fs.existsSync(API_LIST_FILE)) { - return []; - } - try { - const text = fs.readFileSync(API_LIST_FILE, "utf-8").trim(); - if (!text) { - return []; - } - const parsed = JSON.parse(text); - if (!Array.isArray(parsed)) { - return []; - } - const seenRoutes = new Set(); - const list: ApiListItem[] = []; - for (const item of parsed) { - const route = String(item?.route || "").trim(); - const name = String(item?.name || "").trim(); - if (!route || !route.startsWith("/") || !name || seenRoutes.has(route)) { - continue; - } - seenRoutes.add(route); - list.push({ name, route }); - } - return list; - } catch (error) { - console.warn("[ADMIN] 读取 api-list.json 失败:", error); - return []; - } -} - -async function loadApiList(): Promise { - const dbList = await loadApiListFromDb(); - if (dbList.length > 0) { - return dbList; - } - const jsonList = loadApiListFromJson(); - if (jsonList.length > 0) { - await upsertApiListToDb(jsonList); - } - return jsonList; -} - -function normalizeMockFilePath(filePath: string): string { - const trimmed = filePath.trim().replace(/\\/g, "/"); - if (!trimmed) { - throw new Error("filePath is required"); - } - const relative = trimmed.startsWith("mock/") ? trimmed : `mock/${trimmed}`; - if (path.isAbsolute(relative)) { - throw new Error("filePath must be a relative path"); - } - return relative; -} - -function resolveMockFullPath(relativePath: string): string { - const normalized = normalizeMockFilePath(relativePath); - const fullPath = path.resolve(__dirname, normalized); - const mockRoot = path.resolve(MOCK_DIR); - if (!fullPath.startsWith(mockRoot)) { - throw new Error("filePath is invalid"); - } - if (path.basename(fullPath) === "api-list.json") { - throw new Error("api-list.json is read-only in this panel"); - } - return fullPath; -} - -function walkMockFiles(dir: string, baseDir: string, result: string[]): void { - const entries = fs.readdirSync(dir, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - walkMockFiles(fullPath, baseDir, result); - continue; - } - if (!entry.isFile()) continue; - if (entry.name === "api-list.json") continue; - if (entry.name.endsWith(".sqlite3")) continue; - const relative = path.relative(baseDir, fullPath).replace(/\\/g, "/"); - result.push(`mock/${relative}`); - } -} - -async function loadMockFiles(): Promise { - let files = await loadMockFilePathsFromDb(); - files = files.filter( - (item) => - item.file_path !== "mock/api-list.json" && - !item.file_path.endsWith(".sqlite3"), - ); - if (files.length === 0 && fs.existsSync(MOCK_DIR)) { - const fsFiles: string[] = []; - walkMockFiles(MOCK_DIR, MOCK_DIR, fsFiles); - fsFiles.sort((a, b) => a.localeCompare(b)); - for (const filePath of fsFiles) { - await upsertMockFilePathToDb(filePath); - } - files = fsFiles.map((filePath) => ({ file_path: filePath, alias: "" })); - } - - const result: MockFileItem[] = []; - for (const item of files) { - const filePath = item.file_path; - const fullPath = path.join(__dirname, filePath); - if (!fs.existsSync(fullPath)) continue; - result.push({ - filePath, - alias: String(item.alias || ""), - content: fs.readFileSync(fullPath, "utf-8"), - }); - } - return result; -} - -async function initMockFilesFromFsIfNeeded(): Promise { - const filesInDb = await loadMockFilePathsFromDb(); - if (filesInDb.length > 0 || !fs.existsSync(MOCK_DIR)) return; - if (!fs.existsSync(MOCK_DIR)) { - return; - } - const files: string[] = []; - walkMockFiles(MOCK_DIR, MOCK_DIR, files); - files.sort((a, b) => a.localeCompare(b)); - for (const filePath of files) { - await upsertMockFilePathToDb(filePath); - } -} - -function readBody(req: http.IncomingMessage): Promise { - return new Promise((resolve, reject) => { - const chunks: Buffer[] = []; - req.on("data", (chunk: Buffer) => { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - }); - req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8"))); - req.on("error", reject); - }); -} - -// 验证mock文件是否存在 -function validateMockFiles() { - console.log(`[CONFIG] 验证mock文件...`); - let missingFiles: Array<{ - route: string; - filePath: string; - fullPath: string; - }> = []; - - for (const [route, filePath] of Object.entries(MOCK_ROUTES)) { - const fullPath = path.join(__dirname, filePath); - if (!fs.existsSync(fullPath)) { - missingFiles.push({ route, filePath, fullPath }); - console.warn( - `[CONFIG] 警告: mock文件不存在 - ${filePath} (用于路由: ${route})`, - ); - } - } - - if (missingFiles.length > 0) { - console.log( - `[CONFIG] 缺少 ${missingFiles.length} 个mock文件,请创建这些文件`, - ); - } else { - console.log(`[CONFIG] 所有mock文件验证通过`); - } -} - -// 检查是否为mock路由的函数 -function isMockRoute(requestPath: string): boolean { - if (CONFIG.mockEnabled === false) return false; - return MOCK_ROUTES.hasOwnProperty(requestPath); -} - -// 获取mock文件路径 -function getMockFilePath(requestPath: string): string { - return MOCK_ROUTES[requestPath]; -} - -function getMockStatusCode(requestPath: string): number { - return normalizeStatusCode(MOCK_ROUTE_STATUSES[requestPath], 200); -} - -function decodeBodyByEncoding( - bodyBuffer: Buffer, - contentEncoding?: string, -): Buffer { - const encoding = (contentEncoding || "").toLowerCase().trim(); - try { - if (encoding.includes("gzip")) { - return zlib.gunzipSync(bodyBuffer); - } - if (encoding.includes("br")) { - return zlib.brotliDecompressSync(bodyBuffer); - } - if (encoding.includes("deflate")) { - return zlib.inflateSync(bodyBuffer); - } - } catch (error) { - console.warn("[MOCK] 解压上游响应失败,按原始内容写入文件", error); - } - return bodyBuffer; -} - -// 代理服务器 -const proxyServer = http.createServer(async (clientReq, clientRes) => { - // 解析客户端请求的 URL - const parsedUrl = new URL(`http://localhost${clientReq.url!}`); - const requestPath = parsedUrl.pathname; - - // 管理接口:保留路径,不参与代理转发 - if (requestPath === "/__reload-config") { - if (clientReq.method !== "POST") { - clientRes.writeHead(405, { - "Content-Type": "application/json", - Allow: "POST", - }); - clientRes.end( - JSON.stringify({ - success: false, - error: "Method Not Allowed", - allow: ["POST"], - }), - ); - return; - } - - try { - const oldCount = Object.keys(MOCK_ROUTES).length; - await loadConfig(); - const newCount = Object.keys(MOCK_ROUTES).length; - - clientRes.writeHead(200, { "Content-Type": "application/json" }); - clientRes.end( - JSON.stringify({ - success: true, - message: "Configuration reloaded successfully", - routesCount: newCount, - routesChanged: newCount - oldCount, - }), - ); - console.log( - `[ADMIN] 通过API重新加载配置 (路由数: ${oldCount} -> ${newCount})`, - ); - } catch (error) { - clientRes.writeHead(500, { "Content-Type": "application/json" }); - clientRes.end( - JSON.stringify({ - success: false, - error: (error as Error).message, - }), - ); - } - return; - } - - if (requestPath === "/__config") { - if (clientReq.method !== "GET" && clientReq.method !== "POST") { - clientRes.writeHead(405, { - "Content-Type": "application/json", - Allow: "GET, POST", - }); - clientRes.end( - JSON.stringify({ - success: false, - error: "Method Not Allowed", - allow: ["GET", "POST"], - }), - ); - return; - } - - if (clientReq.method === "POST") { - readBody(clientReq) - .then(async (bodyText) => { - const body = bodyText ? JSON.parse(bodyText) : {}; - const nextConfig: ConfigFile = { - routes: body.routes ?? RAW_ROUTES, - routeStatuses: body.routeStatuses ?? RAW_ROUTE_STATUSES, - config: body.config || CONFIG, - }; - await saveConfigFile(nextConfig); - validateMockFiles(); - clientRes.writeHead(200, { "Content-Type": "application/json" }); - clientRes.end( - JSON.stringify({ - success: true, - message: "Configuration saved successfully", - totalRoutes: Object.keys(MOCK_ROUTES).length, - }), - ); - }) - .catch((error) => { - clientRes.writeHead(400, { "Content-Type": "application/json" }); - clientRes.end( - JSON.stringify({ - success: false, - error: (error as Error).message, - }), - ); - }); - return; - } - - try { - clientRes.writeHead(200, { "Content-Type": "application/json" }); - clientRes.end( - JSON.stringify( - { - routes: RAW_ROUTES, - routeStatuses: RAW_ROUTE_STATUSES, - config: CONFIG, - timestamp: new Date().toISOString(), - totalRoutes: Object.keys(MOCK_ROUTES).length, - }, - null, - 2, - ), - ); - } catch (error) { - clientRes.writeHead(500, { "Content-Type": "application/json" }); - clientRes.end( - JSON.stringify({ - success: false, - error: (error as Error).message, - }), - ); - } - return; - } - - if (requestPath === "/__routes") { - if (clientReq.method !== "POST") { - clientRes.writeHead(405, { - "Content-Type": "application/json", - Allow: "POST", - }); - clientRes.end( - JSON.stringify({ - success: false, - error: "Method Not Allowed", - allow: ["POST"], - }), - ); - return; - } - - readBody(clientReq) - .then(async (bodyText) => { - const body = bodyText ? JSON.parse(bodyText) : {}; - let route = String(body.route || "").trim(); - const filePath = String(body.filePath || "").trim(); - const fileContent = String(body.fileContent || ""); - const overwrite = body.overwrite === true; - const template = String(body.template || "").trim(); - const apiName = String(body.apiName || "").trim(); - const selectedApiRoute = String(body.selectedApiRoute || "").trim(); - const originalRoute = String(body.originalRoute || "").trim(); - const originalRawRoute = String(body.originalRawRoute || "").trim(); - const statusCode = normalizeStatusCode(body.statusCode, 200); - const enabled = body.enabled !== false; - const useExistingFile = - body.useExistingFile === true || template === "basicError"; - const apiList = await loadApiList(); - const selectedApi = selectedApiRoute - ? apiList.find((item) => item.route === selectedApiRoute) - : undefined; - const originalApi = originalRoute - ? apiList.find((item) => item.route === originalRoute) - : undefined; - - // 来自 api-list 的接口路由不可在新增/修改时变更 - if (selectedApi) { - if (route && route !== selectedApi.route) { - throw new Error("api-list route cannot be changed"); - } - if (apiName && apiName !== selectedApi.name) { - throw new Error("api-list name cannot be changed"); - } - route = selectedApi.route; - } - if (originalApi && route !== originalApi.route) { - throw new Error("api-list route cannot be changed"); - } - if (originalApi && apiName && apiName !== originalApi.name) { - throw new Error("api-list name cannot be changed"); - } - - if (!route.startsWith("/")) { - throw new Error("route must start with '/'"); - } - const normalizedFilePath = normalizeMockFilePath(filePath); - const fullPath = resolveMockFullPath(normalizedFilePath); - if (useExistingFile) { - if (!fs.existsSync(fullPath)) { - throw new Error("mock file does not exist"); - } - } else { - if (!overwrite && fs.existsSync(fullPath)) { - throw new Error( - "mock file already exists, set overwrite=true to replace", - ); - } - fs.mkdirSync(path.dirname(fullPath), { recursive: true }); - fs.writeFileSync(fullPath, fileContent, "utf-8"); - } - - const nextConfig = buildCurrentConfigFile(); - const nextRouteKey = enabled ? route : `#${route}`; - const oldRouteCandidates = new Set(); - if (originalRawRoute) { - oldRouteCandidates.add(originalRawRoute); - } - if (originalRoute) { - oldRouteCandidates.add(originalRoute); - oldRouteCandidates.add(`#${originalRoute}`); - } - oldRouteCandidates.forEach((key) => { - if (key && key !== nextRouteKey) { - delete nextConfig.routes[key]; - delete nextConfig.routeStatuses?.[key]; - } - }); - nextConfig.routes[nextRouteKey] = normalizedFilePath; - if (!nextConfig.routeStatuses) { - nextConfig.routeStatuses = {}; - } - nextConfig.routeStatuses[nextRouteKey] = statusCode; - await saveConfigFile(nextConfig); - await upsertMockFilePathToDb(normalizedFilePath); - - clientRes.writeHead(200, { "Content-Type": "application/json" }); - clientRes.end( - JSON.stringify({ - success: true, - message: "Route and mock file created successfully", - route, - routeKey: nextRouteKey, - filePath: normalizedFilePath, - statusCode, - enabled, - }), - ); - }) - .catch((error) => { - clientRes.writeHead(400, { "Content-Type": "application/json" }); - clientRes.end( - JSON.stringify({ - success: false, - error: (error as Error).message, - }), - ); - }); - return; - } - - if (requestPath === "/__api-list") { - if (clientReq.method !== "GET") { - clientRes.writeHead(405, { - "Content-Type": "application/json", - Allow: "GET", - }); - clientRes.end( - JSON.stringify({ - success: false, - error: "Method Not Allowed", - allow: ["GET"], - }), - ); - return; - } - clientRes.writeHead(200, { "Content-Type": "application/json" }); - clientRes.end( - JSON.stringify({ - success: true, - list: await loadApiList(), - }), - ); - return; - } - - if (requestPath === "/__mock-files") { - if ( - clientReq.method !== "GET" && - clientReq.method !== "POST" && - clientReq.method !== "DELETE" - ) { - clientRes.writeHead(405, { - "Content-Type": "application/json", - Allow: "GET, POST, DELETE", - }); - clientRes.end( - JSON.stringify({ - success: false, - error: "Method Not Allowed", - allow: ["GET", "POST", "DELETE"], - }), - ); - return; - } - - if (clientReq.method === "GET") { - clientRes.writeHead(200, { "Content-Type": "application/json" }); - clientRes.end( - JSON.stringify({ - success: true, - list: await loadMockFiles(), - }), - ); - return; - } - - readBody(clientReq) - .then(async (bodyText) => { - const body = bodyText ? JSON.parse(bodyText) : {}; - const filePath = String(body.filePath || "").trim(); - const alias = String(body.alias || ""); - const fullPath = resolveMockFullPath(filePath); - const normalizedPath = normalizeMockFilePath(filePath); - - if (clientReq.method === "POST") { - const content = String(body.content || ""); - fs.mkdirSync(path.dirname(fullPath), { recursive: true }); - fs.writeFileSync(fullPath, content, "utf-8"); - await upsertMockFilePathToDb(normalizedPath, alias); - clientRes.writeHead(200, { "Content-Type": "application/json" }); - clientRes.end( - JSON.stringify({ - success: true, - filePath: normalizedPath, - alias, - }), - ); - return; - } - - // DELETE - if (!fs.existsSync(fullPath)) { - throw new Error("mock file does not exist"); - } - fs.unlinkSync(fullPath); - await removeMockFilePathFromDb(normalizedPath); - clientRes.writeHead(200, { "Content-Type": "application/json" }); - clientRes.end( - JSON.stringify({ - success: true, - filePath: normalizedPath, - }), - ); - }) - .catch((error) => { - clientRes.writeHead(400, { "Content-Type": "application/json" }); - clientRes.end( - JSON.stringify({ - success: false, - error: (error as Error).message, - }), - ); - }); - return; - } - - if (requestPath === "/__admin") { - if (clientReq.method !== "GET") { - clientRes.writeHead(405, { "Content-Type": "text/plain; charset=utf-8" }); - clientRes.end("Method Not Allowed"); - return; - } - const adminHtmlPath = path.join(__dirname, "admin.html"); - if (!fs.existsSync(adminHtmlPath)) { - clientRes.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); - clientRes.end("admin.html not found"); - return; - } - const html = fs.readFileSync(adminHtmlPath, "utf-8"); - clientRes.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); - clientRes.end(html); - return; - } - - // 检查是否为需要mock的路由 - if (isMockRoute(requestPath)) { - const mockFile = getMockFilePath(requestPath); - const mockStatusCode = getMockStatusCode(requestPath); - console.log(`[MOCK] 拦截路由: ${requestPath} -> 使用文件: ${mockFile}`); - - try { - // 构建完整的文件路径 - const mockFilePath = path.join(__dirname, mockFile); - - // 检查文件是否存在 - if (!fs.existsSync(mockFilePath)) { - console.warn(`[MOCK] Mock文件不存在,回源并自动生成: ${mockFilePath}`); - - const targetPort = getTargetPort(); - const options: http.RequestOptions = { - hostname: CONFIG.targetHost, - port: targetPort, - method: clientReq.method, - path: parsedUrl.pathname + parsedUrl.search, - headers: { - ...clientReq.headers, - host: CONFIG.targetHost, - }, - }; - - const proxyReq = upstreamRequest(options, (proxyRes) => { - const chunks: Buffer[] = []; - proxyRes.on("data", (chunk: Buffer) => { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - }); - - proxyRes.on("end", () => { - const bodyBuffer = Buffer.concat(chunks); - - try { - const decodedBuffer = decodeBodyByEncoding( - bodyBuffer, - Array.isArray(proxyRes.headers["content-encoding"]) - ? proxyRes.headers["content-encoding"][0] - : proxyRes.headers["content-encoding"], - ); - const contentType = String(proxyRes.headers["content-type"] || ""); - const toWrite = - contentType.includes("application/json") || - contentType.includes("text/") || - contentType.includes("application/xml") || - contentType.includes("application/javascript") - ? decodedBuffer.toString("utf-8") - : decodedBuffer; - fs.mkdirSync(path.dirname(mockFilePath), { recursive: true }); - fs.writeFileSync(mockFilePath, toWrite); - console.log(`[MOCK] 已自动写入mock文件: ${mockFilePath}`); - } catch (writeErr) { - console.error(`[MOCK] 自动写入mock文件失败: ${mockFilePath}`, writeErr); - } - - clientRes.writeHead(mockStatusCode, { - ...proxyRes.headers, - "X-Mock-Autogenerated": "true", - "X-Mock-Source": mockFile, - "X-Mock-Status-Code": String(mockStatusCode), - }); - clientRes.end(bodyBuffer); - }); - }); - - proxyReq.on("error", (err) => { - console.error("Proxy request error:", err); - clientRes.writeHead(500, { "Content-Type": "application/json" }); - clientRes.end( - JSON.stringify({ - error: "Proxy error", - route: requestPath, - file: mockFile, - message: err.message, - timestamp: new Date().toISOString(), - }), - ); - }); - - clientReq.pipe(proxyReq); - return; - } - - // 读取本地mock文件 - const mockData = fs.readFileSync(mockFilePath, "utf-8"); - - // 设置响应头 - const contentType = CONFIG.defaultContentType || "application/json"; - clientRes.writeHead(mockStatusCode, { - "Content-Type": contentType, - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type", - "X-Mock-Source": mockFile, - "X-Mock-Status-Code": String(mockStatusCode), - "X-Mock-Timestamp": new Date().toISOString(), - }); - - // 返回mock数据 - clientRes.end(mockData); - console.log(`[MOCK] 成功返回mock数据: ${mockFile}`); - } catch (error) { - console.error(`[MOCK] 读取mock文件失败: ${mockFile}`, error); - clientRes.writeHead(500, { "Content-Type": "application/json" }); - clientRes.end( - JSON.stringify({ - error: "Failed to read mock data", - route: requestPath, - file: mockFile, - message: (error as Error).message, - timestamp: new Date().toISOString(), - }), - ); - } - return; // 直接返回,不转发到目标服务器 - } - - // 如果不是mock路由,正常代理转发 - console.log(`[PROXY] 转发请求: ${requestPath}`); - - const targetPort = getTargetPort(); - - // 目标服务器的选项 - const options: http.RequestOptions = { - hostname: CONFIG.targetHost, - port: targetPort, - method: clientReq.method, - path: parsedUrl.pathname + parsedUrl.search, - headers: { - ...clientReq.headers, - host: CONFIG.targetHost, - }, - }; - - const proxyReq = upstreamRequest(options, (proxyRes) => { - // 将目标服务器的响应头复制到客户端响应 - clientRes.writeHead(proxyRes.statusCode!, proxyRes.headers); - // 将目标服务器的响应数据管道传输到客户端 - proxyRes.pipe(clientRes); - }); - - // 错误处理 - proxyReq.on("error", (err) => { - console.error("Proxy request error:", err); - clientRes.writeHead(500, { "Content-Type": "application/json" }); - clientRes.end( - JSON.stringify({ - error: "Proxy error", - message: err.message, - timestamp: new Date().toISOString(), - }), - ); - }); - - // 将客户端请求体管道传输到代理请求 - clientReq.pipe(proxyReq); -}); - -function setupConfigWatcher(): void { - if (!CONFIG.reloadOnChange) return; - fs.watchFile(CONFIG_FILE, async () => { - console.log(`[CONFIG] 配置文件已修改,重新加载...`); - try { - const oldRoutesCount = Object.keys(MOCK_ROUTES).length; - await loadConfig(); - const newRoutesCount = Object.keys(MOCK_ROUTES).length; - console.log( - `[CONFIG] 配置重载完成 (路由数: ${oldRoutesCount} -> ${newRoutesCount})`, - ); - } catch (error) { - console.error(`[CONFIG] 重新加载配置文件失败:`, error); - } - }); - console.log(`[CONFIG] 已启用配置文件监视: ${CONFIG_FILE}`); -} +import { migrateApiListFromJsonIfNeeded } from "./src/api-list"; +import { loadConfig } from "./src/config"; +import { getActiveRoutes } from "./src/route-matching"; +import { initDatabase } from "./src/db"; +import { initMockFilesFromFsIfNeeded } from "./src/mock-files"; +import { + createProxyServer, + getEffectiveTargetHttps, + getEffectiveTargetPort, +} from "./src/proxy"; +import { state } from "./src/state"; function startServer(): void { - proxyServer.listen(CONFIG.proxyPort, "0.0.0.0", () => { - const targetPort = getTargetPort(); - const proto = isTargetHttps() ? "https" : "http"; - const defaultPort = isTargetHttps() ? 443 : 80; - console.log(`========================================`); - console.log(`代理服务器运行在: http://localhost:${CONFIG.proxyPort}`); - console.log( - `目标服务器: ${proto}://${CONFIG.targetHost}${targetPort !== defaultPort ? `:${targetPort}` : ""}`, - ); - console.log(`配置文件: ${CONFIG_FILE}`); - console.log( - `已配置Mock路由: ${Object.keys(MOCK_ROUTES).length} 个${CONFIG.mockEnabled !== false ? "" : "(当前 mockEnabled=false,未生效)"}`, - ); - console.log(`========================================`); - console.log(`管理接口:`); - console.log( - ` GET http://localhost:${CONFIG.proxyPort}/__config 查看当前配置`, - ); - console.log( - ` POST http://localhost:${CONFIG.proxyPort}/__reload-config 重新加载配置`, - ); - console.log(`========================================`); - console.log(`Mock路由列表:`); + const proxyServer = createProxyServer(); + proxyServer.listen(state.config.proxyPort, "0.0.0.0", () => { + const targetPort = getEffectiveTargetPort(); + const https = getEffectiveTargetHttps(); + const proto = https ? "https" : "http"; + const defaultPort = https ? 443 : 80; + console.log(`========================================`); + console.log( + `代理服务器运行在: http://localhost:${state.config.proxyPort}`, + ); + console.log( + `目标服务器: ${proto}://${state.config.targetHost}${targetPort !== defaultPort ? `:${targetPort}` : ""}`, + ); + console.log(`数据库: data/mock-mappings.sqlite3`); + const activeRoutes = getActiveRoutes(); + console.log( + `已配置Mock路由: ${Object.keys(activeRoutes).length} 个${state.config.mockEnabled !== false ? "" : "(当前 mockEnabled=false,未生效)"}`, + ); + console.log(`========================================`); + console.log(`管理接口:`); + console.log( + ` GET http://localhost:${state.config.proxyPort}/__config 查看当前配置`, + ); + console.log( + ` POST http://localhost:${state.config.proxyPort}/__reload-config 重新加载配置`, + ); + console.log( + ` GET http://localhost:${state.config.proxyPort}/__admin 管理面板`, + ); + console.log(`========================================`); + console.log(`Mock路由列表:`); - for (const [route, file] of Object.entries(MOCK_ROUTES)) { - const filePath = path.join(__dirname, file); - const exists = fs.existsSync(filePath) ? "✓" : "✗"; - console.log(` ${exists} ${route} -> ${file}`); - } - console.log(`========================================`); + for (const [route, file] of Object.entries(activeRoutes)) { + const filePath = path.join(__dirname, file); + const exists = fs.existsSync(filePath) ? "✓" : "✗"; + console.log(` ${exists} ${route} -> ${file}`); + } + console.log(`========================================`); }); } async function bootstrap(): Promise { await initDatabase(); await initMockFilesFromFsIfNeeded(); + await migrateApiListFromJsonIfNeeded(); await loadConfig(); - const apiListFromDb = await loadApiListFromDb(); - if (apiListFromDb.length === 0) { - await upsertApiListToDb(loadApiListFromJson()); - } - setupConfigWatcher(); startServer(); } diff --git a/mock/api-list.json b/mock/api-list.json index 5581cdb..41b42e6 100644 --- a/mock/api-list.json +++ b/mock/api-list.json @@ -1,60 +1,3 @@ [ - { "name": "登录/注册发送验证码", "route": "/api1/authentication/sms/send" }, - { "name": "绑定手机号发送验证码", "route": "/api1/authentication/bind/send" }, - { "name": "账号注销", "route": "/api1/account/revoke" }, - { "name": "绑定手机号", "route": "/api1/account/bind" }, - { "name": "认证授权刷新接口", "route": "/api1/oauth2/token" }, - { "name": "一键登录阿里云授权码", "route": "/api1/authentication/common" }, - { "name": "更新用户信息", "route": "/api2/user/update" }, - { "name": "获取用户信息", "route": "/api2/user" }, - { "name": "我的链接", "route": "/api2/link" }, - { "name": "获取字典类型", "route": "/api2/dict/types" }, - { "name": "获取字典项", "route": "/api2/dict/" }, - { "name": "获取设备列表", "route": "/api2/device/list" }, - { "name": "根据类型获取设备列表", "route": "/api2/device/type/list" }, - { "name": "查询历史设备列表", "route": "/api2/device/history/list" }, - { "name": "绑定设备", "route": "/api2/device/save" }, - { "name": "切换设备", "route": "/api2/device/switch" }, - { "name": "用机人主设备", "route": "/api2/device/master" }, - { "name": "解绑设备", "route": "/api2/device/unbind" }, - { "name": "获取设备信息", "route": "/api2/device/one" }, - { "name": "获取设备店铺", "route": "/api2/device/store" }, - { "name": "获取设备图片", "route": "/api2/device/bind/img" }, - { "name": "查询设备报告日期", "route": "/api2/device/report/date" }, - { "name": "查询是否有报告页", "route": "/api2/device/reports" }, - - { "name": "查询使用教程列表", "route": "/api2/course/list" }, - { "name": "查询白脸教程列表", "route": "/api2/course/white/face/list" }, - { "name": "获取省份城市列表", "route": "/api2/sys/provinces" }, - { "name": "制氧机报告单天", "route": "/api2/oxygenerator/report/day" }, - { "name": "制氧机报告多天", "route": "/api2/oxygenerator/report/multi/day" }, - { "name": "血氧仪报告单天", "route": "/api2/oximeter/report/day" }, - { "name": "血氧仪报告多天", "route": "/api2/oximeter/report/multi/day" }, - { "name": "呼吸机报告单天", "route": "/api2/ventilator/report/day" }, - { "name": "呼吸机报告多天", "route": "/api2/ventilator/report/multi/day" }, - { "name": "血氧仪健康报告单天", "route": "/api2/oximeter/health/report/day" }, - { "name": "血氧仪健康报告多天", "route": "/api2/oximeter/health/report/multi/day" }, - { "name": "血氧仪报告详情", "route": "/api2/oximeter/report/detail" }, - { "name": "首页判断", "route": "/api2/home/flag" }, - { "name": "首页Banner", "route": "/api2/home/ad/list" }, - { "name": "首页卡片信息", "route": "/api2/home/card/info" }, - { "name": "修改首页卡片", "route": "/api2/home/card/update" }, - { "name": "协议列表", "route": "/api2/protocol/list" }, - { "name": "同意协议", "route": "/api2/protocol/agree" }, - { "name": "协议是否更新", "route": "/api2/protocol/has/update" }, - - { "name": "扫码获取报告", "route": "/api2/qr/scan" }, - { "name": "查询QR报告详情", "route": "/api2/qr/one" }, - { "name": "查询QR报告列表", "route": "/api2/qr/page" }, - { "name": "获取报告数量", "route": "/api2/qr/count" }, - - { "name": "检查健康自测人数", "route": "/api2/self/check/head/count" }, - { "name": "健康自测结果", "route": "/api2/self/check/save" }, - - { "name": "科普分类列表", "route": "/api2/health/category/list" }, - { "name": "科普列表", "route": "/api2/health/service/list" }, - { "name": "科普详情", "route": "/api2/health/service/getById" }, - - { "name": "deepSeek流式接口", "route": "/api2/deepSeek/stream" } ] diff --git a/mock/test.txt b/mock/test.txt new file mode 100644 index 0000000..0f0016a --- /dev/null +++ b/mock/test.txt @@ -0,0 +1 @@ +{"code":0,"msg":"ok"} diff --git a/src/admin-handlers.ts b/src/admin-handlers.ts new file mode 100644 index 0000000..c303651 --- /dev/null +++ b/src/admin-handlers.ts @@ -0,0 +1,485 @@ +import * as fs from "fs"; +import * as http from "http"; +import * as path from "path"; +import { loadApiList } from "./api-list"; +import { reloadConfig, saveConfig, saveRoutes, validateMockFiles } from "./config"; +import { + upsertMockFilePathToDb, + removeMockFilePathFromDb, + insertApiItemToDb, + updateApiItemInDb, + deleteApiItemFromDb, +} from "./db"; +import { + loadMockFiles, + normalizeMockFilePath, + resolveMockFullPath, +} from "./mock-files"; +import { normalizeStatusCode, readBody } from "./utils"; +import { state } from "./state"; + +async function handleReloadConfig( + clientReq: http.IncomingMessage, + clientRes: http.ServerResponse, +): Promise { + if (clientReq.method !== "POST") { + clientRes.writeHead(405, { + "Content-Type": "application/json", + Allow: "POST", + }); + clientRes.end( + JSON.stringify({ + success: false, + error: "Method Not Allowed", + allow: ["POST"], + }), + ); + return; + } + + try { + const oldCount = Object.keys(state.rawRoutes).length; + await reloadConfig(); + const newCount = Object.keys(state.rawRoutes).length; + + clientRes.writeHead(200, { "Content-Type": "application/json" }); + clientRes.end( + JSON.stringify({ + success: true, + message: "Configuration reloaded successfully", + routesCount: newCount, + routesChanged: newCount - oldCount, + }), + ); + console.log( + `[ADMIN] 通过API重新加载配置 (路由数: ${oldCount} -> ${newCount})`, + ); + } catch (error) { + clientRes.writeHead(500, { "Content-Type": "application/json" }); + clientRes.end( + JSON.stringify({ + success: false, + error: (error as Error).message, + }), + ); + } +} + +async function handleConfig( + clientReq: http.IncomingMessage, + clientRes: http.ServerResponse, +): Promise { + if (clientReq.method !== "GET" && clientReq.method !== "POST") { + clientRes.writeHead(405, { + "Content-Type": "application/json", + Allow: "GET, POST", + }); + clientRes.end( + JSON.stringify({ + success: false, + error: "Method Not Allowed", + allow: ["GET", "POST"], + }), + ); + return; + } + + if (clientReq.method === "POST") { + readBody(clientReq) + .then(async (bodyText) => { + const body = bodyText ? JSON.parse(bodyText) : {}; + // 保存服务器配置 + if (body.config) { + await saveConfig(body.config); + } + // 保存路由配置 + if (body.routes || body.routeStatuses) { + await saveRoutes( + body.routes ?? state.rawRoutes, + body.routeStatuses ?? state.rawRouteStatuses, + body.routeEnabledMap ?? state.routeEnabledMap, + body.routeApiNameMap ?? state.routeApiNameMap, + ); + } + validateMockFiles(); + clientRes.writeHead(200, { "Content-Type": "application/json" }); + clientRes.end( + JSON.stringify({ + success: true, + message: "Configuration saved successfully", + totalRoutes: Object.keys(state.rawRoutes).length, + }), + ); + }) + .catch((error) => { + clientRes.writeHead(400, { "Content-Type": "application/json" }); + clientRes.end( + JSON.stringify({ + success: false, + error: (error as Error).message, + }), + ); + }); + return; + } + + try { + clientRes.writeHead(200, { "Content-Type": "application/json" }); + clientRes.end( + JSON.stringify( + { + routes: state.rawRoutes, + routeStatuses: state.rawRouteStatuses, + routeEnabledMap: state.routeEnabledMap, + routeApiNameMap: state.routeApiNameMap, + config: state.config, + timestamp: new Date().toISOString(), + totalRoutes: Object.keys(state.rawRoutes).length, + }, + null, + 2, + ), + ); + } catch (error) { + clientRes.writeHead(500, { "Content-Type": "application/json" }); + clientRes.end( + JSON.stringify({ + success: false, + error: (error as Error).message, + }), + ); + } +} + +async function handleRoutes( + clientReq: http.IncomingMessage, + clientRes: http.ServerResponse, +): Promise { + if (clientReq.method !== "POST") { + clientRes.writeHead(405, { + "Content-Type": "application/json", + Allow: "POST", + }); + clientRes.end( + JSON.stringify({ + success: false, + error: "Method Not Allowed", + allow: ["POST"], + }), + ); + return; + } + + readBody(clientReq) + .then(async (bodyText) => { + const body = bodyText ? JSON.parse(bodyText) : {}; + let route = String(body.route || "").trim(); + const filePath = String(body.filePath || "").trim(); + const fileContent = String(body.fileContent || ""); + const overwrite = body.overwrite === true; + const template = String(body.template || "").trim(); + const apiName = String(body.apiName || "").trim(); + const selectedApiRoute = String(body.selectedApiRoute || "").trim(); + const originalRoute = String(body.originalRoute || "").trim(); + const statusCode = normalizeStatusCode(body.statusCode, 200); + const enabled = body.enabled !== false; + const useExistingFile = + body.useExistingFile === true || template === "basicError"; + const apiList = await loadApiList(); + const selectedApi = selectedApiRoute + ? apiList.find((item) => item.route === selectedApiRoute) + : undefined; + const originalApi = originalRoute + ? apiList.find((item) => item.route === originalRoute) + : undefined; + + // 来自 api-list 的接口路由不可在新增/修改时变更 + if (selectedApi) { + if (route && route !== selectedApi.route) { + throw new Error("api-list route cannot be changed"); + } + if (apiName && apiName !== selectedApi.name) { + throw new Error("api-list name cannot be changed"); + } + route = selectedApi.route; + } + if (originalApi && route !== originalApi.route) { + throw new Error("api-list route cannot be changed"); + } + if (originalApi && apiName && apiName !== originalApi.name) { + throw new Error("api-list name cannot be changed"); + } + + if (!route.startsWith("/")) { + throw new Error("route must start with '/'"); + } + const normalizedFilePath = normalizeMockFilePath(filePath); + const fullPath = resolveMockFullPath(normalizedFilePath); + if (useExistingFile) { + if (!fs.existsSync(fullPath)) { + throw new Error("mock file does not exist"); + } + } else { + if (!overwrite && fs.existsSync(fullPath)) { + throw new Error( + "mock file already exists, set overwrite=true to replace", + ); + } + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, fileContent, "utf-8"); + } + + // 构建新的路由映射 + const nextRoutes = { ...state.rawRoutes }; + const nextStatuses = { ...state.rawRouteStatuses }; + const nextEnabledMap = { ...state.routeEnabledMap }; + const nextApiNameMap = { ...state.routeApiNameMap }; + const oldRouteCandidates = new Set(); + if (originalRoute) { + oldRouteCandidates.add(originalRoute); + } + oldRouteCandidates.forEach((key) => { + if (key && key !== route) { + delete nextRoutes[key]; + delete nextStatuses[key]; + delete nextEnabledMap[key]; + delete nextApiNameMap[key]; + } + }); + nextRoutes[route] = normalizedFilePath; + nextStatuses[route] = statusCode; + nextEnabledMap[route] = enabled; + nextApiNameMap[route] = apiName; + await saveRoutes(nextRoutes, nextStatuses, nextEnabledMap, nextApiNameMap); + await upsertMockFilePathToDb(normalizedFilePath); + + clientRes.writeHead(200, { "Content-Type": "application/json" }); + clientRes.end( + JSON.stringify({ + success: true, + message: "Route and mock file created successfully", + route, + filePath: normalizedFilePath, + statusCode, + enabled, + }), + ); + }) + .catch((error) => { + clientRes.writeHead(400, { "Content-Type": "application/json" }); + clientRes.end( + JSON.stringify({ + success: false, + error: (error as Error).message, + }), + ); + }); +} + +async function handleApiList( + clientReq: http.IncomingMessage, + clientRes: http.ServerResponse, +): Promise { + if (clientReq.method !== "GET" && clientReq.method !== "POST" && clientReq.method !== "DELETE") { + clientRes.writeHead(405, { + "Content-Type": "application/json", + Allow: "GET, POST, DELETE", + }); + clientRes.end( + JSON.stringify({ + success: false, + error: "Method Not Allowed", + allow: ["GET, POST, DELETE"], + }), + ); + return; + } + + if (clientReq.method === "GET") { + clientRes.writeHead(200, { "Content-Type": "application/json" }); + clientRes.end( + JSON.stringify({ + success: true, + list: await loadApiList(), + }), + ); + return; + } + + readBody(clientReq) + .then(async (bodyText) => { + const body = bodyText ? JSON.parse(bodyText) : {}; + const route = String(body.route || "").trim(); + const name = String(body.name || "").trim(); + const originalRoute = String(body.originalRoute || "").trim(); + + if (!route || !route.startsWith("/")) { + throw new Error("route must start with '/'"); + } + + if (clientReq.method === "POST") { + if (!name) { + throw new Error("name is required"); + } + if (originalRoute && originalRoute !== route) { + await updateApiItemInDb(originalRoute, { route, name }); + } else if (originalRoute) { + await updateApiItemInDb(originalRoute, { route, name }); + } else { + await insertApiItemToDb({ route, name }); + } + clientRes.writeHead(200, { "Content-Type": "application/json" }); + clientRes.end(JSON.stringify({ success: true, route, name })); + return; + } + + // DELETE + await deleteApiItemFromDb(route); + clientRes.writeHead(200, { "Content-Type": "application/json" }); + clientRes.end(JSON.stringify({ success: true, route })); + }) + .catch((error) => { + clientRes.writeHead(400, { "Content-Type": "application/json" }); + clientRes.end( + JSON.stringify({ + success: false, + error: (error as Error).message, + }), + ); + }); +} + +async function handleMockFiles( + clientReq: http.IncomingMessage, + clientRes: http.ServerResponse, +): Promise { + if ( + clientReq.method !== "GET" && + clientReq.method !== "POST" && + clientReq.method !== "DELETE" + ) { + clientRes.writeHead(405, { + "Content-Type": "application/json", + Allow: "GET, POST, DELETE", + }); + clientRes.end( + JSON.stringify({ + success: false, + error: "Method Not Allowed", + allow: ["GET, POST, DELETE"], + }), + ); + return; + } + + if (clientReq.method === "GET") { + clientRes.writeHead(200, { "Content-Type": "application/json" }); + clientRes.end( + JSON.stringify({ + success: true, + list: await loadMockFiles(), + }), + ); + return; + } + + readBody(clientReq) + .then(async (bodyText) => { + const body = bodyText ? JSON.parse(bodyText) : {}; + const filePath = String(body.filePath || "").trim(); + const alias = String(body.alias || ""); + const fullPath = resolveMockFullPath(filePath); + const normalizedPath = normalizeMockFilePath(filePath); + + if (clientReq.method === "POST") { + const content = String(body.content || ""); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, content, "utf-8"); + await upsertMockFilePathToDb(normalizedPath, alias); + clientRes.writeHead(200, { "Content-Type": "application/json" }); + clientRes.end( + JSON.stringify({ + success: true, + filePath: normalizedPath, + alias, + }), + ); + return; + } + + // DELETE + if (!fs.existsSync(fullPath)) { + throw new Error("mock file does not exist"); + } + fs.unlinkSync(fullPath); + await removeMockFilePathFromDb(normalizedPath); + clientRes.writeHead(200, { "Content-Type": "application/json" }); + clientRes.end( + JSON.stringify({ + success: true, + filePath: normalizedPath, + }), + ); + }) + .catch((error) => { + clientRes.writeHead(400, { "Content-Type": "application/json" }); + clientRes.end( + JSON.stringify({ + success: false, + error: (error as Error).message, + }), + ); + }); +} + +async function handleAdminPage( + clientReq: http.IncomingMessage, + clientRes: http.ServerResponse, +): Promise { + if (clientReq.method !== "GET") { + clientRes.writeHead(405, { "Content-Type": "text/plain; charset=utf-8" }); + clientRes.end("Method Not Allowed"); + return; + } + const adminHtmlPath = path.join(__dirname, "..", "admin.html"); + if (!fs.existsSync(adminHtmlPath)) { + clientRes.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); + clientRes.end("admin.html not found"); + return; + } + const html = fs.readFileSync(adminHtmlPath, "utf-8"); + clientRes.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + clientRes.end(html); +} + +/** + * 分发管理接口请求。如果请求路径匹配管理端点则处理并返回 true,否则返回 false。 + */ +export async function dispatchAdmin( + requestPath: string, + clientReq: http.IncomingMessage, + clientRes: http.ServerResponse, +): Promise { + switch (requestPath) { + case "/__reload-config": + await handleReloadConfig(clientReq, clientRes); + return true; + case "/__config": + await handleConfig(clientReq, clientRes); + return true; + case "/__routes": + await handleRoutes(clientReq, clientRes); + return true; + case "/__api-list": + await handleApiList(clientReq, clientRes); + return true; + case "/__mock-files": + await handleMockFiles(clientReq, clientRes); + return true; + case "/__admin": + await handleAdminPage(clientReq, clientRes); + return true; + default: + return false; + } +} diff --git a/src/api-list.ts b/src/api-list.ts new file mode 100644 index 0000000..dded2a4 --- /dev/null +++ b/src/api-list.ts @@ -0,0 +1,52 @@ +import * as fs from "fs"; +import { API_LIST_FILE } from "./constants"; +import { loadApiListFromDb, upsertApiListToDb } from "./db"; +import type { ApiListItem } from "./types"; + +function parseApiListFromJson(): ApiListItem[] { + if (!fs.existsSync(API_LIST_FILE)) { + return []; + } + try { + const text = fs.readFileSync(API_LIST_FILE, "utf-8").trim(); + if (!text) { + return []; + } + const parsed = JSON.parse(text); + if (!Array.isArray(parsed)) { + return []; + } + const seenRoutes = new Set(); + const list: ApiListItem[] = []; + for (const item of parsed) { + const route = String(item?.route || "").trim(); + const name = String(item?.name || "").trim(); + if (!route || !route.startsWith("/") || !name || seenRoutes.has(route)) { + continue; + } + seenRoutes.add(route); + list.push({ name, route }); + } + return list; + } catch (error) { + console.warn("[ADMIN] 读取 api-list.json 失败:", error); + return []; + } +} + +// 一次性迁移:如果 DB 为空且 api-list.json 存在,导入到 DB +export async function migrateApiListFromJsonIfNeeded(): Promise { + const dbList = await loadApiListFromDb(); + if (dbList.length > 0) return; + const jsonList = parseApiListFromJson(); + if (jsonList.length > 0) { + await upsertApiListToDb(jsonList); + console.log( + `[BOOTSTRAP] 已从 api-list.json 迁移 ${jsonList.length} 条接口到数据库`, + ); + } +} + +export async function loadApiList(): Promise { + return loadApiListFromDb(); +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..b220ebc --- /dev/null +++ b/src/config.ts @@ -0,0 +1,112 @@ +import * as fs from "fs"; +import * as path from "path"; +import { + saveRoutesToDb, + loadRoutesFromDb, + loadServerConfig, + saveServerConfig, +} from "./db"; +import { getActiveRoutes } from "./route-matching"; +import { state } from "./state"; +import type { AppConfig, RouteApiNameMap, RouteEnabledMap } from "./types"; + +// 验证mock文件是否存在 +export function validateMockFiles(): void { + console.log(`[CONFIG] 验证mock文件...`); + const missingFiles: Array<{ + route: string; + filePath: string; + fullPath: string; + }> = []; + + const activeRoutes = getActiveRoutes(); + for (const [route, filePath] of Object.entries(activeRoutes)) { + const fullPath = path.join(__dirname, "..", filePath); + if (!fs.existsSync(fullPath)) { + missingFiles.push({ route, filePath, fullPath }); + console.warn( + `[CONFIG] 警告: mock文件不存在 - ${filePath} (用于路由: ${route})`, + ); + } + } + + if (missingFiles.length > 0) { + console.log( + `[CONFIG] 缺少 ${missingFiles.length} 个mock文件,请创建这些文件`, + ); + } else { + console.log(`[CONFIG] 所有mock文件验证通过`); + } +} + +// 从 SQLite 加载全部配置 +export async function loadConfig(): Promise { + try { + state.config = await loadServerConfig(); + + const dbMappings = await loadRoutesFromDb(); + state.rawRoutes = dbMappings.routes; + state.rawRouteStatuses = dbMappings.routeStatuses; + state.routeEnabledMap = dbMappings.routeEnabledMap; + state.routeApiNameMap = dbMappings.routeApiNameMap; + + const activeCount = Object.values(state.routeEnabledMap).filter( + (v) => v !== false, + ).length; + console.log( + `[CONFIG] 配置已加载(路由 ${activeCount} 条)${state.config.mockEnabled !== false ? "" : "(mock 已关闭,全部走代理)"}`, + ); + + validateMockFiles(); + } catch (error) { + console.error(`[CONFIG] 加载配置失败:`, error); + state.rawRoutes = {}; + state.rawRouteStatuses = {}; + state.routeEnabledMap = {}; + state.routeApiNameMap = {}; + state.config = { + cacheConfig: true, + reloadOnChange: true, + defaultContentType: "application/json", + proxyPort: 8879, + targetHost: "localhost", + targetPort: 80, + targetHttps: false, + }; + } +} + +// 保存服务器配置到 SQLite +export async function saveConfig(nextConfig: AppConfig): Promise { + await saveServerConfig(nextConfig); + state.config = nextConfig; +} + +// 保存路由配置到 SQLite 并刷新内存 +export async function saveRoutes( + routes: Record, + routeStatuses: Record, + routeEnabledMap: RouteEnabledMap = {}, + routeApiNameMap: RouteApiNameMap = {}, +): Promise { + await saveRoutesToDb(routes, routeStatuses, routeEnabledMap, routeApiNameMap); + const dbMappings = await loadRoutesFromDb(); + state.rawRoutes = dbMappings.routes; + state.rawRouteStatuses = dbMappings.routeStatuses; + state.routeEnabledMap = dbMappings.routeEnabledMap; + state.routeApiNameMap = dbMappings.routeApiNameMap; +} + +// 重新从 SQLite 加载全部配置 +export async function reloadConfig(): Promise { + const oldCount = Object.values(state.routeEnabledMap).filter( + (v) => v !== false, + ).length; + await loadConfig(); + const newCount = Object.values(state.routeEnabledMap).filter( + (v) => v !== false, + ).length; + console.log( + `[CONFIG] 配置重载完成 (路由数: ${oldCount} -> ${newCount})`, + ); +} diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..cbcdfb1 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,7 @@ +import * as path from "path"; + +export const API_LIST_FILE = path.join(__dirname, "..", "mock", "api-list.json"); +export const MOCK_DIR = path.join(__dirname, "..", "mock"); +export const DATA_DIR = path.join(__dirname, "..", "data"); +export const DB_FILE = path.join(DATA_DIR, "mock-mappings.sqlite3"); +export const LEGACY_DB_FILE = path.join(MOCK_DIR, "mock-mappings.sqlite3"); diff --git a/src/db.ts b/src/db.ts new file mode 100644 index 0000000..5ca289f --- /dev/null +++ b/src/db.ts @@ -0,0 +1,293 @@ +import * as fs from "fs"; +import sqlite3 from "sqlite3"; +import { DB_FILE, LEGACY_DB_FILE, MOCK_DIR, DATA_DIR } from "./constants"; +import { state } from "./state"; +import { normalizeStatusCode } from "./utils"; +import type { + ApiListItem, + AppConfig, + MockFileRow, + RouteApiNameMap, + RouteConfig, + RouteEnabledMap, + RouteRow, + RouteStatusConfig, +} from "./types"; + +export function dbRun(sql: string, params: unknown[] = []): Promise { + return new Promise((resolve, reject) => { + state.db.run(sql, params, (error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); +} + +export function dbAll( + sql: string, + params: unknown[] = [], +): Promise { + return new Promise((resolve, reject) => { + state.db.all(sql, params, (error, rows) => { + if (error) { + reject(error); + return; + } + resolve((rows as T[]) || []); + }); + }); +} + +export function openDatabase(): Promise { + return new Promise((resolve, reject) => { + state.db = new sqlite3.Database(DB_FILE, (error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); +} + +export async function initDatabase(): Promise { + fs.mkdirSync(MOCK_DIR, { recursive: true }); + fs.mkdirSync(DATA_DIR, { recursive: true }); + if (!fs.existsSync(DB_FILE) && fs.existsSync(LEGACY_DB_FILE)) { + fs.copyFileSync(LEGACY_DB_FILE, DB_FILE); + } + await openDatabase(); + await dbRun(` + CREATE TABLE IF NOT EXISTS route_mappings ( + route_key TEXT PRIMARY KEY, + file_path TEXT NOT NULL, + status_code INTEGER NOT NULL DEFAULT 200 + ) + `); + await dbRun( + "ALTER TABLE route_mappings ADD COLUMN status_code INTEGER NOT NULL DEFAULT 200", + ).catch(() => { + // ignore when column already exists + }); + await dbRun( + "ALTER TABLE route_mappings ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1", + ).catch(() => { + // ignore when column already exists + }); + await dbRun( + "ALTER TABLE route_mappings ADD COLUMN api_name TEXT NOT NULL DEFAULT ''", + ).catch(() => { + // ignore when column already exists + }); + // 迁移:将 # 前缀路由转为 enabled=0 并去掉前缀 + const hashRoutes = await dbAll( + "SELECT route_key, file_path, status_code FROM route_mappings WHERE route_key LIKE '#%'", + ); + for (const row of hashRoutes) { + const cleanKey = row.route_key.replace(/^#+/, ""); + await dbRun("DELETE FROM route_mappings WHERE route_key = ?", [row.route_key]); + await dbRun( + "INSERT OR IGNORE INTO route_mappings(route_key, file_path, status_code, enabled) VALUES(?, ?, ?, 0)", + [cleanKey, row.file_path, row.status_code], + ); + } + await dbRun(` + CREATE TABLE IF NOT EXISTS api_list ( + route TEXT PRIMARY KEY, + name TEXT NOT NULL + ) + `); + await dbRun(` + CREATE TABLE IF NOT EXISTS mock_files ( + file_path TEXT PRIMARY KEY, + alias TEXT NOT NULL DEFAULT '' + ) + `); + await dbRun( + "ALTER TABLE mock_files ADD COLUMN alias TEXT NOT NULL DEFAULT ''", + ).catch(() => { + // ignore when column already exists + }); + await dbRun(` + CREATE TABLE IF NOT EXISTS server_config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + `); + // 清理不该出现在 mock 列表中的系统文件 + await dbRun( + "DELETE FROM mock_files WHERE file_path = ? OR file_path LIKE ?", + ["mock/api-list.json", "%.sqlite3"], + ); +} + +export async function saveRoutesToDb( + routes: RouteConfig, + routeStatuses: RouteStatusConfig = {}, + routeEnabledMap: RouteEnabledMap = {}, + routeApiNameMap: RouteApiNameMap = {}, +): Promise { + await dbRun("DELETE FROM route_mappings"); + for (const [routeKey, filePath] of Object.entries(routes)) { + const statusCode = normalizeStatusCode(routeStatuses[routeKey], 200); + const enabled = routeEnabledMap[routeKey] !== false ? 1 : 0; + const apiName = routeApiNameMap[routeKey] || ""; + await dbRun( + "INSERT INTO route_mappings(route_key, file_path, status_code, enabled, api_name) VALUES(?, ?, ?, ?, ?)", + [routeKey, filePath, statusCode, enabled, apiName], + ); + await dbRun("INSERT OR IGNORE INTO mock_files(file_path) VALUES(?)", [ + filePath, + ]); + } +} + +export async function loadRoutesFromDb(): Promise<{ + routes: RouteConfig; + routeStatuses: RouteStatusConfig; + routeEnabledMap: RouteEnabledMap; + routeApiNameMap: RouteApiNameMap; +}> { + const rows = await dbAll( + "SELECT route_key, file_path, status_code, enabled, api_name FROM route_mappings ORDER BY route_key ASC", + ); + const routes: RouteConfig = {}; + const routeStatuses: RouteStatusConfig = {}; + const routeEnabledMap: RouteEnabledMap = {}; + const routeApiNameMap: RouteApiNameMap = {}; + for (const row of rows) { + routes[row.route_key] = row.file_path; + routeStatuses[row.route_key] = normalizeStatusCode(row.status_code, 200); + routeEnabledMap[row.route_key] = row.enabled !== 0; + if (row.api_name) { + routeApiNameMap[row.route_key] = row.api_name; + } + } + return { routes, routeStatuses, routeEnabledMap, routeApiNameMap }; +} + +export async function upsertApiListToDb(list: ApiListItem[]): Promise { + await dbRun("DELETE FROM api_list"); + for (const item of list) { + await dbRun("INSERT INTO api_list(route, name) VALUES(?, ?)", [ + item.route, + item.name, + ]); + } +} + +export async function loadApiListFromDb(): Promise { + const rows = await dbAll<{ route: string; name: string }>( + "SELECT route, name FROM api_list ORDER BY route ASC", + ); + return rows.map((row) => ({ route: row.route, name: row.name })); +} + +export async function insertApiItemToDb(item: ApiListItem): Promise { + await dbRun("INSERT INTO api_list(route, name) VALUES(?, ?)", [ + item.route, + item.name, + ]); +} + +export async function updateApiItemInDb( + oldRoute: string, + item: ApiListItem, +): Promise { + await dbRun("UPDATE api_list SET route = ?, name = ? WHERE route = ?", [ + item.route, + item.name, + oldRoute, + ]); +} + +export async function deleteApiItemFromDb(route: string): Promise { + await dbRun("DELETE FROM api_list WHERE route = ?", [route]); +} + +export async function upsertMockFilePathToDb( + filePath: string, + alias?: string, +): Promise { + if (typeof alias === "string") { + await dbRun( + "INSERT INTO mock_files(file_path, alias) VALUES(?, ?) ON CONFLICT(file_path) DO UPDATE SET alias = excluded.alias", + [filePath, alias], + ); + return; + } + await dbRun("INSERT OR IGNORE INTO mock_files(file_path, alias) VALUES(?, '')", [ + filePath, + ]); +} + +export async function removeMockFilePathFromDb(filePath: string): Promise { + await dbRun("DELETE FROM mock_files WHERE file_path = ?", [filePath]); +} + +export async function loadMockFilePathsFromDb(): Promise { + const rows = await dbAll( + "SELECT file_path, alias FROM mock_files ORDER BY file_path ASC", + ); + return rows.map((row) => ({ + file_path: row.file_path, + alias: String(row.alias || ""), + })); +} + +const DEFAULT_SERVER_CONFIG: AppConfig = { + cacheConfig: true, + reloadOnChange: true, + defaultContentType: "application/json", + proxyPort: 8879, + targetHost: "localhost", + targetPort: 80, + targetHttps: false, +}; + +export async function loadServerConfig(): Promise { + const rows = await dbAll<{ key: string; value: string }>( + "SELECT key, value FROM server_config", + ); + if (rows.length === 0) { + return { ...DEFAULT_SERVER_CONFIG }; + } + const map = new Map(rows.map((r) => [r.key, r.value])); + return { + mockEnabled: map.has("mockEnabled") + ? map.get("mockEnabled") === "true" + : undefined, + cacheConfig: map.get("cacheConfig") !== "false", + reloadOnChange: map.get("reloadOnChange") !== "false", + defaultContentType: + map.get("defaultContentType") || + DEFAULT_SERVER_CONFIG.defaultContentType, + proxyPort: Number(map.get("proxyPort")) || DEFAULT_SERVER_CONFIG.proxyPort, + targetHost: map.get("targetHost") || DEFAULT_SERVER_CONFIG.targetHost, + targetPort: Number(map.get("targetPort")) || DEFAULT_SERVER_CONFIG.targetPort, + targetHttps: map.get("targetHttps") === "true", + }; +} + +export async function saveServerConfig(config: AppConfig): Promise { + const entries: [string, string][] = [ + ["mockEnabled", String(config.mockEnabled !== false)], + ["cacheConfig", String(config.cacheConfig)], + ["reloadOnChange", String(config.reloadOnChange)], + ["defaultContentType", config.defaultContentType], + ["proxyPort", String(config.proxyPort)], + ["targetHost", config.targetHost], + ["targetPort", String(config.targetPort ?? "")], + ["targetHttps", String(config.targetHttps !== false)], + ]; + await dbRun("DELETE FROM server_config"); + for (const [key, value] of entries) { + await dbRun("INSERT INTO server_config(key, value) VALUES(?, ?)", [ + key, + value, + ]); + } +} diff --git a/src/mock-files.ts b/src/mock-files.ts new file mode 100644 index 0000000..361cecf --- /dev/null +++ b/src/mock-files.ts @@ -0,0 +1,95 @@ +import * as fs from "fs"; +import * as path from "path"; +import { MOCK_DIR } from "./constants"; +import { loadMockFilePathsFromDb, upsertMockFilePathToDb } from "./db"; +import type { MockFileItem } from "./types"; + +export function normalizeMockFilePath(filePath: string): string { + const trimmed = filePath.trim().replace(/\\/g, "/"); + if (!trimmed) { + throw new Error("filePath is required"); + } + const relative = trimmed.startsWith("mock/") ? trimmed : `mock/${trimmed}`; + if (path.isAbsolute(relative)) { + throw new Error("filePath must be a relative path"); + } + return relative; +} + +export function resolveMockFullPath(relativePath: string): string { + const normalized = normalizeMockFilePath(relativePath); + const fullPath = path.resolve(__dirname, "..", normalized); + const mockRoot = path.resolve(MOCK_DIR); + if (!fullPath.startsWith(mockRoot)) { + throw new Error("filePath is invalid"); + } + if (path.basename(fullPath) === "api-list.json") { + throw new Error("api-list.json is read-only in this panel"); + } + return fullPath; +} + +export function walkMockFiles( + dir: string, + baseDir: string, + result: string[], +): void { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + walkMockFiles(fullPath, baseDir, result); + continue; + } + if (!entry.isFile()) continue; + if (entry.name === "api-list.json") continue; + if (entry.name.endsWith(".sqlite3")) continue; + const relative = path.relative(baseDir, fullPath).replace(/\\/g, "/"); + result.push(`mock/${relative}`); + } +} + +export async function loadMockFiles(): Promise { + let files = await loadMockFilePathsFromDb(); + files = files.filter( + (item) => + item.file_path !== "mock/api-list.json" && + !item.file_path.endsWith(".sqlite3"), + ); + if (files.length === 0 && fs.existsSync(MOCK_DIR)) { + const fsFiles: string[] = []; + walkMockFiles(MOCK_DIR, MOCK_DIR, fsFiles); + fsFiles.sort((a, b) => a.localeCompare(b)); + for (const filePath of fsFiles) { + await upsertMockFilePathToDb(filePath); + } + files = fsFiles.map((filePath) => ({ file_path: filePath, alias: "" })); + } + + const result: MockFileItem[] = []; + for (const item of files) { + const filePath = item.file_path; + const fullPath = path.join(__dirname, "..", filePath); + if (!fs.existsSync(fullPath)) continue; + result.push({ + filePath, + alias: String(item.alias || ""), + content: fs.readFileSync(fullPath, "utf-8"), + }); + } + return result; +} + +export async function initMockFilesFromFsIfNeeded(): Promise { + const filesInDb = await loadMockFilePathsFromDb(); + if (filesInDb.length > 0 || !fs.existsSync(MOCK_DIR)) return; + if (!fs.existsSync(MOCK_DIR)) { + return; + } + const files: string[] = []; + walkMockFiles(MOCK_DIR, MOCK_DIR, files); + files.sort((a, b) => a.localeCompare(b)); + for (const filePath of files) { + await upsertMockFilePathToDb(filePath); + } +} diff --git a/src/proxy.ts b/src/proxy.ts new file mode 100644 index 0000000..90713dd --- /dev/null +++ b/src/proxy.ts @@ -0,0 +1,211 @@ +import * as fs from "fs"; +import * as http from "http"; +import * as https from "https"; +import * as path from "path"; +import { dispatchAdmin } from "./admin-handlers"; +import { + getMockFilePath, + getMockStatusCode, + isMockRoute, +} from "./route-matching"; +import { state } from "./state"; +import { decodeBodyByEncoding } from "./utils"; + +function isTargetHttps(): boolean { + return state.config.targetHttps !== false; +} + +function getTargetPort(): number { + if (state.config.targetPort != null) return state.config.targetPort; + return isTargetHttps() ? 443 : 80; +} + +function upstreamRequest( + options: http.RequestOptions, + callback: (proxyRes: http.IncomingMessage) => void, +): http.ClientRequest { + return isTargetHttps() + ? https.request(options, callback) + : http.request(options, callback); +} + +export function createProxyServer(): http.Server { + return http.createServer(async (clientReq, clientRes) => { + const parsedUrl = new URL(`http://localhost${clientReq.url!}`); + const requestPath = parsedUrl.pathname; + + // 管理接口:保留路径,不参与代理转发 + const handled = await dispatchAdmin(requestPath, clientReq, clientRes); + if (handled) return; + + // 检查是否为需要mock的路由 + if (isMockRoute(requestPath)) { + const mockFile = getMockFilePath(requestPath); + const mockStatusCode = getMockStatusCode(requestPath); + console.log(`[MOCK] 拦截路由: ${requestPath} -> 使用文件: ${mockFile}`); + + try { + const mockFilePath = path.join(__dirname, "..", mockFile); + + // 检查文件是否存在 + if (!fs.existsSync(mockFilePath)) { + console.warn( + `[MOCK] Mock文件不存在,回源并自动生成: ${mockFilePath}`, + ); + + const targetPort = getTargetPort(); + const options: http.RequestOptions = { + hostname: state.config.targetHost, + port: targetPort, + method: clientReq.method, + path: parsedUrl.pathname + parsedUrl.search, + headers: { + ...clientReq.headers, + host: state.config.targetHost, + }, + }; + + const proxyReq = upstreamRequest(options, (proxyRes) => { + const chunks: Buffer[] = []; + proxyRes.on("data", (chunk: Buffer) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + + proxyRes.on("end", () => { + const bodyBuffer = Buffer.concat(chunks); + + try { + const decodedBuffer = decodeBodyByEncoding( + bodyBuffer, + Array.isArray(proxyRes.headers["content-encoding"]) + ? proxyRes.headers["content-encoding"][0] + : proxyRes.headers["content-encoding"], + ); + const contentType = String( + proxyRes.headers["content-type"] || "", + ); + const toWrite = + contentType.includes("application/json") || + contentType.includes("text/") || + contentType.includes("application/xml") || + contentType.includes("application/javascript") + ? decodedBuffer.toString("utf-8") + : decodedBuffer; + fs.mkdirSync(path.dirname(mockFilePath), { recursive: true }); + fs.writeFileSync(mockFilePath, toWrite); + console.log(`[MOCK] 已自动写入mock文件: ${mockFilePath}`); + } catch (writeErr) { + console.error( + `[MOCK] 自动写入mock文件失败: ${mockFilePath}`, + writeErr, + ); + } + + clientRes.writeHead(mockStatusCode, { + ...proxyRes.headers, + "X-Mock-Autogenerated": "true", + "X-Mock-Source": mockFile, + "X-Mock-Status-Code": String(mockStatusCode), + }); + clientRes.end(bodyBuffer); + }); + }); + + proxyReq.on("error", (err) => { + console.error("Proxy request error:", err); + clientRes.writeHead(500, { "Content-Type": "application/json" }); + clientRes.end( + JSON.stringify({ + error: "Proxy error", + route: requestPath, + file: mockFile, + message: err.message, + timestamp: new Date().toISOString(), + }), + ); + }); + + clientReq.pipe(proxyReq); + return; + } + + // 读取本地mock文件 + const mockData = fs.readFileSync(mockFilePath, "utf-8"); + + // 设置响应头 + const contentType = + state.config.defaultContentType || "application/json"; + clientRes.writeHead(mockStatusCode, { + "Content-Type": contentType, + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + "X-Mock-Source": mockFile, + "X-Mock-Status-Code": String(mockStatusCode), + "X-Mock-Timestamp": new Date().toISOString(), + }); + + // 返回mock数据 + clientRes.end(mockData); + console.log(`[MOCK] 成功返回mock数据: ${mockFile}`); + } catch (error) { + console.error(`[MOCK] 读取mock文件失败: ${mockFile}`, error); + clientRes.writeHead(500, { "Content-Type": "application/json" }); + clientRes.end( + JSON.stringify({ + error: "Failed to read mock data", + route: requestPath, + file: mockFile, + message: (error as Error).message, + timestamp: new Date().toISOString(), + }), + ); + } + return; + } + + // 如果不是mock路由,正常代理转发 + console.log(`[PROXY] 转发请求: ${requestPath}`); + + const targetPort = getTargetPort(); + + const options: http.RequestOptions = { + hostname: state.config.targetHost, + port: targetPort, + method: clientReq.method, + path: parsedUrl.pathname + parsedUrl.search, + headers: { + ...clientReq.headers, + host: state.config.targetHost, + }, + }; + + const proxyReq = upstreamRequest(options, (proxyRes) => { + clientRes.writeHead(proxyRes.statusCode!, proxyRes.headers); + proxyRes.pipe(clientRes); + }); + + proxyReq.on("error", (err) => { + console.error("Proxy request error:", err); + clientRes.writeHead(500, { "Content-Type": "application/json" }); + clientRes.end( + JSON.stringify({ + error: "Proxy error", + message: err.message, + timestamp: new Date().toISOString(), + }), + ); + }); + + clientReq.pipe(proxyReq); + }); +} + +export function getEffectiveTargetPort(): number { + if (state.config.targetPort != null) return state.config.targetPort; + return isTargetHttps() ? 443 : 80; +} + +export function getEffectiveTargetHttps(): boolean { + return isTargetHttps(); +} diff --git a/src/route-matching.ts b/src/route-matching.ts new file mode 100644 index 0000000..4dc9182 --- /dev/null +++ b/src/route-matching.ts @@ -0,0 +1,29 @@ +import { state } from "./state"; +import { normalizeStatusCode } from "./utils"; + +// 检查是否为mock路由的函数 +export function isMockRoute(requestPath: string): boolean { + if (state.config.mockEnabled === false) return false; + if (!state.rawRoutes.hasOwnProperty(requestPath)) return false; + return state.routeEnabledMap[requestPath] !== false; +} + +// 获取mock文件路径 +export function getMockFilePath(requestPath: string): string { + return state.rawRoutes[requestPath]; +} + +export function getMockStatusCode(requestPath: string): number { + return normalizeStatusCode(state.rawRouteStatuses[requestPath], 200); +} + +// 获取仅启用的路由映射(用于展示) +export function getActiveRoutes(): Record { + const result: Record = {}; + for (const [route, filePath] of Object.entries(state.rawRoutes)) { + if (state.routeEnabledMap[route] !== false) { + result[route] = filePath; + } + } + return result; +} diff --git a/src/state.ts b/src/state.ts new file mode 100644 index 0000000..ca72799 --- /dev/null +++ b/src/state.ts @@ -0,0 +1,18 @@ +import type sqlite3 from "sqlite3"; +import type { AppConfig, RouteApiNameMap, RouteConfig, RouteEnabledMap, RouteStatusConfig } from "./types"; + +export const state = { + rawRoutes: {} as RouteConfig, + rawRouteStatuses: {} as RouteStatusConfig, + routeEnabledMap: {} as RouteEnabledMap, + routeApiNameMap: {} as RouteApiNameMap, + config: { + cacheConfig: true, + reloadOnChange: true, + defaultContentType: "application/json", + proxyPort: 443, + targetHost: "localhost", + targetPort: 443, + } as AppConfig, + db: null as unknown as sqlite3.Database, +}; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..45de4e2 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,59 @@ +export interface RouteConfig { + [route: string]: string; +} + +export interface RouteStatusConfig { + [route: string]: number; +} + +export interface AppConfig { + /** 为 false 时所有请求走代理,不命中 mock 路由;缺省为 true */ + mockEnabled?: boolean; + cacheConfig: boolean; + reloadOnChange: boolean; + defaultContentType: string; + proxyPort: number; + targetHost: string; + /** 代理转发的目标端口;缺省 HTTPS 为 443,HTTP 为 80 */ + targetPort?: number; + /** 为 false 时用 HTTP 连接上游;缺省 true(HTTPS) */ + targetHttps?: boolean; +} + +export interface ConfigFile { + routes: RouteConfig; + routeStatuses?: RouteStatusConfig; + config: AppConfig; +} + +export interface ApiListItem { + name: string; + route: string; +} + +export interface MockFileItem { + filePath: string; + alias: string; + content: string; +} + +export interface RouteRow { + route_key: string; + file_path: string; + status_code: number; + enabled: number; + api_name: string; +} + +export interface RouteEnabledMap { + [route: string]: boolean; +} + +export interface RouteApiNameMap { + [route: string]: string; +} + +export interface MockFileRow { + file_path: string; + alias: string; +} diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..8c4ff6b --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,43 @@ +import * as http from "http"; +import * as zlib from "zlib"; + +export function normalizeStatusCode(input: unknown, fallback = 200): number { + const numeric = + typeof input === "number" ? input : Number.parseInt(String(input ?? ""), 10); + if (Number.isInteger(numeric) && numeric >= 100 && numeric <= 599) { + return numeric; + } + return fallback; +} + +export function readBody(req: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8"))); + req.on("error", reject); + }); +} + +export function decodeBodyByEncoding( + bodyBuffer: Buffer, + contentEncoding?: string, +): Buffer { + const encoding = (contentEncoding || "").toLowerCase().trim(); + try { + if (encoding.includes("gzip")) { + return zlib.gunzipSync(bodyBuffer); + } + if (encoding.includes("br")) { + return zlib.brotliDecompressSync(bodyBuffer); + } + if (encoding.includes("deflate")) { + return zlib.inflateSync(bodyBuffer); + } + } catch (error) { + console.warn("[MOCK] 解压上游响应失败,按原始内容写入文件", error); + } + return bodyBuffer; +} diff --git a/tsconfig.json b/tsconfig.json index c8f86c8..56e23c2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,5 +9,5 @@ "skipLibCheck": true, "noEmit": true }, - "include": ["*.ts"] + "include": ["*.ts", "src/**/*.ts"] }