本地 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:
logan committed 2026-09-18 12:29:49 +08:00
1 parent 522e5d6300
commit d9ccccd280
17 files changed
+1070 -6

No files matched your search

+8
View File
@@ -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>
+16
View File
@@ -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)" }}>
+2 -1
View File
@@ -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 {