feat(mock): 内置 mock 模式——严格契约中间件 + 种子数据(--mode mock 专用)
This commit is contained in:
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+32
-1
@@ -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")],
|
||||
|
||||
Reference in New Issue
Block a user