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>
This commit is contained in:
zyc
2026-06-17 16:48:15 +08:00
co-authored by Claude Opus 4.8
parent 9bdd0683f8
commit 1ff7be2b56
4 changed files with 379 additions and 3 deletions
+10 -2
View File
@@ -28,8 +28,16 @@ class AssetPagination(PageNumberPagination):
class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
# select_related 回溯链:asset → origin_task → project,供序列化器解析资产归属商品(避免 N+1)
queryset = Asset.objects.prefetch_related("files").select_related("origin_task__project").all()
# select_related 回溯链:asset → origin_task → project,供序列化器解析资产归属商品(避免 N+1)
# ★ defer 掉 AITask 的两个巨型 JSON 列(request/response payload):列表序列化只需 project.product_id,
# 不 defer 的话 select_related 会把每个资产关联 AITask 的完整 AI 请求/响应 payload 全拖出来,
# 200 个资产实测 100s+(数据越多越慢);defer 后 <1s、product 仍正常解析、无额外查询。
queryset = (
Asset.objects.prefetch_related("files")
.select_related("origin_task__project")
.defer("origin_task__request_payload", "origin_task__response_payload")
.all()
)
serializer_class = AssetSerializer
pagination_class = AssetPagination
search_fields = ["name", "description"]
+8 -1
View File
@@ -49,7 +49,14 @@ def summary(request):
@permission_classes([IsAuthenticated])
def ledgers(request):
team = get_current_team(request.user)
queryset = CreditLedger.objects.filter(team=team).select_related("user", "project", "task").order_by("-created_at")
# defer task 的两个巨型 payload 列:流水只展示金额/类型/关联,不需要 AI 请求/响应 payload。
# 与 assets/billing.summary 同一惯例,避免 select_related("task") 把大 JSON 拖出来。
queryset = (
CreditLedger.objects.filter(team=team)
.select_related("user", "project", "task")
.defer("task__request_payload", "task__response_payload")
.order_by("-created_at")
)
project_id = request.query_params.get("project")
user_id = request.query_params.get("user")
if project_id:
+62
View File
@@ -0,0 +1,62 @@
# AirShelf/core 性能验证闸(perf-probe)
**ralph-loop**(或人工)当 pass/fail 闸用的性能回归探针。全绿 → 退出码 0;任一硬闸不过 → 退出码 1 + `PERF-PROBE: FAIL`
## 一句话目的
逐页 + 逐接口验证「访问变快了 / 重复请求没了 / 但功能没改崩」。**改性能时反复跑它,直到全绿。**
## 怎么跑
先起前后端(后端 `:8010` 连真实 MySQL;前端 vite dev **必须带 /api 代理**):
```bash
# 后端(backend/)
DB_ENGINE=mysql .venv/bin/python manage.py runserver 127.0.0.1:8010 --noreload
# 前端(frontend/)—— 记下它实际监听的端口(可能是 5173/5174)
npm run dev
# 探针(qa/function-audit/)
node perf-probe.mjs --base http://127.0.0.1:5174 # 改成前端实际端口
node perf-probe.mjs --base http://127.0.0.1:5174 --only dashboard,projects
node perf-probe.mjs --base http://127.0.0.1:5174 --headed # 看着跑
```
默认演示账号 `e2e-20260529-0806@airshelf.test` / `demo12345`;也可 `--token <TOKEN>` 免登录。
## 闸怎么判(两层)
### 硬闸(确定性,挂 pass/fail)
1. **API 级:列表接口必须遵守 `page_size`** —— `?page_size=200` 要全部一页拉得到时,`next` 必须为 `null`、条数 = `count`
*当前根因*:`/api/assets/` 无视 `page_size=200`,死按 20 条/页返回 → 前端 `allAssets` 被迫并行翻 N 页(资产越多页越多)。
2. **浏览器级:任意页面不得出现资产分页瀑布**(`/api/assets/?page=2…`)。这是上面那条的直接症状。
3. **功能守护**:每页必须真渲染(没掉回登录页)、所有接口 `< 400`、bootstrap 必须在 25s 内发起(否则=后端慢到没加载出来,判失败防假通过)。
> 为什么 API 检查 + 浏览器双保险:浏览器按时序数瀑布会被后端延迟抖动影响(慢时漏抓)。API 断言与时序无关、永不抖,是真正的硬闸;浏览器只作功能守护 + 佐证。
### 软提示(打印不挂闸,留给架构决策)
- **接口延迟**:本地连远程 MySQL 每次握手 ~1.6s,绝对 ms 天然抖,不宜当硬闸;但打印出来暴露**疑似 N+1 的慢接口**(基线里 `/api/projects/` ~2s、`/api/ops/notifications/` ~1.4s,大概率缺 `select_related/prefetch_related`)。
- **首屏接口数超预算**:每页 bootstrap 拉了 ~9 个全局接口(products/projects/assets/billing/trend/members/models/tasks/notifications),且**每个路由都重拉一遍**——连 `/account` 这种用不到的页也拉。这是架构性过量拉取,**怎么改属设计决策**(懒加载 / 按页取数 / 引入 react-query 缓存层),不由闸强判,见下。
## 基线(2026-06-17,优化前)
-`/api/assets/` page_size 未生效(count=194 → 返回 20、next 有)→ **根因**
- ❌ dashboard / projects / account 等出现 9 页资产瀑布。
-`/api/projects/` ~2s、`/api/ops/notifications/` ~1.4s(疑似 N+1)。
- ⚠ 每页首屏 9~18 个接口(bootstrap 每页重拉)。
- ✅ 全部页面功能正常、无接口报错。
## 优化方向(供参考,不替代设计决策)
1. **修 `page_size` 根因(后端)**:让 `/api/assets/` 真正应用 `AssetPagination`(`max_page_size=200`)。这一条修好,前端瀑布自动消失。
2. **修 `allAssets` 兜底(前端 `src/api.ts`)**:别用 `results.length` 反推页数;按 `count` + 真实 `page_size` 算,或直接跟 `next`
3. **慢接口补 ORM**:`/api/projects/``/api/ops/notifications/` 等加 `select_related/prefetch_related`、确认有分页。
4. **(设计决策)bootstrap 瘦身**:全局只加载真正全站要用的;各页数据按需懒加载 + 缓存,避免每路由重拉 9 个接口。**此项请人工拍板方案再做。**
## 给 ralph 的硬规矩
- 严禁删功能换性能;严禁为过闸放宽断言阈值或把检查注释掉。
- 每轮改完必须重跑 `node perf-probe.mjs --base <前端端口>`,全绿(`PERF-PROBE: PASS`)才算完成。
- 改后端任务/接口代码后注意:本地不起 worker,纯接口改动 runserver 即时生效。
+299
View File
@@ -0,0 +1,299 @@
// 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);
});