From 7d6560703d3abb4145598de395e0cfff80409fa2 Mon Sep 17 00:00:00 2001 From: chenxing Date: Thu, 1 Oct 2026 23:10:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(mock):=20=E5=86=85=E7=BD=AE=20mock=20?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E2=80=94=E2=80=94=E4=B8=A5=E6=A0=BC=E5=A5=91?= =?UTF-8?q?=E7=BA=A6=E4=B8=AD=E9=97=B4=E4=BB=B6=20+=20=E7=A7=8D=E5=AD=90?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=EF=BC=88--mode=20mock=20=E4=B8=93=E7=94=A8?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.mock | 7 + src/assets/mock/gatewayApi.ts | 419 ++++++++++++++++++++++++++++++++++ vite.config.ts | 33 ++- 3 files changed, 458 insertions(+), 1 deletion(-) create mode 100644 .env.mock create mode 100644 src/assets/mock/gatewayApi.ts diff --git a/.env.mock b/.env.mock new file mode 100644 index 0000000..8d4c5bc --- /dev/null +++ b/.env.mock @@ -0,0 +1,7 @@ +# 本地纯前端演示模式:仅 vite --mode mock 加载(推荐入口 npm run dev:mock, +# 由 scripts/start-mock.mjs 一并拉起 9527 主题静态通道;直接 vite --mode mock 也可, +# 但无 VPN 时主题 CSS 取不到、只剩骨架)。build 产物不读任何 .env.*。 +# 后端/VPN 不可达时,vite dev server 内置中间件按后端契约拦截 /open-gateway/api/internal/** +# (见 vite.config.ts gatewayAdminMockPlugin + src/assets/mock/gatewayApi.ts)。 +VITE_SERVICE_HOST=/open-gateway +VITE_IS_MOCK=true diff --git a/src/assets/mock/gatewayApi.ts b/src/assets/mock/gatewayApi.ts new file mode 100644 index 0000000..fc3a656 --- /dev/null +++ b/src/assets/mock/gatewayApi.ts @@ -0,0 +1,419 @@ +/** + * 网关治理管理面本地 Mock(vite --mode mock 专用,由 vite.config.ts 的 configureServer + * 中间件在 dev server 内拦截 /open-gateway/api/internal/**,零依赖、零新插件)。 + * + * 背景:本机无内网 VPN 时 base 域不可达,三页治理台账全空。本文件按后端 + * InternalAdminController / RouteRefreshController 的真实响应契约提供内存数据, + * 仅供前端离线演示/走查,字段结构与信封与线上后端保持一致: + * - 统一信封 {code:0, msg:"", data:X};业务非法一律 HTTP 200 + {code:2, msg:"…"}; + * - 分页 data 形状 {total, content, pageIndex, pageSize, totalPages, hasNext}; + * - PUT routes 只认 7 个治理键;enabled 必须真布尔(字符串 "true" 一律按 false 落库, + * 照抄后端真实坑);两个 int 字段须 Number 且 ≥0; + * - 后端没有的接口不 mock(登录/CAS、caller CRUD、统计、路由/目标增删均不存在)。 + * + * 数据形态:静态种子 + 内存态——PUT 后再 GET 可见新值;整页刷新(HTML 文档请求) + * 由中间件调 resetGatewayMockState() 重置回种子;SPA 站内切换不重置。 + */ +import type { IncomingMessage, ServerResponse } from "node:http"; + +/* ============================ 类型(对齐后端 OpenApi DTO) ============================ */ + +type Visibility = "INTERNAL" | "EXTERNAL"; +type AuthMode = "SIGNED" | "ANON"; +type CacheScope = "CALLER" | "GLOBAL"; + +export interface OpenApiRoute { + id: number; + targetCode: string; + pathPattern: string; + httpMethod: string; + visibility: Visibility; + authMode: AuthMode; + cacheTtlSeconds: number; + cacheScope: CacheScope; + rateLimitQps: number; + timeoutMs: number; + requestBodyLimitMb: number; + enabled: boolean; + remark: string; + gmtCreate: string; + gmtModified: string; +} + +export interface OpenApiTarget { + id: number; + targetCode: string; + baseUrl: string; + enabled: boolean; + gmtCreate: string; + gmtModified: string; +} + +export interface OpenApiLog { + id: number; + ts: string; + callerAppKey: string | null; + targetCode: string; + method: string; + path: string; + status: number; + backendCode: null; + costMs: number; +} + +interface PageResult { + total: number; + content: T[]; + pageIndex: number; + pageSize: number; + totalPages: number; + hasNext: boolean; +} + +/* ============================ 静态种子 ============================ */ + +const GMT_BASE = "2026-09-12T02:00:00.000Z"; +const GMT_TUNE = "2026-09-26T06:30:00.000Z"; + +/** 7 个生产 ctx(照 service/docs/sql/03_target.sql 语义:容器网内直连 base-url) */ +const SEED_TARGETS: OpenApiTarget[] = [ + { id: 1, targetCode: "userbase", baseUrl: "http://userbase-service:8080", enabled: true, gmtCreate: GMT_BASE, gmtModified: GMT_BASE }, + { id: 2, targetCode: "userclass", baseUrl: "http://userclass-service:8080", enabled: true, gmtCreate: GMT_BASE, gmtModified: GMT_BASE }, + { id: 3, targetCode: "yschcms", baseUrl: "http://yschcms-service:8080", enabled: true, gmtCreate: GMT_BASE, gmtModified: GMT_BASE }, + { id: 4, targetCode: "svc-hall", baseUrl: "http://svc-hall-service:8080", enabled: true, gmtCreate: GMT_BASE, gmtModified: GMT_BASE }, + { id: 5, targetCode: "flow-center", baseUrl: "http://flow-center-service:8080", enabled: true, gmtCreate: GMT_BASE, gmtModified: GMT_BASE }, + { id: 6, targetCode: "usermsg", baseUrl: "http://usermsg-service:8080", enabled: true, gmtCreate: GMT_BASE, gmtModified: GMT_BASE }, + { id: 7, targetCode: "notice", baseUrl: "http://notice-service:8080", enabled: true, gmtCreate: GMT_BASE, gmtModified: GMT_BASE }, +]; + +/** + * 路由种子:service/docs/sql/02_init_routes.sql 的 22 行全保守种子(INTERNAL×SIGNED)打底, + * 另补 6 行治理后的样本:EXTERNAL×ANON 豁免 ×3、EXTERNAL×SIGNED ×1、INTERNAL×ANON ×1、 + * 停用行 ×1、空备注 ×1,覆盖全部枚举组合与差异化 cacheTtl/qps/remark。 + */ +const SEED_ROUTES: OpenApiRoute[] = [ + // ---- 02_init_routes.sql 种子(22 行,remark 原样照抄)---- + { id: 1, targetCode: "userbase", pathPattern: "/oapi/app/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "应用信息:byId/list", gmtCreate: GMT_BASE, gmtModified: GMT_BASE }, + { id: 2, targetCode: "userbase", pathPattern: "/oapi/group/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "组织:tree/byId/list/users/add/edit/delete/batchAdd/move", gmtCreate: GMT_BASE, gmtModified: GMT_BASE }, + { id: 3, targetCode: "userbase", pathPattern: "/oapi/identity/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "身份:current/available/switch", gmtCreate: GMT_BASE, gmtModified: GMT_BASE }, + { id: 4, targetCode: "userbase", pathPattern: "/oapi/user/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "用户:byId/byLoginName/byPhone/modules/add/edit/delete/batchAdd 等 21 端点", gmtCreate: GMT_BASE, gmtModified: GMT_BASE }, + { id: 5, targetCode: "userclass", pathPattern: "/oapi/DbClass/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "OA 兼容:班级", gmtCreate: GMT_BASE, gmtModified: GMT_BASE }, + { id: 6, targetCode: "userclass", pathPattern: "/oapi/Common/FamCom/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "OA 兼容:家属", gmtCreate: GMT_BASE, gmtModified: GMT_BASE }, + { id: 7, targetCode: "userclass", pathPattern: "/oapi/SchGroup/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "OA 兼容:学校组织", gmtCreate: GMT_BASE, gmtModified: GMT_BASE }, + { id: 8, targetCode: "userclass", pathPattern: "/oapi/SchInfo/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "OA 兼容:学校信息", gmtCreate: GMT_BASE, gmtModified: GMT_BASE }, + { id: 9, targetCode: "userclass", pathPattern: "/oapi/SubInfo/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "OA 兼容:学科", gmtCreate: GMT_BASE, gmtModified: GMT_BASE }, + { id: 10, targetCode: "userclass", pathPattern: "/oapi/Schoolen/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "OA 兼容:学段", gmtCreate: GMT_BASE, gmtModified: GMT_BASE }, + { id: 11, targetCode: "userclass", pathPattern: "/oapi/OAStu/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "OA 兼容:学生信息(21 端点)", gmtCreate: GMT_BASE, gmtModified: GMT_BASE }, + { id: 12, targetCode: "userclass", pathPattern: "/oapi/TeachInfo/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "OA 兼容:任教信息", gmtCreate: GMT_BASE, gmtModified: GMT_BASE }, + { id: 13, targetCode: "userclass", pathPattern: "/oapi/OATea/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "OA 兼容:教师信息", gmtCreate: GMT_BASE, gmtModified: GMT_BASE }, + { id: 14, targetCode: "userclass", pathPattern: "/oapi/UserInfo/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "OA 兼容:用户信息", gmtCreate: GMT_BASE, gmtModified: GMT_BASE }, + { id: 15, targetCode: "userclass", pathPattern: "/oapi/VSchClass/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "OA 兼容:虚拟班级(17 端点)", gmtCreate: GMT_BASE, gmtModified: GMT_BASE }, + { id: 16, targetCode: "userclass", pathPattern: "/oapi/group/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "同步:组织(4 端点)", gmtCreate: GMT_BASE, gmtModified: GMT_BASE }, + { id: 17, targetCode: "userclass", pathPattern: "/oapi/user/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "同步:用户(5 端点)", gmtCreate: GMT_BASE, gmtModified: GMT_BASE }, + { id: 18, targetCode: "yschcms", pathPattern: "/oapi/site/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "门户(注册名 yschcms)", gmtCreate: GMT_BASE, gmtModified: GMT_BASE }, + { id: 19, targetCode: "svc-hall", pathPattern: "/oapi/svc/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "事项:items/filters/detail/view/attachments/related/snapshot", gmtCreate: GMT_BASE, gmtModified: GMT_BASE }, + { id: 20, targetCode: "flow-center", pathPattern: "/oapi/flow/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "流程:list/getFlow/apply/instance/progress/diagram/withdraw/callback-reg", gmtCreate: GMT_BASE, gmtModified: GMT_BASE }, + { id: 21, targetCode: "usermsg", pathPattern: "/oapi/msg/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "消息:sendUserMsg/list/unreadCount/read/readAll + todo(inc/reset/list) + ws/ticket", gmtCreate: GMT_BASE, gmtModified: GMT_BASE }, + { id: 22, targetCode: "notice", pathPattern: "/oapi/notice/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "字典:getDict", gmtCreate: GMT_BASE, gmtModified: GMT_BASE }, + // ---- 治理后补充样本(豁免/差异化旋钮/停用/空备注)---- + { id: 23, targetCode: "yschcms", pathPattern: "/oapi/site/render/**", httpMethod: "*", visibility: "EXTERNAL", authMode: "ANON", cacheTtlSeconds: 600, cacheScope: "GLOBAL", rateLimitQps: 50, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "门户站点渲染对外只读:豁免签名 + 全局缓存 10 分钟", gmtCreate: GMT_BASE, gmtModified: GMT_TUNE }, + { id: 24, targetCode: "svc-hall", pathPattern: "/oapi/svc/items/**", httpMethod: "*", visibility: "EXTERNAL", authMode: "ANON", cacheTtlSeconds: 60, cacheScope: "CALLER", rateLimitQps: 20, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "办事大厅事项对外查询:豁免签名,按调用方隔离缓存", gmtCreate: GMT_BASE, gmtModified: GMT_TUNE }, + { id: 25, targetCode: "notice", pathPattern: "/oapi/notice/public/**", httpMethod: "*", visibility: "EXTERNAL", authMode: "ANON", cacheTtlSeconds: 300, cacheScope: "GLOBAL", rateLimitQps: 100, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "通知公告对外只读镜像(旧门户拉取)", gmtCreate: GMT_BASE, gmtModified: GMT_TUNE }, + { id: 26, targetCode: "userbase", pathPattern: "/oapi/user/batchAdd", httpMethod: "*", visibility: "EXTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 5, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "批量建号对外(密钥强校验,低 qps 熔断)", gmtCreate: GMT_BASE, gmtModified: GMT_TUNE }, + { id: 27, targetCode: "usermsg", pathPattern: "/oapi/msg/health", httpMethod: "*", visibility: "INTERNAL", authMode: "ANON", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 10000, requestBodyLimitMb: 1, enabled: true, remark: "", gmtCreate: GMT_BASE, gmtModified: GMT_TUNE }, + { id: 28, targetCode: "flow-center", pathPattern: "/oapi/flow/diagram/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 60000, requestBodyLimitMb: 10, enabled: false, remark: "流程图渲染内测:暂关", gmtCreate: GMT_BASE, gmtModified: GMT_TUNE }, +]; + +/** 实测调用方 appKey(用于台账 callerAppKey 样本与筛选演示) */ +const APP_KEYS = ["100001", "100007", "100009", "100010", "100013"] as const; + +/** 台账时间跨度:以种子重置时刻为终点,向过去铺 6.5 天 */ +const LOG_SPAN_DAYS = 6.5; +const LOG_COUNT = 233; + +/** 确定性伪随机(LCG):同一次进程内种子稳定,刷新/重启后日志可复现同分布 */ +function lcg(seed: number): () => number { + let s = seed >>> 0; + return () => { + s = (Math.imul(s, 1664525) + 1013904223) >>> 0; + return s / 4294967296; + }; +} + +/** 台账种子:按路由表推导 path 形态;覆盖 200/401/403/404/500、anonymous、无钥(null) 401、快慢耗时 */ +function buildSeedLogs(routes: OpenApiRoute[], now: number): OpenApiLog[] { + const rnd = lcg(20261001); + const byTarget = new Map(); + for (const r of routes) { + const arr = byTarget.get(r.targetCode) ?? []; + arr.push({ path: r.pathPattern, anon: r.authMode === "ANON" }); + byTarget.set(r.targetCode, arr); + } + const targetCodes = [...byTarget.keys()]; + const weight: Record = { + userbase: 0.22, userclass: 0.14, yschcms: 0.06, "svc-hall": 0.14, + "flow-center": 0.12, usermsg: 0.14, notice: 0.18, + }; + const pick = (arr: readonly T[]): T => arr[Math.floor(rnd() * arr.length)]; + const pickTarget = (): string => { + let r = rnd(); + for (const t of targetCodes) { + r -= weight[t] ?? 0; + if (r < 0) return t; + } + return targetCodes[targetCodes.length - 1]; + }; + + const logs: OpenApiLog[] = []; + for (let i = 0; i < LOG_COUNT; i++) { + const ts = now - i * ((LOG_SPAN_DAYS * 24 * 3600 * 1000) / LOG_COUNT) - rnd() * 3600 * 1000; + const targetCode = pickTarget(); + const rt = pick(byTarget.get(targetCode) as { path: string; anon: boolean }[]); + let path = rt.path; + if (rnd() < 0.45 && path.endsWith("/**")) { + path = path.slice(0, -3) + "/" + String(Math.floor(rnd() * 900) + 100); + } + const method = rnd() < 0.7 ? "GET" : "POST"; + + let callerAppKey: string | null; + let status: number; + const cr = rnd(); + if (cr < 0.6) { + // 持钥调用:多数放行,少量鉴权/限流/NotFound/后端炸 + callerAppKey = pick(APP_KEYS); + const sr = rnd(); + status = sr < 0.78 ? 200 : sr < 0.84 ? 401 : sr < 0.9 ? 403 : sr < 0.95 ? 404 : 500; + } else if (cr < 0.8) { + // 匿名调用(后端记字符串 "anonymous"):命中豁免(ANON)路由才放行 + callerAppKey = "anonymous"; + const sr = rnd(); + status = rt.anon ? (sr < 0.85 ? 200 : 500) : sr < 0.7 ? 401 : 403; + } else { + // 未携带 appKey:网关层直接拒(callerAppKey=null 的 401 行) + callerAppKey = null; + status = rnd() < 0.85 ? 401 : 403; + } + const costMs = status < 400 ? Math.floor(3 + Math.pow(rnd(), 2) * 1600) : Math.floor(1 + rnd() * 25); + logs.push({ + id: 50000 + (LOG_COUNT - i), + ts: new Date(ts).toISOString(), + callerAppKey, + targetCode, + method, + path, + status, + backendCode: null, + costMs, + }); + } + return logs; +} + +/* ============================ 内存态(PUT 可变,刷新重置) ============================ */ + +let routes: OpenApiRoute[] = []; +let targets: OpenApiTarget[] = []; +let logs: OpenApiLog[] = []; + +/** 重置为静态种子(vite 中间件在整页文档请求时调用 → 浏览器刷新即还原) */ +export function resetGatewayMockState(): void { + routes = SEED_ROUTES.map((r) => ({ ...r })); + targets = SEED_TARGETS.map((t) => ({ ...t })); + logs = buildSeedLogs(SEED_ROUTES, Date.now()); +} +resetGatewayMockState(); + +/* ============================ 工具 ============================ */ + +function ok(data: T): { code: number; msg: string; data: T } { + return { code: 0, msg: "", data }; +} + +function bizError(msg: string): { code: number; msg: string; data: null } { + return { code: 2, msg, data: null }; +} + +function paginate(list: T[], pageIndex: number, pageSize: number): PageResult { + const total = list.length; + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + return { + total, + content: list.slice((pageIndex - 1) * pageSize, pageIndex * pageSize), + pageIndex, + pageSize, + totalPages, + hasNext: pageIndex < totalPages, + }; +} + +/** 查询参数取正整数:缺省/非法回落 def,上限 cap(logs pageSize 契约:默认 50、上限 500) */ +function positiveInt(raw: string | null, def: number, cap: number): number { + const n = Number(raw); + if (!Number.isInteger(n) || n < 1) return def; + return Math.min(n, cap); +} + +function enumErrMsg(field: string, val: unknown, legal: readonly string[]): string { + return `${field} 非法:${val === undefined ? "(缺失)" : String(val)}(合法 [${legal.join(", ")}])`; +} + +function intErrMsg(field: string, val: unknown): string { + return `${field} 非法:${val === undefined ? "(缺失)" : String(val)}(须为 ≥0 的整数)`; +} + +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on("data", (c: Buffer) => chunks.push(c)); + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + req.on("error", reject); + }); +} + +function send(res: ServerResponse, payload: unknown): void { + res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }); + res.end(JSON.stringify(payload)); +} + +/* ============================ 各端点实现 ============================ */ + +function listRoutes(q: URLSearchParams): PageResult { + const sorted = [...routes].sort( + (a, b) => a.targetCode.localeCompare(b.targetCode) || a.pathPattern.localeCompare(b.pathPattern) + ); + return paginate(sorted, positiveInt(q.get("pageIndex"), 1, 10000), positiveInt(q.get("pageSize"), 10, 500)); +} + +function listTargets(q: URLSearchParams): PageResult { + const sorted = [...targets].sort((a, b) => a.targetCode.localeCompare(b.targetCode)); + return paginate(sorted, positiveInt(q.get("pageIndex"), 1, 10000), positiveInt(q.get("pageSize"), 10, 500)); +} + +function listLogs(q: URLSearchParams): PageResult { + const targetCode = (q.get("targetCode") ?? "").trim(); + const callerAppKey = (q.get("callerAppKey") ?? "").trim(); + // 筛选为精确等值(空串=不过滤),先过滤再分页,按 id 倒序(最新在前) + let rows = logs; + if (targetCode) rows = rows.filter((l) => l.targetCode === targetCode); + if (callerAppKey) rows = rows.filter((l) => l.callerAppKey === callerAppKey); + rows = [...rows].sort((a, b) => b.id - a.id); + return paginate(rows, positiveInt(q.get("pageIndex"), 1, 100000), positiveInt(q.get("pageSize"), 50, 500)); +} + +const VISIBILITIES = ["EXTERNAL", "INTERNAL"] as const; +const AUTH_MODES = ["SIGNED", "ANON"] as const; +const CACHE_SCOPES = ["CALLER", "GLOBAL"] as const; + +/** PUT /routes/{id}:只认 7 个治理键;校验语义对齐后端(含 enabled 非真布尔按 false 落库的坑) */ +function updateRoute(id: number, body: any): { code: number; msg: string; data: any } { + const row = routes.find((r) => r.id === id); + if (!row) return bizError(`路由不存在:id=${id}`); + + if (!VISIBILITIES.includes(body?.visibility)) return bizError(enumErrMsg("visibility", body?.visibility, VISIBILITIES)); + if (!AUTH_MODES.includes(body?.authMode)) return bizError(enumErrMsg("authMode", body?.authMode, AUTH_MODES)); + if (!CACHE_SCOPES.includes(body?.cacheScope)) return bizError(enumErrMsg("cacheScope", body?.cacheScope, CACHE_SCOPES)); + const ttl = body?.cacheTtlSeconds; + if (typeof ttl !== "number" || !Number.isInteger(ttl) || ttl < 0) return bizError(intErrMsg("cacheTtlSeconds", ttl)); + const qps = body?.rateLimitQps; + if (typeof qps !== "number" || !Number.isInteger(qps) || qps < 0) return bizError(intErrMsg("rateLimitQps", qps)); + + row.visibility = body.visibility; + row.authMode = body.authMode; + row.cacheScope = body.cacheScope; + row.cacheTtlSeconds = ttl; + row.rateLimitQps = qps; + // enabled 必须真布尔:传字符串 "true" 一律按 false 处理(后端真实行为,勿修) + row.enabled = body.enabled === true; + row.remark = body.remark == null ? "" : String(body.remark); + row.gmtModified = new Date().toISOString(); + return ok({ loaded: routes.length }); +} + +/** PUT /targets/{id}:baseUrl 非空 + enabled 真布尔(同款坑) */ +function updateTarget(id: number, body: any): { code: number; msg: string; data: any } { + const row = targets.find((t) => t.id === id); + if (!row) return bizError(`目标不存在:id=${id}`); + + const baseUrl = body?.baseUrl; + if (typeof baseUrl !== "string" || baseUrl.trim() === "") { + return bizError(`baseUrl 非法:${baseUrl === undefined ? "(缺失)" : String(baseUrl)}(须为非空 url)`); + } + row.baseUrl = baseUrl.trim(); + row.enabled = body.enabled === true; + row.gmtModified = new Date().toISOString(); + return ok({ targets: targets.length }); +} + +/* ============================ 中间件入口(vite dev server 内) ============================ */ + +/** + * 拦截带 serviceHost 前缀(/open-gateway)的治理面请求。命中并应答返回 true, + * 未命中返回 false(交回 vite 内部中间件/代理,非 mock 模式根本不会注册本处理)。 + */ +export async function handleGatewayMockRequest(req: IncomingMessage, res: ServerResponse): Promise { + const url = new URL(req.url ?? "/", "http://mock.local"); + const pathname = url.pathname; + if (!pathname.startsWith("/open-gateway/api/")) return false; + + const api = pathname.slice("/open-gateway".length); + const method = (req.method ?? "GET").toUpperCase(); + const q = url.searchParams; + + if (api === "/api/internal/admin/routes" && method === "GET") { + send(res, ok(listRoutes(q))); + return true; + } + if (api === "/api/internal/admin/targets" && method === "GET") { + send(res, ok(listTargets(q))); + return true; + } + if (api === "/api/internal/admin/logs" && method === "GET") { + send(res, ok(listLogs(q))); + return true; + } + if (api === "/api/internal/routes/refresh" && method === "POST") { + send(res, ok({ loaded: routes.length, targets: targets.length })); + return true; + } + + let m = /^\/api\/internal\/admin\/routes\/(\d+)$/.exec(api); + if (m) { + if (method !== "PUT") { + send(res, bizError(`不支持的请求:${method} ${api}`)); + return true; + } + const body = await readJsonBody(req); + if (body === undefined) { + send(res, bizError("请求体不是合法 JSON")); + return true; + } + send(res, updateRoute(Number(m[1]), body)); + return true; + } + m = /^\/api\/internal\/admin\/targets\/(\d+)$/.exec(api); + if (m) { + if (method !== "PUT") { + send(res, bizError(`不支持的请求:${method} ${api}`)); + return true; + } + const body = await readJsonBody(req); + if (body === undefined) { + send(res, bizError("请求体不是合法 JSON")); + return true; + } + send(res, updateTarget(Number(m[1]), body)); + return true; + } + return false; +} + +/** 读 PUT 的 JSON 请求体;空体按 {},解析失败返回 undefined(由调用方回业务错) */ +async function readJsonBody(req: IncomingMessage): Promise { + const raw = await readBody(req); + if (!raw.trim()) return {}; + try { + return JSON.parse(raw); + } catch { + return undefined; + } +} diff --git a/vite.config.ts b/vite.config.ts index 4463b07..018d677 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,4 +1,4 @@ -import { ConfigEnv, UserConfig, defineConfig } from "vite"; +import { ConfigEnv, UserConfig, defineConfig, type Plugin } from "vite"; import { createHtmlPlugin } from "vite-plugin-html"; import vue from "@vitejs/plugin-vue"; import path, { resolve } from "path"; @@ -9,6 +9,35 @@ import UnoCSS from "@unocss/vite"; import presetUno from "@unocss/preset-uno"; import presetRemToPx from "@unocss/preset-rem-to-px"; +// mock 数据层(仅 --mode mock 注册,见下方 gatewayAdminMockPlugin;零依赖手写中间件而非 +// vite-plugin-mock:后者独立打包 mock 文件为第二模块实例,无法实现"整页刷新重置内存态") +import { handleGatewayMockRequest, resetGatewayMockState } from "./src/assets/mock/gatewayApi"; + +/** 内置 mock(仅 serve + --mode mock):拦截 /open-gateway/api/internal/** 按后端契约应答。 + * 直接注册在 configureServer(先于 vite 内部中间件,含 /open-gateway/api 代理); + * 整页文档请求(刷新/直达)触发内存态重置回静态种子,SPA 站内切换不重置。 */ +function gatewayAdminMockPlugin(): Plugin { + return { + name: "gateway-admin-mock", + apply: "serve", + configureServer(server) { + server.middlewares.use((req, _res, next) => { + const accept = String(req.headers.accept ?? ""); + if (accept.includes("text/html")) resetGatewayMockState(); + next(); + }); + server.middlewares.use(async (req, res, next) => { + try { + if (!(await handleGatewayMockRequest(req, res))) next(); + } catch (e) { + next(e); + } + }); + }, + }; +} + + // 构建输出恒为 dist 根:CI 打包 zip 的根目录必须直接是 index.html(projman 前端发布校验 zip 根含 html)。 // 不再有 dist/base 中间层——outDir=dist/base 时 zip 打 dist 整层,zip 根只有 base/,发布会校验失败。 // dev(--mode dev)读 .env.dev,VITE_SERVICE_HOST 指向同源前缀 /open-gateway(经下方 proxy 打到 base 域) @@ -17,6 +46,8 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => { return { plugins: [ + // 仅 --mode mock 启用内置 mock;dev/联调/正式 build 行为零变化 + ...(mode === "mock" ? [gatewayAdminMockPlugin()] : []), vue(), createSvgIconsPlugin({ iconDirs: [resolve(process.cwd(), "src/assets/icons")],