Files
yingqing/core/qa/function-audit/perf-probe.mjs
T
zycandClaude Opus 4.8 1ff7be2b56 perf(core): assets/ledgers 列表 defer 掉 AITask 巨型 payload 列,根治取全部资产 60s+ 慢查询
- /api/assets/ 列表 select_related('origin_task__project') 为解析资产归属商品,
  会把每个资产关联 AITask 的 request_payload/response_payload(完整 AI 请求/响应 JSON)
  整行拖出 → 取全部 200 资产实测 >60s 超时,数据越多越慢(用户报的「刷新越来越卡」)。
  加 .defer 这两列后 0.76s,product 仍正常解析、序列化 0 额外查询。
- billing/ledgers 同模式补 .defer(task payload)。ai/billing.summary 早已 defer,assets/ledgers 本是漏网。
- 附 qa/function-audit/perf-probe.mjs 性能验证闸 + PERF-GATE.md(API 确定性断言+浏览器功能守护)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 16:48:15 +08:00

300 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// AirShelf/core 性能验证闸 —— 逐页测「请求数 / 重复请求 / 加载耗时 / 功能是否还在」。
// 这是给 ralph-loop 当 pass/fail 闸用的:全绿 → 退出码 0;任一页超阈值/功能崩 → 退出码 1。
//
// 核心信号(均与数据量无关,故确定性强、可当回归闸):
// 1. 重复请求:一次页面加载里,同一个 (method + 路径 + query) 被打 ≥2 次 = 死罪(用户报的「连续重复请求」)。
// 2. 请求数:每页加载触发的 /api 请求总数超阈值 = 失败(架构性过量拉取)。
// 3. 功能守护:页面必须真渲染出来(侧边栏在 + 没掉回登录页 + 所有接口 <400),防止「为了变快把功能改崩」。
// 4. 耗时:仅作参考记录(随机器/网络抖动,不作硬闸)。
//
// 用法(先起前后端 :5173 / :8010):
// node perf-probe.mjs # 用默认演示账号登录跑全部页
// node perf-probe.mjs --token <TOKEN> # 用已有 token(免登录)
// node perf-probe.mjs --only dashboard,products
// node perf-probe.mjs --headed # 看着跑
// node perf-probe.mjs --update-baseline # 把当前结果写成新基线(仅人工确认变更时用)
import { chromium } from "playwright";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const here = path.dirname(fileURLToPath(import.meta.url));
function arg(name, def = "") {
const i = process.argv.indexOf(`--${name}`);
return i >= 0 && process.argv[i + 1] && !process.argv[i + 1].startsWith("--") ? process.argv[i + 1] : def;
}
function boolArg(name) {
return process.argv.includes(`--${name}`);
}
const BASE = arg("base", "http://127.0.0.1:5173").replace(/\/+$/, "");
const EMAIL = arg("email", "e2e-20260529-0806@airshelf.test");
const PASSWORD = arg("password", "demo12345");
let TOKEN = arg("token", "");
const ONLY = arg("only", "").split(",").map((s) => s.trim()).filter(Boolean);
const HEADED = boolArg("headed");
const UPDATE_BASELINE = boolArg("update-baseline");
const QUIET_MS = Number.parseInt(arg("quiet", "2500"), 10); // 非轮询请求静默多久算首屏结束
const HARD_CAP = Number.parseInt(arg("cap", "25000"), 10); // 单页最长等待硬上限 ms(兜底慢后端)
// 这些端点是合法的稳态轮询(进度/通知),窗口内重发不算「重复请求」死罪,但仍计入请求总数。
const POLLING = /\/api\/(ops\/notifications|ai\/tasks|projects\/[^/]+\/(poll-video|poll-export)|assets\/poll-reviews)/;
const outDir = path.resolve(here, "output");
fs.mkdirSync(outDir, { recursive: true });
const baselinePath = path.resolve(here, "perf-baseline.json");
// ── 每页的请求数硬阈值 ──────────────────────────────────────────────
// 注:App.tsx 启动会并行拉 ~10 个全局接口(products/projects/assets/billing/...)。
// 这些是「全局首屏」开销,记在 dashboard 头上。其余页若在已加载全局态后还重复拉同样数据 = 架构问题。
// 阈值是「当前现状 + 少量余量」起步;优化推进后人工调低,逼着请求数下降。
const REQUEST_BUDGET = {
dashboard: 14,
products: 6,
projects: 6,
library: 8,
account: 8,
team: 4,
messages: 4,
productDetail: 8,
pipeline: 10,
};
async function login() {
if (TOKEN) return TOKEN;
const res = await fetch(`${BASE}/api/auth/login/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: EMAIL, password: PASSWORD }),
});
if (!res.ok) throw new Error(`登录失败 ${res.status}:${await res.text()}\n请确认后端 :8010 在跑、演示账号正确,或用 --token。`);
return (await res.json()).token;
}
async function fetchIds() {
const headers = { Authorization: `Token ${TOKEN}` };
const out = { productId: "", projectId: "" };
try {
const p = await (await fetch(`${BASE}/api/products/?page_size=1`, { headers })).json();
out.productId = (p.results || p)[0]?.id || "";
} catch {}
try {
const j = await (await fetch(`${BASE}/api/projects/?page_size=1`, { headers })).json();
out.projectId = (j.results || j)[0]?.id || "";
} catch {}
return out;
}
// 规范化一个请求 → 去重 key(忽略缓存破坏参数,但保留真实 query 语义)
function reqKey(method, url) {
try {
const u = new URL(url);
u.searchParams.delete("_");
u.searchParams.delete("t");
const qs = [...u.searchParams.entries()].sort().map(([k, v]) => `${k}=${v}`).join("&");
return `${method} ${u.pathname}${qs ? "?" + qs : ""}`;
} catch {
return `${method} ${url}`;
}
}
async function probePage(context, name, route, ids) {
const url = `${BASE}${route}`;
const page = await context.newPage();
const reqs = []; // { key, method, url, status, failed }
const t0 = Date.now();
page.on("request", (r) => {
const u = r.url();
if (!u.includes("/api/")) return; // 只关心后端接口,静态资源不计
reqs.push({ key: reqKey(r.method(), u), method: r.method(), url: u, status: null });
});
page.on("requestfinished", async (r) => {
if (!r.url().includes("/api/")) return;
const resp = await r.response().catch(() => null);
const rec = reqs.find((x) => x.url === r.url() && x.status === null);
if (rec) rec.status = resp ? resp.status() : 0;
});
page.on("requestfailed", (r) => {
if (!r.url().includes("/api/")) return;
const rec = reqs.find((x) => x.url === r.url() && x.status === null);
if (rec) rec.status = -1;
});
// 直接单跳目标页(token 已在 context 级 addInitScript 预注入),
// 避免「先跳 / 再跳目标」把首屏在途请求取消、低估真实冷加载成本。
await page.goto(url, { waitUntil: "domcontentloaded" }).catch(() => {});
// 两段式等待,确保「冷加载首屏请求」全部捕获且不被轮询拖住:
const nonPoll = () => reqs.filter((r) => !POLLING.test(r.key)).length;
// ① 先等 bootstrap 真的发出(哨兵 /api/products/)。吸收「me() 偶发变慢」的空档,
// 避免窗口在 loadData 发出前到点而漏抓(实测真实 MySQL 并发下 me() 会偶发 >8s)。
while (Date.now() - t0 < HARD_CAP && !reqs.some((r) => r.key === "GET /api/products/")) {
await page.waitForTimeout(200);
}
const bootstrapStarted = reqs.some((r) => r.key === "GET /api/products/");
// ② 再对「非轮询请求」做静默检测:资产分页瀑布算活动会续命,轮询不算 → 瀑布跑完即收。
let prev = nonPoll();
let idleSince = Date.now();
while (Date.now() - t0 < HARD_CAP) {
await page.waitForTimeout(250);
const n = nonPoll();
if (n > prev) { prev = n; idleSince = Date.now(); }
else if (Date.now() - idleSince >= QUIET_MS) break;
}
const elapsed = Date.now() - t0;
// 功能守护:有没有掉回登录页 / app 有没有真渲染出来。
const onLogin = await page.locator('input[type="password"]').count().catch(() => 0);
const hasApp = await page.locator(".app-shell, .sidebar, nav, [class*='sidebar']").count().catch(() => 0);
await page.close();
// 统计
const counts = {};
for (const r of reqs) counts[r.key] = (counts[r.key] || 0) + 1;
// ★ 主指标:去重后的「非轮询接口数」。轮询重发(notifications/tasks)被排除,
// 故跟窗口长短无关、稳定;资产瀑布 page=2..10 因 query 不同各算一个 → 过量拉取照样现形。
const uniqueKeys = Object.keys(counts).filter((k) => !POLLING.test(k));
const unique = uniqueKeys.length;
// 同一非轮询接口被打 ≥2 次(用户报的「连续重复请求」)。偶发重试可能误报,仅作信息。
const duplicates = Object.entries(counts)
.filter(([k, n]) => n >= 2 && !POLLING.test(k))
.map(([k, n]) => ({ key: k, n }));
const failedReqs = reqs.filter((r) => r.status === -1 || (r.status && r.status >= 400)).map((r) => `${r.key}${r.status}`);
const breakdown = Object.entries(counts).sort((a, b) => b[1] - a[1]).map(([k, n]) => (n > 1 ? `${k} ×${n}` : k));
const budget = REQUEST_BUDGET[name] ?? 99;
// 资产分页瀑布:出现 ?page=2(及以后)= 前端在并行翻一堆页。这是 page_size 被后端截断的直接症状。
const waterfall = uniqueKeys.filter((k) => /\/api\/assets\/\?.*page=([2-9]|\d{2,})/.test(k));
const problems = []; // 硬失败:判 pass/fail
const warnings = []; // 软提示:打印但不挂闸(留给架构决策的项)
if (onLogin > 0 && hasApp === 0) problems.push("掉回登录页/未渲染(功能崩或登录态失效)");
// 没等到 bootstrap 完成 = 后端太慢拖到超时。这不是「请求少=快」,而是「慢到没加载出来」,判失败防假通过。
if (!bootstrapStarted) problems.push(`bootstrap 未完成(${HARD_CAP}ms 内 /api/products/ 未发出,后端过慢)`);
if (waterfall.length > 0) problems.push(`资产分页瀑布 ${waterfall.length} 页(page_size 没生效→并行翻页)`);
if (failedReqs.length > 0) problems.push(`接口报错: ${failedReqs.join(" ; ")}`);
if (unique > budget) warnings.push(`首屏接口数 ${unique} > 预算 ${budget}(架构性过量拉取,待决策)`);
return { name, route: url, unique, budget, rawRequests: reqs.length, waterfall: waterfall.length, duplicates, failed: failedReqs, breakdown, elapsedMs: elapsed, problems, warnings, pass: problems.length === 0 };
}
// ── API 级确定性断言 ────────────────────────────────────────────────
// 不经浏览器,直接打后端。结果与时序/并发/轮询无关 → 永不抖,是 pass/fail 的硬主闸。
async function apiChecks() {
const headers = { Authorization: `Token ${TOKEN}` };
const checks = [];
const get = async (p) => {
const t = Date.now();
const res = await fetch(`${BASE}${p}`, { headers });
const ms = Date.now() - t;
let body = null;
try { body = await res.json(); } catch {}
return { status: res.status, ms, body };
};
// 1) 列表接口必须遵守 page_size(根因:assets 把 page_size=200 截成 20 → 前端并行翻 10 页)。
// 断言:要全部一页拉得到时,next 必须为 null、条数 = min(count, 上限)。
for (const ep of ["/api/assets/", "/api/products/", "/api/projects/"]) {
const r = await get(`${ep}?page_size=200`);
if (r.status !== 200) { checks.push({ name: `page_size ${ep}`, pass: false, detail: `HTTP ${r.status}` }); continue; }
const count = r.body?.count ?? (Array.isArray(r.body?.results) ? r.body.results.length : 0);
const len = r.body?.results?.length ?? 0;
const next = r.body?.next ?? null;
const ok = count <= 200 ? (next === null && len === count) : (len === 200);
checks.push({ name: `page_size 生效 ${ep}`, pass: ok, detail: `count=${count} 返回=${len} next=${next ? "有" : "无"}` + (ok ? "" : " ← page_size 未生效,前端会被迫并行翻页") });
}
// 2) 关键列表接口响应延迟(代表「快」)。软提示不硬挂闸:本地连远程 MySQL 每次握手 ~1.6s、
// 绝对 ms 天然抖,不宜当确定性闸;但打印出来给 ralph 看趋势 + 暴露疑似 N+1 的慢接口。
const LAT = { "/api/assets/?page_size=200": 1200, "/api/products/?page_size=200": 1200, "/api/projects/?page_size=200": 1200, "/api/ops/notifications/?page_size=100": 1200 };
for (const [p, budget] of Object.entries(LAT)) {
const r = await get(p);
const ok = r.status === 200 && r.ms <= budget;
checks.push({ name: `延迟 ${p}`, pass: ok, soft: true, detail: `${r.ms}ms / 参考 ${budget}ms (HTTP ${r.status})` });
}
return checks;
}
async function main() {
TOKEN = await login();
const ids = await fetchIds();
// —— 硬主闸:API 级断言(确定性)——
console.log("── API 级检查(确定性硬闸)──");
const api = await apiChecks();
for (const c of api) console.log(`${c.pass ? "✅" : (c.soft ? "⚠" : "❌")} ${c.name.padEnd(34)} ${c.detail}`);
const apiFailed = api.filter((c) => !c.pass && !c.soft); // 软项(延迟)不挂闸
console.log("");
const ROUTES = {
dashboard: "/dashboard",
products: "/products",
projects: "/projects",
library: "/library",
account: "/account",
team: "/team",
messages: "/messages",
productDetail: ids.productId ? `/products/${encodeURIComponent(ids.productId)}` : null,
pipeline: ids.projectId ? `/pipeline/${encodeURIComponent(ids.projectId)}` : null,
};
const targets = Object.entries(ROUTES)
.filter(([n, r]) => r && (ONLY.length === 0 || ONLY.includes(n)));
const browser = await chromium.launch({ headless: !HEADED });
const results = [];
for (const [name, route] of targets) {
// 每页全新 context:隔离缓存/内存态,测的是「冷进这一页」真实触发多少请求。
const context = await browser.newContext();
// context 级预注入登录态,页面一打开就带 token,无需先跳登录页。
await context.addInitScript((t) => {
try { localStorage.setItem("airshelf_token", t); } catch {}
}, TOKEN);
const r = await probePage(context, name, route, ids);
await context.close();
results.push(r);
const tag = r.pass ? "✅" : "❌";
const warn = r.warnings.length ? " ⚠ " + r.warnings.join(" ;; ") : "";
console.log(`${tag} ${name.padEnd(14)} 首屏接口 ${String(r.unique).padStart(2)} (原始${r.rawRequests}) 瀑布${r.waterfall} ${r.elapsedMs}ms ${r.problems.join(" ;; ") || "OK"}${warn}`);
await new Promise((res) => setTimeout(res, 800)); // 页间留空隙,别把后端打到过载(否则测量被自造的并发拖偏)
}
await browser.close();
const failed = results.filter((r) => !r.pass);
const report = { ts: new Date().toISOString(), base: BASE, apiChecks: api, pageFailed: failed.length, results };
fs.writeFileSync(path.resolve(outDir, "perf-report.json"), JSON.stringify(report, null, 2));
if (UPDATE_BASELINE) {
const baseline = {};
for (const r of results) baseline[r.name] = { unique: r.unique, elapsedMs: r.elapsedMs };
fs.writeFileSync(baselinePath, JSON.stringify(baseline, null, 2));
console.log(`\n📌 已写入基线 ${baselinePath}`);
}
const warned = results.filter((r) => r.warnings.length);
console.log(`\n${"=".repeat(56)}`);
const ok = apiFailed.length === 0 && failed.length === 0;
if (ok) {
console.log(`✅ API 检查全过 + 全部 ${results.length} 页功能正常、无分页瀑布`);
if (warned.length) console.log(`⚠ 但有 ${warned.length} 页首屏接口数超预算(架构性过量拉取,见下,非硬闸):`);
for (const r of warned) console.log(` - ${r.name}: ${r.warnings.join(" ;; ")}`);
console.log("PERF-PROBE: PASS");
process.exit(0);
} else {
if (apiFailed.length) { console.log(`❌ API 级检查 ${apiFailed.length} 项未过:`); for (const c of apiFailed) console.log(` - ${c.name}: ${c.detail}`); }
if (failed.length) { console.log(`❌ ${failed.length}/${results.length} 页未通过:`); for (const r of failed) console.log(` - ${r.name}: ${r.problems.join(" ;; ")}`); }
console.log("PERF-PROBE: FAIL");
process.exit(1);
}
}
main().catch((e) => {
console.error("探针崩溃:", e.message);
console.log("PERF-PROBE: FAIL");
process.exit(1);
});