diff --git a/README.md b/README.md index 58539a6..a94dbe1 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,8 @@ CGO_ENABLED=1 go test ./... - 查看当前额度、已用、余额以及最近扣费账目; - 累计、今日和最近请求统计。 +管理页面资源可以直接反向代理。未填写 Management Key 时展示脱敏后的真实只读数据并隐藏全部修改入口;输入有效 Management Key 后才显示完整管理操作。匿名只读接口不会返回完整下游 Key,历史 Usage 的旧 `api_key` 字段也会被清空。只读展示仅需代理 `/v0/resource/plugins/billing/ui`(数据请求复用同一路径的查询参数);若要在外部页面执行管理操作,还需同源代理 `/v0/management/plugins/billing/*`。 + 严格绑定的账号不可用或不支持目标模型时请求直接失败,不回退到其他账号。新 Key 在创建时复制 `default` 当时的路由与模型规则,之后独立维护。 现有和新建 Key 的初始额度都是 `$0`,默认不自动重置、并发上限为 4。管理员分配额度后才能发起模型请求。金额以微美元整数保存;修改额度不会清空本周期已用金额,手动或自动重置不会结转旧余额。允许模型没有价格配置时,请求会在触达上游前以 503 拒绝。 diff --git a/TODOS.md b/TODOS.md index a466e7f..938055c 100644 --- a/TODOS.md +++ b/TODOS.md @@ -2,7 +2,7 @@ - [x] 持久化用量、价格与管理面板 - [x] 一个 Key 对应一个用户的认证、模型权限与上游路由 -- [x] 测试模式与真实数据隔离的紧凑管理台 +- [x] 匿名只读与密钥管理分离的紧凑管理台 - [x] 美元额度、周期重置、并发限制与不可变账目 - [x] 请求明细的服务端分页与筛选 - [x] models.dev 供应商参考价目录、显式导入与确认更新 diff --git a/docs/modules/admin-console.md b/docs/modules/admin-console.md index e4e5072..5ca7752 100644 --- a/docs/modules/admin-console.md +++ b/docs/modules/admin-console.md @@ -14,7 +14,7 @@ | 请求明细 | 分页查看请求结果、上游、Token、性能和成本,执行组合筛选 | | 价格配置 | 维护本地价格,并搜索、导入和确认更新 models.dev 参考价格 | -页面顶部统一提供实时/测试模式切换和 CPA 管理密钥输入。 +页面顶部保留 CPA 管理密钥输入。留空时页面进入匿名只读模式;输入有效密钥后进入管理模式。 ## 用户 Key 页面 @@ -29,7 +29,7 @@ Key 列表展示: - 自动或严格上游路由; - 允许的模型规则。 -管理员可以创建 Key、显示已归档项、刷新列表,并通过侧边抽屉完成以下操作: +管理员可以创建 Key,并通过侧边抽屉完成以下操作。列表默认不显示已归档项: - 修改名称和状态; - 选择自动路由或指定上游账号; @@ -44,7 +44,7 @@ Key 列表展示: ## 请求明细页面 -请求明细默认每页显示 100 条,页码、上一页、下一页、选择列和刷新操作位于同一顶部工具区。 +请求明细默认每页显示 100 条,页码、上一页、下一页、查询过滤、列和刷新操作位于同一顶部工具区。查询过滤默认折叠,按需展开。 当前筛选项包括: @@ -56,13 +56,13 @@ Key 列表展示: - Responses、Compact 或 Chat 端点; - 完整 Request ID。 -筛选和分页由服务端执行。应用新筛选会返回第一页;刷新保持当前页和筛选条件。只有页面可见、位于请求明细且处于第一页时,实时模式才每三秒自动刷新,避免用户查看历史页时列表自行跳动。 +筛选和分页由服务端执行。应用新筛选会返回第一页;刷新保持当前页和筛选条件。只有页面可见、位于请求明细且处于第一页时才每三秒自动刷新,避免用户查看历史页时列表自行跳动。 -“选择列”用于控制明细表可见字段,不改变服务端保存的数据。 +“列”用于控制明细表可见字段,不改变服务端保存的数据。 ## 价格配置页面 -价格页面采用左侧模型列表、右侧编辑区: +价格页面采用左侧模型列表、右侧编辑区。未选择模型时右侧留空,点击左侧任意模型项后加载价格详情: - 新增或选择一个精确模型名称; - 设置输入、缓存读取、缓存写入和输出的基础价格; @@ -79,30 +79,26 @@ Key 列表展示: 点击普通“保存价格”始终把该模型切换为手动配置。目录更新或下载失败不会改变本地价格,也不会影响用户请求。 -## 实时模式 +## 匿名只读模式 -实时模式连接当前 CLIProxyAPI 实例: +直接打开资源 URL 且不填写管理密钥时,页面读取当前 CLIProxyAPI 实例的真实数据: -- 需要输入 CPA Management Key; -- 所有读取来自真实管理接口; -- 创建、编辑、归档、重置和价格保存会修改真实数据; -- 请求明细和汇总反映真实请求事实。 +- 可以查看汇总、请求明细和已配置价格;完整用户 Key 管理表不展示; +- 可以执行分页、筛选、刷新和选择显示列; +- Key 只由服务端返回 `00******0000` 形式的脱敏值,不能复制完整 Key; +- 历史 Usage 中可能存在的旧 `api_key` 值在匿名响应中统一清空; +- 创建、管理、保存、删除、归档、重置、目录更新和参考价导入入口不显示。 -管理台资源本身可以被 CPA 加载,但敏感管理接口仍由 CLIProxyAPI 的管理认证保护。 +匿名读取使用单独的 GET-only 插件资源,不复用受保护的完整 Key 接口,也不提供任何修改方法。 -## 测试模式 +## 管理模式 -测试模式用于管理员熟悉页面和验证交互,与真实数据完全隔离: +输入有效 CPA Management Key 后,页面切换到受保护的 Management API: -- 不需要管理密钥; -- 不访问任何真实 billing 管理接口; -- 使用浏览器本地的模拟 Key、上游、请求、价格和账目; -- 使用浏览器本地的参考价格样例测试搜索和导入,不访问 models.dev; -- 创建、编辑、归档、重置和删除只修改本地模拟状态; -- 提供放大的请求记录量,便于测试分页、筛选和表格布局; -- 可以一键恢复初始测试数据。 - -测试数据保存在浏览器本地存储中,当前选择的实时/测试模式保存在当前会话。切换模式不会把测试数据导入真实数据库,也不会用真实数据覆盖测试样例。 +- 读取完整管理数据,脱敏 Key 可以点击复制完整值; +- 显示创建、管理、归档、额度重置、价格保存和目录更新等操作; +- 所有修改仍由 CLIProxyAPI 管理认证和 billing 服务端规则校验; +- 密钥无效时回退匿名只读数据,不额外显示状态标签。 ## 管理接口 @@ -134,6 +130,12 @@ Key 列表展示: /v0/resource/plugins/billing/ui ``` +匿名只读 JSON 复用同一个 GET-only 页面资源,页面通过 `view` 查询参数选择数据: + +```text +/v0/resource/plugins/billing/ui?view=keys +``` + 管理接口返回结构化 JSON 和明确的 HTTP 状态码;未知路径返回 `not_found`,数据库不可用、请求字段错误等情况返回对应错误码和消息。 ## 界面与数据原则 @@ -141,7 +143,7 @@ Key 列表展示: - 管理台不直接操作 SQLite,只调用公开管理接口。 - 统计卡片和图表使用服务端汇总,不从当前明细页推算。 - 业务校验由服务端完成,前端校验只用于尽早提示。 -- 完整 Key 仅由受管理认证保护的 Key 接口返回;页面列表脱敏显示,点击后复制完整值。 +- 完整 Key 仅由受管理认证保护的 Key 接口返回;匿名接口只返回服务端生成的脱敏值。 - 上游 Token、Cookie 和原始凭证不进入管理台。 - 界面保持单个嵌入资源,避免运行时依赖外部 CDN。 - 窄屏下表单和工具栏自动重排,明细表保留横向滚动能力。 @@ -152,13 +154,13 @@ Key 列表展示: - 提供统一管理页面; - 调用并呈现 billing Management API; -- 管理实时和测试数据模式; +- 提供匿名只读和密钥管理两种访问状态; - 提供紧凑、可筛选、可分页的操作界面。 本模块不负责: -- 绕过 CLIProxyAPI 管理认证; +- 允许匿名调用任何修改接口; - 在浏览器中直接执行业务扣费或访问控制; - 保存真实业务数据; -- 提供普通用户自助页面; +- 提供普通用户自助修改能力; - 提供登录、角色和多管理员权限体系。 diff --git a/docs/modules/user-access-management.md b/docs/modules/user-access-management.md index ae58a21..333f45b 100644 --- a/docs/modules/user-access-management.md +++ b/docs/modules/user-access-management.md @@ -36,7 +36,7 @@ | 上游账号 | 严格路由时绑定的 CPA 上游账号 | | 模型规则 | 允许全部模型,或一组模型匹配规则 | -Key 值允许 1–256 个非空白、非控制字符。未手动填写时,系统生成以 `cpa_` 开头的随机值。 +Key 值允许 6–256 个非空白、非控制字符。未手动填写时,系统生成以 `cpa_` 开头的随机值。 ## 状态规则 @@ -125,7 +125,7 @@ Key 值允许 1–256 个非空白、非控制字符。未手动填写时,系 - 根据历史请求和价格配置获得模型名称建议; - 查看该 Key 的累计、今日和最近使用记录。 -管理操作通过受 CLIProxyAPI Management Key 保护的管理接口完成。测试模式使用独立模拟数据,不读取或修改这里描述的真实 Key 和路由配置。 +管理操作通过受 CLIProxyAPI Management Key 保护的管理接口完成。匿名页面只能读取服务端脱敏后的真实数据,不返回完整 Key,也不能创建、修改、重置或归档。 ## 模块边界 diff --git a/internal/plugin/key_management.go b/internal/plugin/key_management.go index bdc8413..d7ee374 100644 --- a/internal/plugin/key_management.go +++ b/internal/plugin/key_management.go @@ -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 { diff --git a/internal/plugin/management.go b/internal/plugin/management.go index 3188c83..a1eb9e5 100644 --- a/internal/plugin/management.go +++ b/internal/plugin/management.go @@ -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{ diff --git a/internal/plugin/management_test.go b/internal/plugin/management_test.go index 08e3914..57f9e6e 100644 --- a/internal/plugin/management_test.go +++ b/internal/plugin/management_test.go @@ -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{``, ``, `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, "