feat(admin): Phase 7 计费审计+4层额度策略 — 流水浏览/手动调额/额度策略CRUD+拦截
后端: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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
ca1e50d32c
commit
39b4467258
@@ -0,0 +1,102 @@
|
||||
// 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);
|
||||
Reference in New Issue
Block a user