From 77060325efee1b438df9da8ef12a93fd977e02e0 Mon Sep 17 00:00:00 2001 From: chenxing Date: Tue, 6 Oct 2026 20:22:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(mock):=20mock=20=E6=BC=94=E8=BF=9B?= =?UTF-8?q?=E8=BD=AE=E2=80=94=E2=80=94=E5=BA=94=E7=94=A8=E5=88=97=E8=A1=A8?= =?UTF-8?q?/=E4=BB=A3=E7=90=86=E6=B5=8B=E8=AF=95=E9=A1=B5=20+=20=E7=BD=91?= =?UTF-8?q?=E5=85=B3=20mock=20=E6=95=B0=E6=8D=AE=E6=89=A9=E5=85=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - views/app/AppList.vue(新)+ views/proxytest/ProxyTest.vue(新):应用列表与代理测试演示页 - router/routes + apiDefinitions + App.vue/layout:新页路由与菜单接线 - assets/mock/gatewayApi.ts:mock 数据扩充(+136 行) - RouteList:列表页联动增强 - vite.config:mock 相关配置调整 - theme.ts:sync 刷新至 20261006-2013-75c1d10 并补回本仓唯一允许适配(顶部 // @ts-nocheck,strict vue-tsc 契约);typecheck 零错误 --- src/App.vue | 4 +- src/api/apiDefinitions.ts | 4 + src/assets/mock/gatewayApi.ts | 136 +++++++++++++++ src/components/layout.vue | 2 +- src/router/routes.ts | 12 ++ src/utils/theme.ts | 2 +- src/views/app/AppList.vue | 281 ++++++++++++++++++++++++++++++ src/views/proxytest/ProxyTest.vue | 256 +++++++++++++++++++++++++++ src/views/route/RouteList.vue | 14 +- vite.config.ts | 7 +- 10 files changed, 711 insertions(+), 7 deletions(-) create mode 100644 src/views/app/AppList.vue create mode 100644 src/views/proxytest/ProxyTest.vue diff --git a/src/App.vue b/src/App.vue index 1460a7c..adf7141 100644 --- a/src/App.vue +++ b/src/App.vue @@ -6,11 +6,13 @@ import { LayoutData } from "./types/Basics"; const isRouterAlive = ref(true), isLogin = ref(false); -/** 网关无菜单接口:三个治理页写死为静态菜单(与 notice/userbase 的 LeftMenu 同形) */ +/** 网关无菜单接口:治理页写死为静态菜单(与 notice/userbase 的 LeftMenu 同形) */ const modList = [ { name: "LedgerList", title: "访问台账", page: "/ledger" }, { name: "RouteList", title: "路由注册表", page: "/routes" }, { name: "TargetList", title: "目标地址", page: "/targets" }, + { name: "AppList", title: "应用管理", page: "/apps" }, + { name: "ProxyTest", title: "代理测试", page: "/proxy-test" }, ]; const layoutData: LayoutData = reactive({ diff --git a/src/api/apiDefinitions.ts b/src/api/apiDefinitions.ts index f4c8550..bbe937b 100644 --- a/src/api/apiDefinitions.ts +++ b/src/api/apiDefinitions.ts @@ -11,6 +11,10 @@ export default { 'GatewayAdmin.updateRoute': ['PUT', '/api/internal/admin/routes/{id}'], 'GatewayAdmin.targets': ['GET', '/api/internal/admin/targets'], 'GatewayAdmin.updateTarget': ['PUT', '/api/internal/admin/targets/{id}'], + 'GatewayAdmin.apps': ['GET', '/api/internal/admin/apps'], + 'GatewayAdmin.createApp': ['POST', '/api/internal/admin/apps'], + 'GatewayAdmin.updateApp': ['PUT', '/api/internal/admin/apps/{id}'], 'GatewayAdmin.logs': ['GET', '/api/internal/admin/logs'], 'GatewayAdmin.refresh': ['POST', '/api/internal/routes/refresh'], + 'GatewayAdmin.proxyTest': ['POST', '/api/internal/admin/proxy-test'], }; diff --git a/src/assets/mock/gatewayApi.ts b/src/assets/mock/gatewayApi.ts index fc3a656..bb351b4 100644 --- a/src/assets/mock/gatewayApi.ts +++ b/src/assets/mock/gatewayApi.ts @@ -15,6 +15,7 @@ * 由中间件调 resetGatewayMockState() 重置回种子;SPA 站内切换不重置。 */ import type { IncomingMessage, ServerResponse } from "node:http"; +import { createHmac } from "node:crypto"; /* ============================ 类型(对齐后端 OpenApi DTO) ============================ */ @@ -344,6 +345,132 @@ function updateTarget(id: number, body: any): { code: number; msg: string; data: return ok({ targets: targets.length }); } +/* ============================ 代理测试(服务端签名回环的真实语义 mock) ============================ */ + +const MOCK_PROXY_SECRET = "mock-secret-0123456789"; + +/** 复刻后端 SignVerifyFilter.sortRawQuery:逐对解码、k=v 字典序、& 连接 */ +function sortRawQuery(rawQuery: string | null): string { + if (!rawQuery) return ""; + const pairs: string[] = []; + for (const part of rawQuery.split("&")) { + if (!part) continue; + const idx = part.indexOf("="); + const k = idx < 0 ? part : part.substring(0, idx); + const v = idx < 0 ? "" : part.substring(idx + 1); + pairs.push(urlDecode(k) + "=" + urlDecode(v)); + } + pairs.sort(); + return pairs.join("&"); +} + +/** 复刻后端 URLDecoder.decode(UTF-8):+ 号解为空格;非法 %xx 转义回退原串 */ +function urlDecode(s: string): string { + try { + return decodeURIComponent(s.replace(/\+/g, "%20")); + } catch { + return s; + } +} + +/** + * 代理测试 mock:错误文案与后端 ProxyTestController 逐字一致,data 形状与真实响应同构。 + * 校验顺序照后端真实链路:url/appKey → 容忍解析 → 密钥解析(fail-closed)→ 签名 → 路由命中。 + */ +function mockProxyTest(body: any): { code: number; msg: string; data: any } { + const method = String(body?.method ?? "GET").trim().toUpperCase() || "GET"; + const url = typeof body?.url === "string" ? body.url.trim() : ""; + const appKey = typeof body?.appKey === "string" ? body.appKey.trim() : ""; + const appSecret = typeof body?.appSecret === "string" ? body.appSecret.trim() : ""; + if (!url) return bizError("url 不可为空(形如 http://网关域名/{target}/oapi/**)"); + if (!appKey) return bizError("appKey 不可为空"); + + // 容忍解析:剥 scheme,取 path+query(回环只打网关自身,scheme/host 无意义) + let pathAndQuery: string; + const schemeEnd = url.indexOf("://"); + if (schemeEnd >= 0) { + const pathStart = url.indexOf("/", schemeEnd + 3); + if (pathStart < 0) return bizError(`URL 缺少路径(形如 http://host/{target}/oapi/**):${url}`); + pathAndQuery = url.substring(pathStart); + } else if (url.startsWith("/")) { + pathAndQuery = url; + } else { + pathAndQuery = "/" + url; + } + const qIdx = pathAndQuery.indexOf("?"); + const path = qIdx < 0 ? pathAndQuery : pathAndQuery.substring(0, qIdx); + const query = qIdx < 0 ? null : pathAndQuery.substring(qIdx + 1); + if (!path || path === "/") { + return bizError("路径须形如 /{target}/oapi/**(如 /userbase/oapi/user/byId)"); + } + const second = path.indexOf("/", 1); + if (second < 0) { + return bizError(`路径第二段必须为 oapi(如 /userbase/oapi/user/byId),当前:${path}`); + } + const target = path.substring(1, second); + const rest = path.substring(second); + if (!target || target.includes("..")) { + return bizError(`target 非法:${target}`); + } + if (rest !== "/oapi" && !rest.startsWith("/oapi/")) { + return bizError(`路径第二段必须为 oapi(如 /userbase/oapi/user/byId),当前:${path}`); + } + const stripped = rest; + + // 密钥解析(回环前,fail-closed——顺序与后端一致) + let secret: string; + let secretSource: "INPUT" | "REDIS"; + if (appSecret) { + secret = appSecret; + secretSource = "INPUT"; + } else if ((APP_KEYS as readonly string[]).includes(appKey)) { + secret = MOCK_PROXY_SECRET; + secretSource = "REDIS"; + } else { + return bizError("appSecret 未填且网关解析不到该 appKey 的密钥(未注册/已禁用/密钥链不可用)"); + } + if (secret.length < 16) { + return bizError("签名失败:签名密钥长度不能小于16位"); + } + + // 签名(复刻 SignUtil.signRequest + buildCanonicalTarget:HMAC-SHA256 → base64) + const sorted = sortRawQuery(query); + const canonical = sorted ? `${stripped}?${sorted}` : stripped; + const timestamp = String(Date.now()); + const signature = createHmac("sha256", secret).update(`${method}\n${canonical}\n${timestamp}`).digest("base64"); + + // 路由命中:targetCode 相等 + pathPattern(剥 /** 后)等于 stripped 或为其路径前缀 + const hit = routes.find((r) => { + if (r.targetCode !== target) return false; + const prefix = r.pathPattern.replace(/\/\*\*$/, ""); + return stripped === prefix || stripped.startsWith(prefix + "/"); + }); + + const base = { + target, + method, + requestUrl: "http://mock-gateway" + pathAndQuery, + latencyMs: 20 + Math.floor(Math.random() * 281), + bodyTruncated: false, + signature: { appKey, timestamp, canonical, signature, secretSource }, + error: null, + }; + if (!hit) { + // 模拟网关裸 404 + return ok({ ...base, status: 404, contentType: null, body: "" }); + } + const echoBody = JSON.stringify({ + code: 0, + msg: "mock 应答", + data: { + mock: true, + route: { pathPattern: hit.pathPattern, visibility: hit.visibility, authMode: hit.authMode }, + echo: { method, url }, + }, + }); + return ok({ ...base, status: 200, contentType: "application/json", body: echoBody }); +} + /* ============================ 中间件入口(vite dev server 内) ============================ */ /** @@ -375,6 +502,15 @@ export async function handleGatewayMockRequest(req: IncomingMessage, res: Server send(res, ok({ loaded: routes.length, targets: targets.length })); return true; } + if (api === "/api/internal/admin/proxy-test" && method === "POST") { + const body = await readJsonBody(req); + if (body === undefined) { + send(res, bizError("请求体不是合法 JSON")); + return true; + } + send(res, mockProxyTest(body)); + return true; + } let m = /^\/api\/internal\/admin\/routes\/(\d+)$/.exec(api); if (m) { diff --git a/src/components/layout.vue b/src/components/layout.vue index c87a395..59fb02e 100644 --- a/src/components/layout.vue +++ b/src/components/layout.vue @@ -30,7 +30,7 @@ async function reloadRegistry() { reloading.value = true; try { const d: any = await Apis.GatewayAdmin.refresh({}); - Message.success(`已重载:路由 ${d?.loaded ?? "-"} 条 / 目标 ${d?.targets ?? "-"} 条`); + Message.success(`已重载:路由 ${d?.loaded ?? "-"} 条 / 目标 ${d?.targets ?? "-"} 条 / 应用 ${d?.apps ?? "-"} 条`); lastReloadTime.value = Utils.dateFormat(new Date(), "HH:mm:ss"); } catch (e: any) { // 错误提示由 alova 全局 onBizError 弹出,这里不重复弹 diff --git a/src/router/routes.ts b/src/router/routes.ts index bc46187..414d977 100644 --- a/src/router/routes.ts +++ b/src/router/routes.ts @@ -24,6 +24,18 @@ const routes: RouteRecordRaw[] = [ component: () => import("@/views/target/TargetList.vue"), meta: { title: "目标地址" }, }, + { + path: "/apps", + name: "AppList", + component: () => import("@/views/app/AppList.vue"), + meta: { title: "应用管理" }, + }, + { + path: "/proxy-test", + name: "ProxyTest", + component: () => import("@/views/proxytest/ProxyTest.vue"), + meta: { title: "代理测试" }, + }, ]; export default routes; diff --git a/src/utils/theme.ts b/src/utils/theme.ts index f3de612..fd805a9 100644 --- a/src/utils/theme.ts +++ b/src/utils/theme.ts @@ -3,7 +3,7 @@ 产物(readThemeOverrides 内部实现过不了 strict vue-tsc);Notice/UserBase/UserHome 均不跑 typecheck,本仓是首个严格检查端。等 YschTheme 上游补齐类型后移除本行。 */ /** - * 本文件由 YschTheme scripts/gen-theme.mjs 从 assets-src/pc/theme-core.js 生成(版本 20260930-1248-4e12e6d),勿手改。 + * 本文件由 YschTheme scripts/gen-theme.mjs 从 assets-src/pc/theme-core.js 生成(版本 20261006-2013-75c1d10),勿手改。 * 分发方式:YschTheme `npm run sync` 拷进各端 src/utils/theme.ts(与 ThemeSwitcher.vue 配套,MD5 对齐报告)。 * 运行时档位类名由 /static/js/yh-theme-init.js 在 内先行挂好,本模块只负责读写与切换。 */ diff --git a/src/views/app/AppList.vue b/src/views/app/AppList.vue new file mode 100644 index 0000000..ad861bd --- /dev/null +++ b/src/views/app/AppList.vue @@ -0,0 +1,281 @@ + + + + + diff --git a/src/views/proxytest/ProxyTest.vue b/src/views/proxytest/ProxyTest.vue new file mode 100644 index 0000000..66ac78e --- /dev/null +++ b/src/views/proxytest/ProxyTest.vue @@ -0,0 +1,256 @@ + + + + + diff --git a/src/views/route/RouteList.vue b/src/views/route/RouteList.vue index 38d9fe9..5a65c8b 100644 --- a/src/views/route/RouteList.vue +++ b/src/views/route/RouteList.vue @@ -2,7 +2,7 @@
- 路由注册表(open_api_route):治理旋钮行内编辑,保存后网关热重载,无须重启。 + 路由注册表(open_api_route):治理旋钮行内编辑,保存后网关热重载,无须重启。qps>0 时按 调用方×本行 限流(超限裸 429,0=不限);timeout/bodyLimit 立即生效。
@@ -31,6 +31,12 @@ + + @@ -72,8 +78,8 @@ const columns = [ { title: 'cacheTtl(s)', slot: 'cacheTtlSeconds', width: 105 }, { title: 'cacheScope', slot: 'cacheScope', width: 120 }, { title: 'qps', slot: 'rateLimitQps', width: 90 }, - { title: 'timeout(ms)', key: 'timeoutMs', width: 105, align: 'right' }, - { title: 'bodyLimit(mb)', key: 'requestBodyLimitMb', width: 115, align: 'right' }, + { title: 'timeout(ms)', slot: 'timeoutMs', width: 115, align: 'right' }, + { title: 'bodyLimit(mb)', slot: 'requestBodyLimitMb', width: 125, align: 'right' }, { title: '启用', slot: 'enabled', width: 80, align: 'center' }, { title: '备注', slot: 'remark', minWidth: 160 }, { title: '操作', slot: 'action', width: 90, align: 'center', fixed: 'right' as const }, @@ -95,6 +101,8 @@ async function save(row: any) { cacheTtlSeconds: Number(row.cacheTtlSeconds) || 0, cacheScope: row.cacheScope, rateLimitQps: Number(row.rateLimitQps) || 0, + timeoutMs: Number(row.timeoutMs) || 30000, + requestBodyLimitMb: Number(row.requestBodyLimitMb) || 10, enabled: row.enabled, remark: row.remark, }, diff --git a/vite.config.ts b/vite.config.ts index 018d677..ca4b2f6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -121,10 +121,15 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => { port: 8131, // 本地联调:/open-gateway/api/* → base 域(Caddy 剥前缀后转发到网关容器网内 10013) // 走代理而非跨源直连:网关无 CORS,且 10013 仅绑容器网内 127.0.0.1 + // 设 YH_GW_PROXY_TARGET(如 http://localhost:10013)= 不经 base 域直打本地网关, + // 此时剥掉 /open-gateway 前缀(原本由 Caddy 剥);不设则行为与线上联调零变化 proxy: { "/open-gateway/api": { - target: "http://base.yunhaixingtong.com", + target: process.env.YH_GW_PROXY_TARGET || "http://base.yunhaixingtong.com", changeOrigin: true, + ...(process.env.YH_GW_PROXY_TARGET + ? { rewrite: (p: string) => p.replace(/^\/open-gateway/, "") } + : {}), }, // 主题通道本地联调(照 Notice 配方):index.html loader 走同源相对 /static/js/yh-theme-init.js, // manifest/CSS 经此代理到中央通道;base 域不可达(无 VPN)时由 npm run dev:mock