feat: 完善匿名只读管理台
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 条", "最多展示", "1–100 /", "一个 Key 对应一个用户", "查看请求结果、实际上游", "维护模型基础价格", "点击“管理”修改访问、路由与额度", "由 CPA 调度", `copy.textContent = "复制"`} {
|
||||
for _, removed := range []string{"下游 Keys", "最近用量记录", "显示 102 条", "最多展示", "1–100 /", "一个 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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user