From 2933d077b3edb7875cd2e0eae22fc23e3265e473 Mon Sep 17 00:00:00 2001 From: chuan Date: Wed, 19 Aug 2026 18:55:35 +0800 Subject: [PATCH] feat(usage): limit read-only request history --- internal/plugin/management_test.go | 13 ++++++++++--- internal/plugin/readonly_management.go | 3 ++- internal/web/app/core/runtime.js | 6 +++++- internal/web/app/features/usage.js | 24 ++++++++++++++++-------- internal/web/ui.html | 6 +++--- internal/webdemo/fake_handler.go | 9 +++++++++ internal/webdemo/server.go | 4 +++- internal/webdemo/server_test.go | 21 ++++++++++++++++++++- 8 files changed, 68 insertions(+), 18 deletions(-) diff --git a/internal/plugin/management_test.go b/internal/plugin/management_test.go index 5c84f34..32c64bf 100644 --- a/internal/plugin/management_test.go +++ b/internal/plugin/management_test.go @@ -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{``, `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{``, `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) } diff --git a/internal/plugin/readonly_management.go b/internal/plugin/readonly_management.go index 2d314b6..53dcef6 100644 --- a/internal/plugin/readonly_management.go +++ b/internal/plugin/readonly_management.go @@ -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 } diff --git a/internal/web/app/core/runtime.js b/internal/web/app/core/runtime.js index 78fd063..e9d7f69 100644 --- a/internal/web/app/core/runtime.js +++ b/internal/web/app/core/runtime.js @@ -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); diff --git a/internal/web/app/features/usage.js b/internal/web/app/features/usage.js index 58a2531..3c1ee6d 100644 --- a/internal/web/app/features/usage.js +++ b/internal/web/app/features/usage.js @@ -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) { diff --git a/internal/web/ui.html b/internal/web/ui.html index e5a70e4..dc4ad2f 100644 --- a/internal/web/ui.html +++ b/internal/web/ui.html @@ -51,8 +51,8 @@
-
-
查询过滤
+
+
查询过滤
@@ -63,7 +63,7 @@
-
+
diff --git a/internal/webdemo/fake_handler.go b/internal/webdemo/fake_handler.go index 283b712..521e0b0 100644 --- a/internal/webdemo/fake_handler.go +++ b/internal/webdemo/fake_handler.go @@ -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 { diff --git a/internal/webdemo/server.go b/internal/webdemo/server.go index b576ad6..89f8de4 100644 --- a/internal/webdemo/server.go +++ b/internal/webdemo/server.go @@ -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 } diff --git a/internal/webdemo/server_test.go b/internal/webdemo/server_test.go index 844697d..5f91527 100644 --- a/internal/webdemo/server_test.go +++ b/internal/webdemo/server_test.go @@ -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)