diff --git a/core/backend/apps/assets/views.py b/core/backend/apps/assets/views.py index a0f7bb9..e72841d 100644 --- a/core/backend/apps/assets/views.py +++ b/core/backend/apps/assets/views.py @@ -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"] diff --git a/core/backend/apps/billing/views.py b/core/backend/apps/billing/views.py index 3df35ea..6b1fec3 100644 --- a/core/backend/apps/billing/views.py +++ b/core/backend/apps/billing/views.py @@ -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: diff --git a/core/qa/function-audit/PERF-GATE.md b/core/qa/function-audit/PERF-GATE.md new file mode 100644 index 0000000..ce46a89 --- /dev/null +++ b/core/qa/function-audit/PERF-GATE.md @@ -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 ` 免登录。 + +## 闸怎么判(两层) + +### 硬闸(确定性,挂 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 即时生效。 diff --git a/core/qa/function-audit/perf-probe.mjs b/core/qa/function-audit/perf-probe.mjs new file mode 100644 index 0000000..a686d08 --- /dev/null +++ b/core/qa/function-audit/perf-probe.mjs @@ -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(免登录) +// 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); +});