feat(mock): 内置 mock 模式——严格契约中间件 + 种子数据(--mode mock 专用)

This commit is contained in:
chenxing
2026-10-01 23:10:08 +08:00
parent 8c8147cd5b
commit 7d6560703d
3 changed files with 458 additions and 1 deletions
+419
View File
@@ -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<T> {
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<string, { path: string; anon: boolean }[]>();
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<string, number> = {
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 = <T,>(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<T>(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<T>(list: T[], pageIndex: number, pageSize: number): PageResult<T> {
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<string> {
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<OpenApiRoute> {
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<OpenApiTarget> {
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<OpenApiLog> {
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<boolean> {
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<any | undefined> {
const raw = await readBody(req);
if (!raw.trim()) return {};
try {
return JSON.parse(raw);
} catch {
return undefined;
}
}