后端:adminpanel ledgers(全局流水+筛)+ adjust(手动调额,落 ADJUSTMENT)+ quota-policies CRUD; 额度拦截 _enforce_quota_policy(单任务/月度/项目)接入 reserve_credit,仅团队有 active 策略才生效(无策略零回归); adjust_credit helper。修真 bug:log_admin_action 加 savepoint + JSON 安全化(UUID/Decimal), 修复传 serializer.data 致审计报错污染外层事务 → TransactionManagementError。 前端:adminApi 计费/额度系列;计费审计页(流水表+手动调额弹窗)+ 额度策略页(CRUD 弹窗,团队下拉+三类上限+启用)。 测试:adminpanel 44 + billing + accounts = 70 单测过(调额±/超额拒/额度拦截 per_task+monthly/无策略零回归); 无头 e2e _admin-p7.mjs 6 断言过 + 0 console error(一次性团队,不动 demo 余额);tsc+build 绿。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
103 lines
5.6 KiB
JavaScript
103 lines
5.6 KiB
JavaScript
// Phase 7 e2e:计费审计(流水 + 手动调额)+ 额度策略(CRUD)。
|
|
// 用一次性 P7 团队做调额/建策略,绝不动 demo 团队余额。建的策略跑完删。
|
|
import { chromium } from "playwright";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
|
|
const BASE = process.env.BASE || "http://127.0.0.1:5188";
|
|
const API = process.env.API || "http://127.0.0.1:8010";
|
|
const OUT = path.resolve("output/admin");
|
|
fs.mkdirSync(OUT, { recursive: true });
|
|
const stamp = Date.now();
|
|
|
|
async function apiLogin(u, p) {
|
|
const res = await fetch(`${API}/api/auth/login/`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: u, password: p }) });
|
|
return res.json();
|
|
}
|
|
const admin = await apiLogin("admin", "admin123");
|
|
const authH = { "Content-Type": "application/json", Authorization: `Token ${admin.token}` };
|
|
// 一次性团队
|
|
const code = (await (await fetch(`${API}/api/admin/invitations/`, { method: "POST", headers: authH, body: "{}" })).json()).code;
|
|
const reg = await (await fetch(`${API}/api/auth/register/`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: `p7o-${stamp}`, password: "ownerpass1", team_name: `P7Team${stamp}`, invite_code: code }) })).json();
|
|
const teamId = reg.team.id;
|
|
|
|
const r = { billing: {}, adjust: {}, quota: {}, consoleErrors: [], pass: false };
|
|
const browser = await chromium.launch({ headless: true });
|
|
const hook = (p, tag) => {
|
|
p.on("console", (m) => { if (m.type() === "error") r.consoleErrors.push(`${tag}:${m.text()}`); });
|
|
p.on("pageerror", (e) => r.consoleErrors.push(`${tag}:PAGEERR:${e.message}`));
|
|
};
|
|
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
|
await ctx.addInitScript((tk) => localStorage.setItem("airshelf_token", tk), admin.token);
|
|
const p = await ctx.newPage(); hook(p, "billing");
|
|
|
|
// ── 计费审计 + 手动调额 ──
|
|
await p.goto(BASE + "/admin/billing", { waitUntil: "load" });
|
|
await p.waitForSelector(".admin-app", { timeout: 12000 });
|
|
await p.waitForFunction(() => document.querySelector(".admin-table") || (document.querySelector(".empty-state.show h3") || {}).textContent !== "加载中…", { timeout: 20000 }).catch(() => {});
|
|
r.billing.title = (await p.locator(".content h1").first().innerText().catch(() => "")).trim();
|
|
r.billing.adjustBtn = (await p.locator(".page-head .actions .btn-primary").count()) >= 1;
|
|
r.billing.rows = await p.locator(".admin-table tbody tr").count();
|
|
await p.screenshot({ path: path.join(OUT, "p7-ledgers.png"), fullPage: true });
|
|
|
|
await p.locator(".page-head .actions .btn-primary").click();
|
|
await p.waitForSelector(".modal", { timeout: 6000 });
|
|
await p.locator(".modal .select").selectOption(teamId);
|
|
await p.locator('.modal input[placeholder*="100"]').fill("88");
|
|
await p.locator('.modal input[placeholder*="原因"]').fill("e2e 调额");
|
|
await p.locator(".modal-f .btn-primary").click();
|
|
await p.waitForTimeout(1200);
|
|
const ledgers = await (await fetch(`${API}/api/admin/ledgers/?team=${teamId}&ledger_type=adjustment`, { headers: authH })).json();
|
|
r.adjust.persisted = ledgers.count >= 1 && ledgers.results.some((l) => l.amount.startsWith("88"));
|
|
|
|
// ── 额度策略 CRUD ──
|
|
await p.goto(BASE + "/admin/quota", { waitUntil: "load" });
|
|
await p.waitForSelector(".admin-app", { timeout: 12000 });
|
|
await p.waitForFunction(() => document.querySelector(".admin-table") || (document.querySelector(".empty-state.show h3") || {}).textContent !== "加载中…", { timeout: 20000 }).catch(() => {});
|
|
r.quota.title = (await p.locator(".content h1").first().innerText().catch(() => "")).trim();
|
|
r.quota.newBtn = (await p.locator(".page-head .actions .btn-primary").count()) >= 1;
|
|
|
|
await p.locator(".page-head .actions .btn-primary").click();
|
|
await p.waitForSelector(".modal", { timeout: 6000 });
|
|
await p.locator(".modal .select").selectOption(teamId);
|
|
await p.locator(".modal .input").nth(1).fill("5"); // 单任务上限(field-row 第二个)
|
|
await p.locator(".modal-f .btn-primary").click();
|
|
await p.waitForTimeout(1200);
|
|
const pols = await (await fetch(`${API}/api/admin/quota-policies/?team=${teamId}`, { headers: authH })).json();
|
|
r.quota.created = pols.count >= 1;
|
|
await p.screenshot({ path: path.join(OUT, "p7-quota.png"), fullPage: true });
|
|
|
|
// UI 删除我们建的策略(清理)
|
|
if (r.quota.created) {
|
|
// 该团队是新建的,本页仅它一条相关;直接删页面上属于它的行(按团队名定位)
|
|
const rowDel = p.locator(`.admin-table tbody tr:has-text("P7Team${stamp}") .btn-ghost.danger`).first();
|
|
if (await rowDel.count()) {
|
|
await rowDel.click();
|
|
await p.waitForTimeout(1000);
|
|
}
|
|
const after = await (await fetch(`${API}/api/admin/quota-policies/?team=${teamId}`, { headers: authH })).json();
|
|
r.quota.deleted = after.count === 0;
|
|
}
|
|
|
|
await ctx.close();
|
|
await browser.close();
|
|
// 兜底清理:删该团队残留策略
|
|
const left = await (await fetch(`${API}/api/admin/quota-policies/?team=${teamId}`, { headers: authH })).json();
|
|
for (const pol of left.results || []) {
|
|
await fetch(`${API}/api/admin/quota-policies/${pol.id}/`, { method: "DELETE", headers: authH });
|
|
}
|
|
|
|
const checks = {
|
|
billingPage: r.billing.title === "计费审计" && r.billing.adjustBtn === true && r.billing.rows >= 1,
|
|
adjustPersisted: r.adjust.persisted === true,
|
|
quotaPage: r.quota.title === "额度策略" && r.quota.newBtn === true,
|
|
quotaCreated: r.quota.created === true,
|
|
quotaDeleted: r.quota.deleted === true,
|
|
zeroConsoleErrors: r.consoleErrors.length === 0,
|
|
};
|
|
r.checks = checks;
|
|
r.pass = Object.values(checks).every(Boolean);
|
|
console.log(JSON.stringify(r, null, 2));
|
|
fs.writeFileSync(path.join(OUT, "p7-summary.json"), JSON.stringify(r, null, 2));
|
|
process.exit(r.pass ? 0 : 1);
|