本地 Mock 服务 /mock + 限流 20QPS/60QPM + 服务条款
- Go Mock 引擎(与前端同语法 @cname/@integer/... 每次请求随机生成,JSON 叶子保持类型) - mock_endpoints 表 + 发布 API(PUT /api/workspaces/:id/mock,全量替换,单空间 200 个) - 路由 /mock/:wsid/*path:状态码/延迟(≤5s)/?_method 覆盖;未发布 404 - 限流(IP+空间 滑动窗口):20 QPS / 60 QPM → 429 + Retry-After + 条款提示 - 官网 /terms 服务条款(七节:含禁止滥用——未经授权或滥用有权采取措施、技术手段规避限流属违规) - UI:HTTP 编辑器「Mock 链接」弹窗(发布/状态码/延迟/模板预览/复制/试一试); 发布自动烘焙智能 Mock 表达式;集合端点去重 + INSERT OR REPLACE - docs:14-mock 增本地 Mock 服务与限流章节;页脚/429 响应均指向 /terms - 单测:mocksrv 引擎/限流/路径 5 用例;curl E2E(随机×2、201+延迟、404、连发 30 次触发 429);浏览器发布+试一试通过
This commit is contained in:
1 parent
522e5d6300
commit
d9ccccd280
17 files changed
+1070
-6
No files matched your search
@@ -21,6 +21,7 @@ import { WebsocketEditor } from "./components/editors/WebsocketEditor";
|
||||
import { ExportDialog } from "./components/dialogs/ExportDialog";
|
||||
import { ImportDialog } from "./components/dialogs/ImportDialog";
|
||||
import { AuthDialog, TeamDialog } from "./components/dialogs/AccountDialogs";
|
||||
import { MockLinkDialog } from "./components/dialogs/MockLinkDialog";
|
||||
import {
|
||||
EnvironmentsDialog,
|
||||
NewItemDialog,
|
||||
@@ -94,6 +95,13 @@ export function OpsxWorkspace() {
|
||||
{dialog?.type === "environments" && <EnvironmentsDialog />}
|
||||
{dialog?.type === "settings" && <SettingsDialog tab={dialog.tab} />}
|
||||
{dialog?.type === "rename" && <RenameDialog id={dialog.id} />}
|
||||
{dialog?.type === "mocklink" &&
|
||||
(() => {
|
||||
const ref = findNode(collections, dialog.nodeId);
|
||||
return ref?.node.type === "request" && ref.node.kind === "http" ? (
|
||||
<MockLinkDialog node={ref.node} />
|
||||
) : null;
|
||||
})()}
|
||||
{dialog?.type === "auth" && <AuthDialog />}
|
||||
{dialog?.type === "team" && <TeamDialog />}
|
||||
</div>
|
||||
|
||||
@@ -66,6 +66,22 @@ export function fetchWorkspace(token: string, id: number) {
|
||||
);
|
||||
}
|
||||
|
||||
export type MockEndpointPayload = {
|
||||
method: string;
|
||||
path: string;
|
||||
status: number;
|
||||
contentType: string;
|
||||
body: string;
|
||||
delayMs: number;
|
||||
};
|
||||
|
||||
export function publishMock(token: string, workspaceId: number, endpoints: MockEndpointPayload[]) {
|
||||
return call<{ ok: boolean; count: number; base: string }>(`/workspaces/${workspaceId}/mock`, token, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ endpoints }),
|
||||
});
|
||||
}
|
||||
|
||||
export function saveWorkspace(token: string, id: number, data: string) {
|
||||
return call<{ updatedAt: number }>(`/workspaces/${id}`, token, {
|
||||
method: "PUT",
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Mock 链接弹窗:把当前接口发布到本地 Mock 服务(/mock/{空间ID}{路径}),
|
||||
* 发布后可复制 URL、浏览器试一试;响应体支持 @表达式按次随机生成。
|
||||
*/
|
||||
import { smartFillJson } from "@opsxapi/core";
|
||||
import type { HttpApiNode, MockSettings } from "@opsxapi/core";
|
||||
import { useState } from "react";
|
||||
import { publishMock } from "../../account";
|
||||
import { useWorkspace } from "../../workspaceStore";
|
||||
import { Modal, cx } from "../common";
|
||||
import { Icon } from "../icons";
|
||||
|
||||
export function MockLinkDialog({ node }: { node: HttpApiNode }) {
|
||||
const account = useWorkspace((s) => s.account);
|
||||
const settings = useWorkspace((s) => s.settings);
|
||||
const collections = useWorkspace((s) => s.collections);
|
||||
const setDialog = useWorkspace((s) => s.setDialog);
|
||||
|
||||
const space = account.spaces.find((sp) => sp.id === account.activeSpaceId) ?? null;
|
||||
const [status, setStatus] = useState(200);
|
||||
const [delayMs, setDelayMs] = useState(0);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null);
|
||||
|
||||
// 路径:取请求 URL 的 path 部分(前置 URL 模式下即相对路径)
|
||||
const urlPath = extractPath(node.data.url);
|
||||
const method = node.data.method;
|
||||
|
||||
// 模板:JSON Body(智能填充烘焙 @表达式);非 JSON 用空对象
|
||||
let template = "{}";
|
||||
if (node.data.bodyMode === "json" && node.data.bodyRaw.trim()) {
|
||||
template = node.data.bodyRaw;
|
||||
if (settings.mock.enabled && settings.mock.smart) {
|
||||
const filled = smartFillJson(template, settings.mock);
|
||||
if (filled.filled > 0) template = filled.text;
|
||||
}
|
||||
}
|
||||
|
||||
const mockUrl = space ? `${location.origin.replace(/:\d+$/, ":8787")}/mock/${space.id}${urlPath}` : "";
|
||||
|
||||
const publish = async () => {
|
||||
if (!account.token || !space) return;
|
||||
setBusy(true);
|
||||
setResult(null);
|
||||
try {
|
||||
// 全量发布:集合内全部 HTTP 接口(同空间共用一组 Mock 端点)
|
||||
const endpoints = collectEndpoints(collections, settings);
|
||||
// 当前接口应用弹窗选择的状态码与延迟
|
||||
const mine = endpoints.find((e) => e.method === method && e.path === urlPath);
|
||||
if (mine) {
|
||||
mine.status = status;
|
||||
mine.delayMs = delayMs;
|
||||
}
|
||||
await publishMock(account.token, space.id, endpoints);
|
||||
setResult({ ok: true, message: `已发布 ${endpoints.length} 个接口到 /mock/${space.id}` });
|
||||
} catch (e) {
|
||||
setResult({ ok: false, message: e instanceof Error ? e.message : String(e) });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const preview = async () => {
|
||||
if (!mockUrl) return;
|
||||
setBusy(true);
|
||||
setResult(null);
|
||||
try {
|
||||
const res = await fetch(mockUrl, { method: "GET" });
|
||||
const text = await res.text();
|
||||
setResult({ ok: res.ok, message: `HTTP ${res.status} · ${text.slice(0, 400)}` });
|
||||
} catch (e) {
|
||||
setResult({ ok: false, message: e instanceof Error ? e.message : String(e) });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Mock 链接"
|
||||
onClose={() => setDialog(null)}
|
||||
width="620px"
|
||||
footer={
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span className="ops-faint text-[11px]">本地 Mock 服务 · 限流 20 QPS / 60 QPM(超限 429)</span>
|
||||
<div className="flex gap-2">
|
||||
<button className="ops-btn ops-btn-secondary" onClick={preview} disabled={!space || busy}>
|
||||
<Icon name="play" size={13} /> 试一试
|
||||
</button>
|
||||
<button className="ops-btn ops-btn-primary" onClick={publish} disabled={!space || busy}>
|
||||
<Icon name="upload" size={13} /> {busy ? "发布中…" : "发布 / 更新"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3.5">
|
||||
{!account.user ? (
|
||||
<div className="space-y-3">
|
||||
<div className="ops-hint">
|
||||
<Icon name="info" size={14} />
|
||||
<span>Mock 链接挂在云端空间下(按空间隔离),请先登录并切换到个人 / 团队空间。</span>
|
||||
</div>
|
||||
<button className="ops-btn ops-btn-secondary" onClick={() => setDialog({ type: "auth" })}>
|
||||
<Icon name="shield-check" size={13} /> 登录 / 注册
|
||||
</button>
|
||||
</div>
|
||||
) : !space ? (
|
||||
<div className="ops-hint">
|
||||
<Icon name="info" size={14} />
|
||||
<span>当前在本地空间。请通过顶栏账号胶囊切换到「我的空间」或团队空间后再发布 Mock。</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<label className="ops-label">Mock 地址(前端联调直接请求它)</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input className="ops-input flex-1 font-mono !text-[12px]" readOnly value={mockUrl} onFocus={(e) => e.currentTarget.select()} />
|
||||
<button
|
||||
className="ops-btn ops-btn-secondary !px-2"
|
||||
title="复制"
|
||||
onClick={() => void navigator.clipboard.writeText(mockUrl)}
|
||||
>
|
||||
<Icon name="copy" size={13} />
|
||||
</button>
|
||||
{method === "GET" && (
|
||||
<a className="ops-btn ops-btn-ghost !px-2" href={mockUrl} target="_blank" rel="noopener noreferrer" title="浏览器打开">
|
||||
<Icon name="external-link" size={13} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="ops-faint mt-1 text-[11px]">
|
||||
{method} {urlPath} · 非方法可用 <code>?_method={method}</code> 测试 · 发布包含当前空间集合内全部 HTTP 接口
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="ops-label">响应状态码</label>
|
||||
<select className="ops-input" value={status} onChange={(e) => setStatus(Number(e.target.value))}>
|
||||
{[200, 201, 204, 400, 401, 403, 404, 429, 500].map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="ops-label">延迟(毫秒,模拟慢接口)</label>
|
||||
<input
|
||||
className="ops-input"
|
||||
type="number"
|
||||
min={0}
|
||||
max={5000}
|
||||
step={100}
|
||||
value={delayMs}
|
||||
onChange={(e) => setDelayMs(Math.max(0, Math.min(5000, Number(e.target.value) || 0)))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="ops-label">响应模板(@表达式每次请求随机生成)</label>
|
||||
<textarea className="ops-code min-h-36 w-full resize-y !text-[12px]" readOnly value={template} />
|
||||
<div className="ops-faint mt-1 text-[11px]">
|
||||
模板取自当前请求的 JSON Body(开启智能 Mock 时自动烘焙 @ 表达式);改 Body 后重新发布即可生效。
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className={cx("rounded-lg px-3 py-2 text-[12px] font-mono", result.ok ? "ops-success" : "ops-danger")}
|
||||
style={{ border: "1px solid var(--border)" }}>
|
||||
{result.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function extractPath(url: string): string {
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) return "/";
|
||||
const m = /^https?:\/\/[^/]+(\/[^\s?#]*)?/.exec(trimmed);
|
||||
if (m) return m[1] || "/";
|
||||
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||
}
|
||||
|
||||
function collectEndpoints(
|
||||
collections: { children: unknown[] }[],
|
||||
settings: { mock: MockSettings },
|
||||
): { method: string; path: string; status: number; contentType: string; body: string; delayMs: number }[] {
|
||||
const out: { method: string; path: string; status: number; contentType: string; body: string; delayMs: number }[] = [];
|
||||
const seen = new Map<string, number>(); // method+path 去重(保留靠后的目录项)
|
||||
const walk = (nodes: unknown[]): void => {
|
||||
for (const n of nodes as { type?: string; kind?: string; children?: unknown[]; data?: { url?: string; method?: string; bodyMode?: string; bodyRaw?: string } }[]) {
|
||||
if (n.type === "folder") {
|
||||
walk(n.children ?? []);
|
||||
} else if (n.type === "request" && n.kind === "http" && n.data) {
|
||||
let body = "{}";
|
||||
if (n.data.bodyMode === "json" && n.data.bodyRaw?.trim()) {
|
||||
body = n.data.bodyRaw;
|
||||
if (settings.mock.enabled && settings.mock.smart) {
|
||||
const filled = smartFillJson(body, settings.mock);
|
||||
if (filled.filled > 0) body = filled.text;
|
||||
}
|
||||
}
|
||||
const ep = {
|
||||
method: n.data.method ?? "GET",
|
||||
path: extractPath(n.data.url ?? ""),
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body,
|
||||
delayMs: 0,
|
||||
};
|
||||
const key = `${ep.method} ${ep.path}`;
|
||||
const prev = seen.get(key);
|
||||
if (prev !== undefined) out[prev] = ep;
|
||||
else {
|
||||
seen.set(key, out.length);
|
||||
out.push(ep);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
for (const c of collections) walk(c.children);
|
||||
return out;
|
||||
}
|
||||
@@ -31,6 +31,7 @@ export function HttpEditor({ node }: { node: HttpApiNode }) {
|
||||
const update = useWorkspace((s) => s.updateRequestData);
|
||||
const updateDesc = useWorkspace((s) => s.updateNodeDescription);
|
||||
const runtime = useWorkspace((s) => s.runtime[node.id]);
|
||||
const setDialog = useWorkspace((s) => s.setDialog);
|
||||
const [tab, setTab] = useState<ReqTab>("Params");
|
||||
const [codeMenu, setCodeMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
const [codeView, setCodeView] = useState<{ target: CodeTarget; code: string } | null>(null);
|
||||
@@ -99,6 +100,13 @@ export function HttpEditor({ node }: { node: HttpApiNode }) {
|
||||
>
|
||||
<Icon name="code" size={13} /> 代码
|
||||
</button>
|
||||
<button
|
||||
className="ops-btn ops-btn-secondary"
|
||||
onClick={() => setDialog({ type: "mocklink", nodeId: node.id })}
|
||||
title="发布到本地 Mock 服务,获取可分享的 Mock 地址"
|
||||
>
|
||||
<Icon name="globe" size={13} /> Mock 链接
|
||||
</button>
|
||||
</div>
|
||||
{(data.url.includes("{{") || (envBase && (data.url === "" || data.url.trimStart().startsWith("/")))) && (
|
||||
<div className="ops-faint px-4 py-1 text-[11px]" style={{ borderBottom: "1px solid var(--border)" }}>
|
||||
|
||||
@@ -89,7 +89,8 @@ export type Dialog =
|
||||
| { type: "rename"; id: string }
|
||||
| { type: "confirm"; title: string; message: string; action: () => void }
|
||||
| { type: "auth" }
|
||||
| { type: "team" };
|
||||
| { type: "team" }
|
||||
| { type: "mocklink"; nodeId: string };
|
||||
|
||||
export function emptyRuntime(): RequestRuntime {
|
||||
return {
|
||||
|
||||
Reference in new issue
Block a user