feat(usage): limit read-only request history

This commit is contained in:
2026-08-19 18:55:35 +08:00
parent a35854e018
commit 2933d077b3
8 changed files with 68 additions and 18 deletions
+10 -3
View File
@@ -92,11 +92,18 @@ func TestReadOnlyResourceReturnsRealDataWithoutSecrets(t *testing.T) {
usage := managementCallRequest(t, app, ManagementRequest{
Method: http.MethodGet,
Path: resourceBase + resourceUI,
Query: url.Values{"view": {"usage"}},
Query: url.Values{"view": {"usage"}, "page": {"9"}, "page_size": {"100"}},
})
if usage.StatusCode != http.StatusOK || strings.Contains(string(usage.Body), `"api_key":"000000"`) {
t.Fatalf("read-only usage exposed a historical key: status=%d body=%s", usage.StatusCode, usage.Body)
}
var readOnlyPayload usageListResponse
if err := json.Unmarshal(usage.Body, &readOnlyPayload); err != nil {
t.Fatal(err)
}
if readOnlyPayload.Pagination.Page != 1 || readOnlyPayload.Pagination.PageSize != 50 {
t.Fatalf("read-only usage pagination=%+v", readOnlyPayload.Pagination)
}
invalid := managementCallRequest(t, app, ManagementRequest{
Method: http.MethodGet,
@@ -565,7 +572,7 @@ func TestUsageResourceServesFeatureModules(t *testing.T) {
if pageResponse.StatusCode != http.StatusOK || !strings.Contains(page, `data-view="logs">日志`) {
t.Fatalf("unexpected UI response: status=%d", pageResponse.StatusCode)
}
for _, feature := range []string{`<body class="read-only">`, `id="demo-perspective" class="demo-perspective hidden"`, `data-perspective="admin">管理员示教`, `data-perspective="user">普通用户视角`, `data-view="stats">统计`, `data-view="users">用户`, `id="view-users" class="view admin-only"`, `id="quota-chart"`, `id="page-buttons"`, `id="user-usage"`, `id="usage-filter-panel" class="usage-filter-panel"`, `id="editor-quota"`, `id="billing-ledger"`, `id="key-management" class="user-management"`, `id="reset-all-billing"`, `id="key-rows" class="user-card-grid"`, `class="surface price-panel price-layout"`, `src="./ui-config.js"`, `type="module" src="./app/main.js"`, `href="./styles/base.css"`, `href="./styles/keys.css"`, `href="./styles/usage.css"`, `href="./styles/pricing.css"`} {
for _, feature := range []string{`<body class="read-only">`, `id="demo-perspective" class="demo-perspective hidden"`, `data-perspective="admin">管理员示教`, `data-perspective="user">普通用户视角`, `data-view="stats">统计`, `data-view="users">用户`, `id="view-users" class="view admin-only"`, `id="quota-chart"`, `id="page-buttons" class="page-buttons admin-only"`, `id="user-usage"`, `id="usage-filter-panel" class="usage-filter-panel admin-only"`, `id="refresh" class="admin-only"`, `id="editor-quota"`, `id="billing-ledger"`, `id="key-management" class="user-management"`, `id="reset-all-billing"`, `id="key-rows" class="user-card-grid"`, `class="surface price-panel price-layout"`, `src="./ui-config.js"`, `type="module" src="./app/main.js"`, `href="./styles/base.css"`, `href="./styles/keys.css"`, `href="./styles/usage.css"`, `href="./styles/pricing.css"`} {
if !strings.Contains(page, feature) {
t.Fatalf("UI does not contain HTML feature %q", feature)
}
@@ -594,7 +601,7 @@ func TestUsageResourceServesFeatureModules(t *testing.T) {
}
javascript.Write(response.Body)
}
for _, feature := range []string{"initializeRuntime", "dataFetch", "isManagementAuthorized", "pageSize = 100", `row.setAttribute("role", "button")`, "syncLongSectionVisibility", `return "compact"`, "isCompactEndpoint(record.endpoint)", `identity.title = "点击复制 Key"`, `return "自由选择"`, "剩余额度", `document.body.classList.toggle("read-only"`, `删除 ${model} 的真实价格配置`, `发现 ${changed.length} 个已关联价格发生变化`} {
for _, feature := range []string{"initializeRuntime", "dataFetch", "isManagementAuthorized", "adminPageSize = 100", "userPageSize = 50", `row.setAttribute("role", "button")`, "syncLongSectionVisibility", `return "compact"`, "isCompactEndpoint(record.endpoint)", `identity.title = "点击复制 Key"`, `return "自由选择"`, "剩余额度", `document.body.classList.toggle("read-only"`, `删除 ${model} 的真实价格配置`, `发现 ${changed.length} 个已关联价格发生变化`} {
if !strings.Contains(javascript.String(), feature) {
t.Fatalf("UI modules do not contain feature %q", feature)
}
+2 -1
View File
@@ -47,7 +47,8 @@ func (a *App) readOnlyResponse(query url.Values) ManagementResponse {
}
func (a *App) readOnlyUsageResponse(query url.Values) ManagementResponse {
response := a.usageResponse(query)
limited := url.Values{"page": {"1"}, "page_size": {"50"}}
response := a.usageResponse(limited)
if response.StatusCode != http.StatusOK {
return response
}
+5 -1
View File
@@ -57,6 +57,10 @@ export function isManagementAuthorized() {
return managementAuthorized;
}
export function usesReadOnlyData() {
return !keyInput.value.trim() || (config.demoPerspectives && demoPerspective === "user");
}
export function setAccessState(authorized) {
managementAuthenticated = authorized;
applyAccessState();
@@ -95,7 +99,7 @@ function applyAccessState() {
}
export async function dataFetch(url) {
const key = keyInput.value.trim();
const key = usesReadOnlyData() ? "" : keyInput.value.trim();
if (key) {
const response = await fetch(url, { headers: authHeaders() });
const payload = await responsePayload(response);
+16 -8
View File
@@ -1,4 +1,4 @@
import { dataFetch, routes } from "../core/runtime.js";
import { dataFetch, isManagementAuthorized, routes, usesReadOnlyData } from "../core/runtime.js";
import { endpoint, isCompactEndpoint, number, option, result } from "../core/shared.js";
const statusNode = document.querySelector("#status");
@@ -7,7 +7,8 @@ const headersNode = document.querySelector("#headers");
const columnOptionsNode = document.querySelector("#column-options");
const pageButtonsNode = document.querySelector("#page-buttons");
const columnStoreKey = "billing:usage-columns";
const pageSize = 100;
const adminPageSize = 100;
const userPageSize = 50;
let currentRecords = [];
let currentPage = 1;
@@ -57,7 +58,8 @@ export function initializeUsage() {
}
export async function activateUsage() {
await Promise.all([loadUsageFilterOptions(), loadUsage(true, 1)]);
await loadUsage(true, 1);
if (isManagementAuthorized()) await loadUsageFilterOptions();
}
export function refreshVisibleUsage() {
@@ -75,10 +77,11 @@ async function loadUsage(manual = false, page = currentPage, cursor = "") {
loading = true;
if (manual) statusNode.textContent = "正在读取";
try {
const params = usageFilterQuery();
params.set("page", String(page || 1));
params.set("page_size", String(pageSize));
if (cursor) params.set("cursor", cursor);
const readOnly = usesReadOnlyData();
const params = readOnly ? new URLSearchParams() : usageFilterQuery();
params.set("page", String(readOnly ? 1 : page || 1));
params.set("page_size", String(readOnly ? userPageSize : adminPageSize));
if (!readOnly && cursor) params.set("cursor", cursor);
const payload = await dataFetch(routes.usage + "?" + params.toString());
currentPagination = payload.pagination || emptyPagination();
render(payload.records || []);
@@ -150,6 +153,11 @@ function renderColumnControls() {
}
function renderPagination() {
if (!isManagementAuthorized()) {
currentPage = 1;
pageButtonsNode.replaceChildren();
return;
}
const pageCount = currentPagination.total_pages;
currentPage = currentPagination.page || 1;
const controls = [];
@@ -231,7 +239,7 @@ function pageItems(page, count) {
}
function emptyPagination() {
return { page: 1, page_size: pageSize, total: 0, total_pages: 0, previous_cursor: "", next_cursor: "" };
return { page: 1, page_size: usesReadOnlyData() ? userPageSize : adminPageSize, total: 0, total_pages: 0, previous_cursor: "", next_cursor: "" };
}
function duration(value) {
+3 -3
View File
@@ -51,8 +51,8 @@
</section>
<section id="view-usage" class="view">
<div class="section-heading"><div><div id="page-buttons" class="page-buttons"></div><span id="status" class="status"></span></div><div class="tools">
<details id="usage-filter-panel" class="usage-filter-panel"><summary>查询过滤</summary><div class="surface usage-filters">
<div class="section-heading"><div><div id="page-buttons" class="page-buttons admin-only"></div><span id="status" class="status"></span></div><div class="tools">
<details id="usage-filter-panel" class="usage-filter-panel admin-only"><summary>查询过滤</summary><div class="surface usage-filters">
<label class="usage-filter">开始时间<input id="usage-from" type="datetime-local" step="1"></label>
<label class="usage-filter">结束时间<input id="usage-to" type="datetime-local" step="1"></label>
<label class="usage-filter">用户<select id="usage-key"><option value="">全部用户</option></select></label>
@@ -63,7 +63,7 @@
<label class="usage-filter request-filter">请求 ID<input id="usage-request-id" placeholder="粘贴完整 Request ID"></label>
<div class="usage-filter-actions"><button id="clear-usage-filters" type="button">清空</button><button id="apply-usage-filters" class="primary" type="button">查询</button></div>
</div></details>
<details><summary></summary><div id="column-options" class="column-options"></div></details><button id="refresh" type="button">刷新</button>
<details class="admin-only"><summary></summary><div id="column-options" class="column-options"></div></details><button id="refresh" class="admin-only" type="button">刷新</button>
</div></div>
<div class="surface"><div class="table-wrap"><table><thead><tr id="headers"></tr></thead><tbody id="rows"></tbody></table></div></div>
</section>
+9
View File
@@ -17,6 +17,15 @@ func (input *fakeInput) ServeHTTP(response http.ResponseWriter, request *http.Re
response.Header().Set("Content-Type", "application/json; charset=utf-8")
path := strings.TrimPrefix(request.URL.Path, managementBase)
if request.URL.Path == resourceUI {
path = "/" + strings.TrimSpace(request.URL.Query().Get("view"))
if path == "/usage" {
query := request.URL.Query()
query.Set("page", "1")
query.Set("page_size", "50")
request.URL.RawQuery = query.Encode()
}
}
var payload any
var err error
switch {
+3 -1
View File
@@ -7,6 +7,7 @@ import (
)
const managementBase = "/v0/management/plugins/billing"
const resourceUI = "/v0/resource/plugins/billing/ui"
type Input interface {
ServeHTTP(http.ResponseWriter, *http.Request)
@@ -18,10 +19,11 @@ func NewServer(input Input) http.Handler {
http.Redirect(response, request, "/ui", http.StatusTemporaryRedirect)
})
mux.HandleFunc("GET /ui", asset("text/html; charset=utf-8", web.UI()))
mux.HandleFunc("GET /ui-config.js", asset("text/javascript; charset=utf-8", []byte(`window.BILLING_UI_CONFIG = Object.freeze({managementBase:"/v0/management/plugins/billing",managementKey:"demo",lockManagementKey:true,hideManagementKey:true,demoPerspectives:true});`)))
mux.HandleFunc("GET /ui-config.js", asset("text/javascript; charset=utf-8", []byte(`window.BILLING_UI_CONFIG = Object.freeze({managementBase:"/v0/management/plugins/billing",readOnlyBase:"/v0/resource/plugins/billing/ui",managementKey:"demo",lockManagementKey:true,hideManagementKey:true,demoPerspectives:true});`)))
mux.HandleFunc("GET /styles/{name}", embeddedAsset("styles/"))
mux.HandleFunc("GET /app/{path...}", embeddedAsset("app/"))
mux.Handle(managementBase+"/", input)
mux.Handle(resourceUI, input)
return mux
}
+20 -1
View File
@@ -34,7 +34,7 @@ func TestServerServesSplitUIAndFakeInput(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(configBody), "demoPerspectives:true") {
if !strings.Contains(string(configBody), "demoPerspectives:true") || !strings.Contains(string(configBody), `readOnlyBase:"/v0/resource/plugins/billing/ui"`) {
t.Fatalf("demo config does not enable perspective switch: %s", configBody)
}
@@ -59,6 +59,25 @@ func TestServerServesSplitUIAndFakeInput(t *testing.T) {
}
}
response, err = http.Get(server.URL + resourceUI + "?view=usage&page=8&page_size=100")
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
var readOnlyUsage struct {
Records []fakeUsage `json:"records"`
Page struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
} `json:"pagination"`
}
if err := json.NewDecoder(response.Body).Decode(&readOnlyUsage); err != nil {
t.Fatal(err)
}
if len(readOnlyUsage.Records) != 50 || readOnlyUsage.Page.Page != 1 || readOnlyUsage.Page.PageSize != 50 {
t.Fatalf("unexpected read-only usage: records=%d page=%+v", len(readOnlyUsage.Records), readOnlyUsage.Page)
}
response, err = http.Get(server.URL + managementBase + "/usage?page=1&page_size=1")
if err != nil {
t.Fatal(err)