- 资产 preview_url 从「逐图签发预签名 URL」改为虚拟主机式公读直链 (https://{bucket}.{host}/{key})。桶公读已验证免签可访问;直链稳定可被浏览器/CDN 缓存,而签名链每次都变、1h 过期反而打不到缓存。boto3 也移出序列化热路径。 - allNotifications 不再逐页翻全部(原 O(N) 随消息增长拖慢每次刷新),只取首页 100 条 (侧边栏徽标 unread_count + 团队动态仅展示最近 6 条已足够);「共 N」改用后端真实 count。 - perf-probe:page_size 由 API 级硬闸确定性守住后,浏览器端「资产翻页」降为提示 (翻 1~2 页是资产真超 200 的合法分页,仅 ≥3 页才疑似 page_size 失效)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
302 lines
15 KiB
JavaScript
302 lines
15 KiB
JavaScript
// 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 (failedReqs.length > 0) problems.push(`接口报错: ${failedReqs.join(" ; ")}`);
|
||
// 资产翻页本身:page_size 是否生效已由 API 级硬闸确定性守住。这里翻 1~2 页可能是「资产真超 200」的
|
||
// 合法分页,故只在「明显过量(≥3 页 = page_size 没生效的老症状)」时提示,不硬挂闸(避免误报)。
|
||
if (waterfall.length >= 3) warnings.push(`资产分页瀑布 ${waterfall.length} 页(疑似 page_size 失效,查 API 硬闸)`);
|
||
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);
|
||
});
|