feat: 完善匿名只读管理台

This commit is contained in:
chuan
2026-08-16 00:04:01 +08:00
parent 6775b64f1a
commit daf7e4ce61
9 changed files with 370 additions and 357 deletions
+1 -6
View File
@@ -280,12 +280,7 @@ func billingSettings(request billingSettingsRequest) (managedaccess.BillingSetti
}
func managedKeyResponse(key managedaccess.ManagedKey, state managedaccess.BillingState) managedKeyDTO {
return managedKeyDTO{ManagedKey: key, Billing: billingStateDTO{
QuotaUSD: formatMicros(state.QuotaMicros), SpentUSD: formatMicros(state.SpentMicros),
BalanceUSD: formatMicros(state.BalanceMicros), ResetPeriod: state.ResetPeriod,
NextResetAt: state.NextResetAt, MaxConcurrency: state.MaxConcurrency,
ActiveRequests: state.ActiveRequests, CycleStartedAt: state.CycleStartedAt,
}}
return managedKeyDTO{ManagedKey: key, Billing: billingStateResponse(state)}
}
func (a *App) resetManagedKeyBilling(body []byte) ManagementResponse {
+3
View File
@@ -68,6 +68,9 @@ func (a *App) handleManagement(raw []byte) ([]byte, error) {
}
path := strings.TrimRight(req.Path, "/")
if req.Method == http.MethodGet && path == resourceBase+resourceUI {
if strings.TrimSpace(req.Query.Get("view")) != "" {
return OKEnvelope(a.readOnlyResponse(req.Query))
}
return OKEnvelope(ManagementResponse{
StatusCode: http.StatusOK,
Headers: http.Header{
+45 -4
View File
@@ -62,6 +62,47 @@ func TestManagementRegistrationDeclaresUsageAPIAndUI(t *testing.T) {
}
}
func TestReadOnlyResourceReturnsRealDataWithoutSecrets(t *testing.T) {
app := NewApp()
defer app.Shutdown()
if _, err := app.HandleMethod(MethodPluginRegister, lifecycleRequest(t, SchemaVersion, testConfig(t, "enabled: true\ncodex_only: false\n"))); err != nil {
t.Fatal(err)
}
keys := managementCallRequest(t, app, ManagementRequest{
Method: http.MethodGet,
Path: resourceBase + resourceUI,
Query: url.Values{"view": {"keys"}},
})
if keys.StatusCode != http.StatusOK || !strings.Contains(string(keys.Body), `"masked_secret":"00******0000"`) {
t.Fatalf("unexpected read-only keys: status=%d body=%s", keys.StatusCode, keys.Body)
}
if strings.Contains(string(keys.Body), `"secret":`) {
t.Fatalf("read-only response exposed a complete secret: %s", keys.Body)
}
usageRaw, _ := json.Marshal(UsageRecord{Provider: "openai", Model: "gpt-test", APIKey: "000000", RequestedAt: time.Now(), Detail: UsageDetail{TotalTokens: 1}})
if _, err := app.HandleMethod(MethodUsageHandle, usageRaw); err != nil {
t.Fatal(err)
}
usage := managementCallRequest(t, app, ManagementRequest{
Method: http.MethodGet,
Path: resourceBase + resourceUI,
Query: url.Values{"view": {"usage"}},
})
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)
}
invalid := managementCallRequest(t, app, ManagementRequest{
Method: http.MethodGet,
Path: resourceBase + resourceUI,
Query: url.Values{"view": {"missing"}},
})
if invalid.StatusCode != http.StatusBadRequest || !strings.Contains(string(invalid.Body), `"code":"invalid_view"`) {
t.Fatalf("unexpected invalid view response: status=%d body=%s", invalid.StatusCode, invalid.Body)
}
}
func TestModelsDevCatalogRequiresExplicitImportAndConfirmedRefresh(t *testing.T) {
catalogBody := `{"providers":{"openai":{"id":"openai","name":"OpenAI","models":{"gpt-5.6-sol":{"id":"gpt-5.6-sol","name":"GPT-5.6 Sol","cost":{"input":5,"output":30,"cache_read":0.5,"cache_write":6.25}}}},"free":{"id":"free","name":"Free","models":{"placeholder":{"id":"placeholder","cost":{"input":0,"output":0}}}}}}`
serverStatus := http.StatusOK
@@ -532,7 +573,7 @@ func TestUsageResourceServesTablePage(t *testing.T) {
t.Fatalf("UI does not contain managed access feature %q", feature)
}
}
for _, feature := range []string{"测试模式", `data-mode="demo"`, `id="page-buttons"`, "const PAGE_SIZE = 100", "length: 2370", `id="user-usage"`, `id="daily-chart"`, `id="usage-from"`, `id="usage-request-id"`, `id="apply-usage-filters"`, "SUMMARY_API", `overflow-y: hidden`, "测试模式禁止访问真实管理接口", `id="editor-quota"`, `id="editor-reset-period"`, `id="billing-ledger"`, "deepseek-*", "请求结束后按实际费用扣款"} {
for _, feature := range []string{`<body class="read-only">`, `<input id="key" type="password" autocomplete="off" aria-label="管理密钥">`, `id="page-buttons"`, "const PAGE_SIZE = 100", `id="user-usage"`, `id="daily-chart"`, `id="usage-filter-panel" class="usage-filter-panel"`, `id="usage-from"`, `id="usage-request-id"`, `id="apply-usage-filters"`, "SUMMARY_API", "READONLY_API", "dataFetch", "managementAuthorized", `overflow-y: hidden`, `id="editor-quota"`, `id="editor-reset-period"`, `id="billing-ledger"`, "deepseek-*", "请求结束后按实际费用扣款", `id="key-management" class="surface admin-only"`, `class="price-section admin-only"`, `id="price-editor-content" class="hidden"`, `row.setAttribute("role", "button")`, `id="long-price-section"`, "syncLongSectionVisibility"} {
if !strings.Contains(page, feature) {
t.Fatalf("UI does not contain workspace feature %q", feature)
}
@@ -540,12 +581,12 @@ func TestUsageResourceServesTablePage(t *testing.T) {
if strings.Contains(page, "<script src=") || strings.Contains(page, "<link rel=\"stylesheet\" href=") {
t.Fatal("UI unexpectedly depends on external assets")
}
for _, removed := range []string{"下游 Keys", "最近用量记录", "显示 102 条", "最多展示", "1100 /", "一个 Key 对应一个用户", "查看请求结果、实际上游", "维护模型基础价格", "点击“管理”修改访问、路由与额度", "由 CPA 调度", `copy.textContent = "复制"`} {
for _, removed := range []string{"下游 Keys", "最近用量记录", "显示 102 条", "最多展示", "1100 /", "一个 Key 对应一个用户", "查看请求结果、实际上游", "维护模型基础价格", "点击“管理”修改访问、路由与额度", "由 CPA 调度", `copy.textContent = "复制"`, "测试模式", `data-mode="demo"`, "DEMO_STORE_KEY", "demoState", "mode-button", "测试目录 · 不访问 models.dev", `id="access-mode"`, `placeholder="留空时只读"`, `label for="key"`, "密钥无效 · 只读", `id="include-archived"`, `id="reload-keys"`} {
if strings.Contains(page, removed) {
t.Fatalf("UI still contains removed description %q", removed)
}
}
for _, feature := range []string{`minlength="6"`, `maskedSecret(key.secret)`, `className = "credential-copy"`, `return "自由选择"`} {
for _, feature := range []string{`minlength="6"`, `maskedSecret(key.secret)`, `key.masked_secret`, `className = "credential-copy"`, `return "自由选择"`, `body.classList.toggle("read-only"`} {
if !strings.Contains(page, feature) {
t.Fatalf("UI does not contain compact Key display feature %q", feature)
}
@@ -553,7 +594,7 @@ func TestUsageResourceServesTablePage(t *testing.T) {
if strings.Index(page, `id="page-buttons"`) > strings.Index(page, `id="headers"`) {
t.Fatal("UI pagination controls are not above the usage table")
}
for _, feature := range []string{`class="surface price-panel price-layout"`, `id="new-price"`, "基础价格 · $ / 1M Token", "长上下文价格", "Fast 价格", "删除 ${model} 的真实价格配置", `id="catalog-query"`, `id="refresh-catalog"`, "PRICE_IMPORT_API", "发现 ${changed.length} 个已关联价格发生变化", "测试目录 · 不访问 models.dev"} {
for _, feature := range []string{`class="surface price-panel price-layout"`, `id="new-price"`, "基础价格 · $ / 1M Token", "长上下文价格", "Fast 价格", "删除 ${model} 的真实价格配置", `id="catalog-query"`, `id="refresh-catalog"`, "PRICE_IMPORT_API", "发现 ${changed.length} 个已关联价格发生变化"} {
if !strings.Contains(page, feature) {
t.Fatalf("UI does not contain redesigned pricing feature %q", feature)
}
+132
View File
@@ -0,0 +1,132 @@
package plugin
import (
"context"
"encoding/json"
"net/http"
"net/url"
"strings"
"time"
managedaccess "billing/internal/access"
)
type readOnlyManagedKeyDTO struct {
ID string `json:"id"`
Name string `json:"name"`
MaskedSecret string `json:"masked_secret"`
Status string `json:"status"`
RouteMode string `json:"route_mode"`
UpstreamAccountID string `json:"upstream_account_id,omitempty"`
AllModels bool `json:"all_models"`
Models []string `json:"models"`
Billing billingStateDTO `json:"billing"`
}
func (a *App) readOnlyResponse(query url.Values) ManagementResponse {
switch strings.TrimSpace(query.Get("view")) {
case "keys":
return a.readOnlyManagedKeys(query.Get("include_archived") == "1")
case "usage":
return a.readOnlyUsageResponse(query)
case "usage-summary":
return a.usageDashboardResponse()
case "prices":
return a.listPrices()
case "price-catalog":
return a.searchPriceCatalog(query)
case "upstreams":
return a.readOnlyUpstreamAccounts()
case "model-suggestions":
return a.modelSuggestions()
case "key-stats":
return a.readOnlyManagedKeyStats(strings.TrimSpace(query.Get("id")))
default:
return managementError(http.StatusBadRequest, "invalid_view", "只读资源类型不存在")
}
}
func (a *App) readOnlyUsageResponse(query url.Values) ManagementResponse {
response := a.usageResponse(query)
if response.StatusCode != http.StatusOK {
return response
}
var payload usageListResponse
if err := json.Unmarshal(response.Body, &payload); err != nil {
return managementError(http.StatusInternalServerError, "response_error", "无法生成只读请求明细")
}
redactUsageItems(payload.Records)
return jsonManagementResponse(http.StatusOK, payload)
}
func (a *App) readOnlyManagedKeyStats(id string) ManagementResponse {
response := a.managedKeyStats(id)
if response.StatusCode != http.StatusOK {
return response
}
var payload managedKeyStatsResponse
if err := json.Unmarshal(response.Body, &payload); err != nil {
return managementError(http.StatusInternalServerError, "response_error", "无法生成只读 Key 统计")
}
redactUsageItems(payload.Recent)
return jsonManagementResponse(http.StatusOK, payload)
}
func redactUsageItems(items []usageListItem) {
for index := range items {
items[index].APIKey = ""
}
}
func (a *App) readOnlyManagedKeys(includeArchived bool) ManagementResponse {
store, ok := a.currentStore()
if !ok {
return managementError(http.StatusServiceUnavailable, "database_unavailable", "Key 数据库尚未初始化")
}
keys, err := store.ListManagedKeys(context.Background(), includeArchived)
if err != nil {
return managementError(http.StatusInternalServerError, "database_error", err.Error())
}
items := make([]readOnlyManagedKeyDTO, 0, len(keys))
for _, key := range keys {
state, stateErr := store.BillingState(context.Background(), key.ID, time.Now().UTC())
if stateErr != nil {
return managementError(http.StatusInternalServerError, "database_error", stateErr.Error())
}
items = append(items, readOnlyManagedKeyDTO{
ID: key.ID, Name: key.Name, MaskedSecret: maskManagedSecret(key.Secret), Status: key.Status,
RouteMode: key.RouteMode, UpstreamAccountID: key.UpstreamAccountID,
AllModels: key.AllModels, Models: key.Models, Billing: billingStateResponse(state),
})
}
return jsonManagementResponse(http.StatusOK, map[string]any{"keys": items})
}
func (a *App) readOnlyUpstreamAccounts() ManagementResponse {
store, ok := a.currentStore()
if !ok {
return managementError(http.StatusServiceUnavailable, "database_unavailable", "上游账号数据库尚未初始化")
}
accounts, err := store.ListUpstreamAccounts(context.Background())
if err != nil {
return managementError(http.StatusInternalServerError, "database_error", err.Error())
}
return jsonManagementResponse(http.StatusOK, map[string]any{"accounts": accounts})
}
func maskManagedSecret(secret string) string {
characters := []rune(secret)
if len(characters) < 6 {
return "******"
}
return string(characters[:2]) + "******" + string(characters[len(characters)-4:])
}
func billingStateResponse(state managedaccess.BillingState) billingStateDTO {
return billingStateDTO{
QuotaUSD: formatMicros(state.QuotaMicros), SpentUSD: formatMicros(state.SpentMicros),
BalanceUSD: formatMicros(state.BalanceMicros), ResetPeriod: state.ResetPeriod,
NextResetAt: state.NextResetAt, MaxConcurrency: state.MaxConcurrency,
ActiveRequests: state.ActiveRequests, CycleStartedAt: state.CycleStartedAt,
}
}