feat: add account quota endpoint
This commit is contained in:
@@ -129,6 +129,8 @@ CGO_ENABLED=1 go test ./...
|
||||
|
||||
管理页面资源可以直接反向代理。未填写 Management Key 时展示脱敏后的真实只读数据并隐藏全部修改入口;输入有效 Management Key 后才显示完整管理操作。匿名只读接口不会返回完整下游 Key,历史 Usage 的旧 `api_key` 字段也会被清空。只读展示仅需代理 `/v0/resource/plugins/billing/ui`(数据请求复用同一路径的查询参数);若要在外部页面执行管理操作,还需同源代理 `/v0/management/plugins/billing/*`。
|
||||
|
||||
下游用户可通过 `GET /v0/resource/plugins/billing/account` 自查账户,使用 `Authorization: Bearer <key>` 或 `X-Api-Key: <key>` 认证。接口返回用户名、当前周期额度/已用/剩余、刷新规则与时间、实时并发和模型权限,不返回完整 Key 或内部路由信息;完整字段见 [`docs/modules/user-access-management.md`](docs/modules/user-access-management.md#用户自查接口)。
|
||||
|
||||
严格绑定的账号不可用或不支持目标模型时请求直接失败,不回退到其他账号。新 Key 在创建时复制 `default` 当时的路由与模型规则,之后独立维护。
|
||||
|
||||
现有和新建 Key 的初始额度都是 `$0`,默认不自动重置、并发上限为 4。管理员分配额度后才能发起模型请求。金额以微美元整数保存;修改额度不会清空本周期已用金额,手动或自动重置不会结转旧余额。允许模型没有价格配置时,请求会在触达上游前以 503 拒绝。
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
| Key 创建 | 支持自动生成或手动指定 Key 值 |
|
||||
| Key 状态 | 支持启用、禁用和归档 |
|
||||
| 下游认证 | 支持 `Authorization: Bearer <key>` 和 `X-Api-Key` |
|
||||
| 用户自查 | 有效 Key 可读取自己的额度、刷新规则、并发和模型权限 |
|
||||
| 模型权限 | 支持允许全部模型、精确模型名和 `*` 通配符 |
|
||||
| 上游路由 | 支持 CPA 自动选择或严格绑定一个上游账号 |
|
||||
| 默认迁移 | 首次启动自动创建 `default` Key,兼容现有调用配置 |
|
||||
@@ -112,6 +113,47 @@ Key 值允许 6–256 个非空白、非控制字符。未手动填写时,系
|
||||
|
||||
额度、并发和价格检查发生在同一条请求准入链路中,但分别属于“额度与计费”和“价格配置”模块,不在本文展开。
|
||||
|
||||
## 用户自查接口
|
||||
|
||||
下游用户可使用自己的 Key 查询当前账户状态:
|
||||
|
||||
```http
|
||||
GET /v0/resource/plugins/billing/account
|
||||
Authorization: Bearer <key>
|
||||
```
|
||||
|
||||
也可改用 `X-Api-Key: <key>`。Key 只允许放在请求头中,不接受 URL 查询参数或请求体。成功响应示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"username": "Alice",
|
||||
"billing": {
|
||||
"quota_usd": "10.000000",
|
||||
"spent_usd": "9.764502",
|
||||
"balance_usd": "0.235498"
|
||||
},
|
||||
"reset": {
|
||||
"period": "monthly",
|
||||
"next_reset_at": "2026-09-01T00:00:00+08:00",
|
||||
"cycle_started_at": "2026-08-01T00:00:00+08:00",
|
||||
"timezone": "Asia/Shanghai"
|
||||
},
|
||||
"concurrency": {
|
||||
"active": 1,
|
||||
"limit": 4
|
||||
},
|
||||
"model_access": {
|
||||
"all_models": false,
|
||||
"models": ["deepseek-*", "gpt-5.6-sol"]
|
||||
},
|
||||
"as_of": "2026-08-22T23:10:00+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
金额使用字符串和固定 6 位小数,避免 JSON 浮点误差。`period` 为 `none`、`daily`、`weekly` 或 `monthly`;不自动刷新时 `next_reset_at` 为 `null`。读取接口会先执行已经到期的自动重置,因此返回的是实时周期状态。
|
||||
|
||||
响应始终带 `Cache-Control: no-store`。缺少 Key、Key 错误、已禁用或已归档都返回相同的 HTTP 401;数据库未就绪或暂时不可读返回 HTTP 503。接口不会返回 Key 值、内部 Key ID、上游账号、累计消费或管理状态。
|
||||
|
||||
## 管理操作
|
||||
|
||||
管理员页面当前支持:
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
managedaccess "billing/internal/access"
|
||||
"billing/internal/repository"
|
||||
)
|
||||
|
||||
type accountBillingDTO struct {
|
||||
QuotaUSD string `json:"quota_usd"`
|
||||
SpentUSD string `json:"spent_usd"`
|
||||
BalanceUSD string `json:"balance_usd"`
|
||||
}
|
||||
|
||||
type accountResetDTO struct {
|
||||
Period string `json:"period"`
|
||||
NextResetAt *time.Time `json:"next_reset_at"`
|
||||
CycleStartedAt time.Time `json:"cycle_started_at"`
|
||||
Timezone string `json:"timezone"`
|
||||
}
|
||||
|
||||
type accountConcurrencyDTO struct {
|
||||
Active int `json:"active"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
type accountModelAccessDTO struct {
|
||||
AllModels bool `json:"all_models"`
|
||||
Models []string `json:"models"`
|
||||
}
|
||||
|
||||
type accountResponseDTO struct {
|
||||
Username string `json:"username"`
|
||||
Billing accountBillingDTO `json:"billing"`
|
||||
Reset accountResetDTO `json:"reset"`
|
||||
Concurrency accountConcurrencyDTO `json:"concurrency"`
|
||||
ModelAccess accountModelAccessDTO `json:"model_access"`
|
||||
AsOf time.Time `json:"as_of"`
|
||||
}
|
||||
|
||||
func (a *App) accountResponse(headers http.Header) ManagementResponse {
|
||||
response := a.loadAccountResponse(headers, time.Now().UTC())
|
||||
if response.Headers == nil {
|
||||
response.Headers = make(http.Header)
|
||||
}
|
||||
response.Headers.Set("Cache-Control", "no-store")
|
||||
return response
|
||||
}
|
||||
|
||||
func (a *App) loadAccountResponse(headers http.Header, now time.Time) ManagementResponse {
|
||||
credential := requestCredential(headers)
|
||||
if credential == "" {
|
||||
return accountUnauthorized()
|
||||
}
|
||||
store, ok := a.currentStore()
|
||||
if !ok {
|
||||
return managementError(http.StatusServiceUnavailable, "database_unavailable", "账户服务暂不可用")
|
||||
}
|
||||
key, err := store.ManagedKeyByCredential(context.Background(), credential)
|
||||
if errors.Is(err, repository.ErrManagedKeyNotFound) || (err == nil && key.Status != managedaccess.StatusActive) {
|
||||
return accountUnauthorized()
|
||||
}
|
||||
if err != nil {
|
||||
return managementError(http.StatusServiceUnavailable, "database_unavailable", "账户服务暂不可用")
|
||||
}
|
||||
state, err := store.BillingState(context.Background(), key.ID, now)
|
||||
if err != nil {
|
||||
return managementError(http.StatusServiceUnavailable, "database_unavailable", "账户服务暂不可用")
|
||||
}
|
||||
models := append([]string(nil), key.Models...)
|
||||
if models == nil {
|
||||
models = []string{}
|
||||
}
|
||||
location := accountLocation()
|
||||
cycleStartedAt := state.CycleStartedAt.In(location)
|
||||
var nextResetAt *time.Time
|
||||
if state.NextResetAt != nil {
|
||||
value := state.NextResetAt.In(location)
|
||||
nextResetAt = &value
|
||||
}
|
||||
return jsonManagementResponse(http.StatusOK, accountResponseDTO{
|
||||
Username: key.Name,
|
||||
Billing: accountBillingDTO{
|
||||
QuotaUSD: formatAccountMicros(state.QuotaMicros), SpentUSD: formatAccountMicros(state.SpentMicros),
|
||||
BalanceUSD: formatAccountMicros(state.BalanceMicros),
|
||||
},
|
||||
Reset: accountResetDTO{
|
||||
Period: state.ResetPeriod, NextResetAt: nextResetAt,
|
||||
CycleStartedAt: cycleStartedAt, Timezone: "Asia/Shanghai",
|
||||
},
|
||||
Concurrency: accountConcurrencyDTO{Active: state.ActiveRequests, Limit: state.MaxConcurrency},
|
||||
ModelAccess: accountModelAccessDTO{AllModels: key.AllModels, Models: models},
|
||||
AsOf: now.In(location),
|
||||
})
|
||||
}
|
||||
|
||||
func accountUnauthorized() ManagementResponse {
|
||||
response := managementError(http.StatusUnauthorized, "unauthorized", "无效的访问凭证")
|
||||
if response.Headers == nil {
|
||||
response.Headers = make(http.Header)
|
||||
}
|
||||
response.Headers.Set("WWW-Authenticate", "Bearer")
|
||||
return response
|
||||
}
|
||||
|
||||
func formatAccountMicros(value int64) string {
|
||||
prefix := ""
|
||||
if value < 0 {
|
||||
prefix = "-"
|
||||
value = -value
|
||||
}
|
||||
return prefix + formatAccountWhole(value/1_000_000) + "." + formatAccountFraction(value%1_000_000)
|
||||
}
|
||||
|
||||
func formatAccountWhole(value int64) string {
|
||||
return strconv.FormatInt(value, 10)
|
||||
}
|
||||
|
||||
func accountLocation() *time.Location {
|
||||
location, err := time.LoadLocation("Asia/Shanghai")
|
||||
if err != nil {
|
||||
return time.FixedZone("Asia/Shanghai", 8*60*60)
|
||||
}
|
||||
return location
|
||||
}
|
||||
|
||||
func formatAccountFraction(value int64) string {
|
||||
const width = 6
|
||||
digits := [width]byte{}
|
||||
for index := width - 1; index >= 0; index-- {
|
||||
digits[index] = byte('0' + value%10)
|
||||
value /= 10
|
||||
}
|
||||
return string(digits[:])
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
managedaccess "billing/internal/access"
|
||||
)
|
||||
|
||||
func TestAccountResourceReturnsCurrentKeyDetails(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)
|
||||
}
|
||||
store, _ := app.currentStore()
|
||||
key, err := store.ManagedKeyByID(context.Background(), "key_default")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key.Name = "Alice"
|
||||
key.AllModels = false
|
||||
key.Models = []string{"gpt-5.6-sol", "deepseek-*"}
|
||||
if err := store.UpdateManagedKey(context.Background(), key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
nextReset := now.Add(24 * time.Hour)
|
||||
if _, err := store.UpdateBilling(context.Background(), key.ID, managedaccess.BillingSettings{
|
||||
QuotaMicros: 10_000_000, ResetPeriod: managedaccess.ResetDaily,
|
||||
NextResetAt: &nextReset, MaxConcurrency: 2,
|
||||
}, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
response := managementCallRequest(t, app, ManagementRequest{
|
||||
Method: http.MethodGet, Path: resourceBase + resourceAccount,
|
||||
Headers: http.Header{"Authorization": {"Bearer 000000"}},
|
||||
})
|
||||
if response.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", response.StatusCode, response.Body)
|
||||
}
|
||||
if response.Headers.Get("Cache-Control") != "no-store" {
|
||||
t.Fatalf("cache-control=%q", response.Headers.Get("Cache-Control"))
|
||||
}
|
||||
var payload accountResponseDTO
|
||||
if err := json.Unmarshal(response.Body, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload.Username != "Alice" || payload.Billing.QuotaUSD != "10.000000" || payload.Billing.SpentUSD != "0.000000" || payload.Billing.BalanceUSD != "10.000000" {
|
||||
t.Fatalf("unexpected account payload: %+v", payload)
|
||||
}
|
||||
if payload.Reset.Period != managedaccess.ResetDaily || payload.Reset.NextResetAt == nil || payload.Reset.Timezone != "Asia/Shanghai" {
|
||||
t.Fatalf("unexpected reset payload: %+v", payload.Reset)
|
||||
}
|
||||
if payload.Concurrency.Active != 0 || payload.Concurrency.Limit != 2 {
|
||||
t.Fatalf("unexpected concurrency payload: %+v", payload.Concurrency)
|
||||
}
|
||||
if payload.ModelAccess.AllModels || len(payload.ModelAccess.Models) != 2 {
|
||||
t.Fatalf("unexpected model access: %+v", payload.ModelAccess)
|
||||
}
|
||||
body := string(response.Body)
|
||||
for _, forbidden := range []string{`"secret":`, "key_default", `"lifetime_spent_usd":`, `"currency":`, `"status":`, `"route_mode":`, `"upstream_account_id":`} {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Fatalf("account response exposed %q: %s", forbidden, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountResourceAcceptsXAPIKeyAndAppliesDueReset(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)
|
||||
}
|
||||
store, _ := app.currentStore()
|
||||
anchor := time.Now().UTC().Add(-2 * time.Hour)
|
||||
nextReset := anchor.Add(time.Hour)
|
||||
if _, err := store.UpdateBilling(context.Background(), "key_default", managedaccess.BillingSettings{
|
||||
QuotaMicros: 5_000_000, ResetPeriod: managedaccess.ResetDaily,
|
||||
NextResetAt: &nextReset, MaxConcurrency: 4,
|
||||
}, anchor); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, err := store.BillingState(context.Background(), "key_default", anchor)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response := managementCallRequest(t, app, ManagementRequest{
|
||||
Method: http.MethodGet, Path: resourceBase + resourceAccount,
|
||||
Headers: http.Header{"X-Api-Key": {"000000"}},
|
||||
})
|
||||
if response.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", response.StatusCode, response.Body)
|
||||
}
|
||||
after, err := store.BillingState(context.Background(), "key_default", time.Now().UTC())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after.CycleSequence != before.CycleSequence+1 || !after.CycleStartedAt.Equal(nextReset) {
|
||||
t.Fatalf("automatic reset not applied: before=%+v after=%+v", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountResourceRejectsMissingInvalidAndInactiveKeysUniformly(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)
|
||||
}
|
||||
|
||||
call := func(headers http.Header) ManagementResponse {
|
||||
return managementCallRequest(t, app, ManagementRequest{Method: http.MethodGet, Path: resourceBase + resourceAccount, Headers: headers})
|
||||
}
|
||||
missing := call(nil)
|
||||
invalid := call(http.Header{"Authorization": {"Bearer not-a-key"}})
|
||||
store, _ := app.currentStore()
|
||||
key, _ := store.ManagedKeyByID(context.Background(), "key_default")
|
||||
key.Status = managedaccess.StatusDisabled
|
||||
if err := store.UpdateManagedKey(context.Background(), key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
disabled := call(http.Header{"Authorization": {"Bearer 000000"}})
|
||||
key.Status = managedaccess.StatusActive
|
||||
if err := store.UpdateManagedKey(context.Background(), key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.ArchiveManagedKey(context.Background(), key.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
archived := call(http.Header{"Authorization": {"Bearer 000000"}})
|
||||
queryOnly := managementCallRequest(t, app, ManagementRequest{
|
||||
Method: http.MethodGet, Path: resourceBase + resourceAccount,
|
||||
Query: map[string][]string{"key": {"000000"}},
|
||||
})
|
||||
for name, response := range map[string]ManagementResponse{"missing": missing, "invalid": invalid, "disabled": disabled, "archived": archived, "query_only": queryOnly} {
|
||||
if response.StatusCode != http.StatusUnauthorized || response.Headers.Get("WWW-Authenticate") != "Bearer" || response.Headers.Get("Cache-Control") != "no-store" {
|
||||
t.Fatalf("%s response=%+v body=%s", name, response, response.Body)
|
||||
}
|
||||
if string(response.Body) != string(missing.Body) {
|
||||
t.Fatalf("%s body differs: %s != %s", name, response.Body, missing.Body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountResourceReturnsServiceUnavailableBeforeConfiguration(t *testing.T) {
|
||||
response := managementCallRequest(t, NewApp(), ManagementRequest{
|
||||
Method: http.MethodGet, Path: resourceBase + resourceAccount,
|
||||
Headers: http.Header{"Authorization": {"Bearer 000000"}},
|
||||
})
|
||||
if response.StatusCode != http.StatusServiceUnavailable || response.Headers.Get("Cache-Control") != "no-store" || !strings.Contains(string(response.Body), `"code":"database_unavailable"`) {
|
||||
t.Fatalf("response=%+v body=%s", response, response.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatAccountMicros(t *testing.T) {
|
||||
for value, want := range map[int64]string{0: "0.000000", 1: "0.000001", 235_498: "0.235498", 10_000_000: "10.000000", -1_250_000: "-1.250000"} {
|
||||
if got := formatAccountMicros(value); got != want {
|
||||
t.Fatalf("formatAccountMicros(%d)=%q want %q", value, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ const (
|
||||
routeBillingLedger = "/billing-ledger"
|
||||
routeEvents = "/events"
|
||||
resourceUI = "/ui"
|
||||
resourceAccount = "/account"
|
||||
)
|
||||
|
||||
var resourceAssets = []string{
|
||||
@@ -81,6 +82,9 @@ func managementRegistration() ManagementRegistrationResponse {
|
||||
for _, path := range resourceAssets {
|
||||
registration.Resources = append(registration.Resources, ResourceRoute{Path: resourceBase + path})
|
||||
}
|
||||
registration.Resources = append(registration.Resources, ResourceRoute{
|
||||
Path: resourceBase + resourceAccount, Description: "使用下游 Key 查看自己的额度与访问设置。",
|
||||
})
|
||||
return registration
|
||||
}
|
||||
|
||||
@@ -90,6 +94,9 @@ func (a *App) handleManagement(raw []byte) ([]byte, error) {
|
||||
return nil, fmt.Errorf("解析管理请求: %w", err)
|
||||
}
|
||||
path := strings.TrimRight(req.Path, "/")
|
||||
if req.Method == http.MethodGet && path == resourceBase+resourceAccount {
|
||||
return OKEnvelope(a.accountResponse(req.Headers))
|
||||
}
|
||||
if req.Method == http.MethodGet && path == resourceBase+resourceUI {
|
||||
if strings.TrimSpace(req.Query.Get("view")) != "" {
|
||||
return OKEnvelope(a.readOnlyResponse(req.Query))
|
||||
|
||||
@@ -57,7 +57,7 @@ func TestManagementRegistrationDeclaresUsageAPIAndUI(t *testing.T) {
|
||||
if len(registration.Routes) != 19 || registration.Routes[0].Path != managementBase+routeUsage || registration.Routes[1].Path != managementBase+routeUsageSummary || registration.Routes[2].Path != managementBase+routePrices || registration.Routes[18].Path != managementBase+routeEvents {
|
||||
t.Fatalf("unexpected management routes: %+v", registration.Routes)
|
||||
}
|
||||
if len(registration.Resources) != 1+len(resourceAssets) || registration.Resources[0].Path != resourceBase+resourceUI {
|
||||
if len(registration.Resources) != 2+len(resourceAssets) || registration.Resources[0].Path != resourceBase+resourceUI {
|
||||
t.Fatalf("unexpected resource routes: %+v", registration.Resources)
|
||||
}
|
||||
for index, path := range resourceAssets {
|
||||
@@ -65,6 +65,9 @@ func TestManagementRegistrationDeclaresUsageAPIAndUI(t *testing.T) {
|
||||
t.Fatalf("resource %d = %q, want %q", index+1, registration.Resources[index+1].Path, resourceBase+path)
|
||||
}
|
||||
}
|
||||
if got := registration.Resources[len(registration.Resources)-1].Path; got != resourceBase+resourceAccount {
|
||||
t.Fatalf("account resource = %q, want %q", got, resourceBase+resourceAccount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBusinessEventsPersistSuccessfulAndFailedManagementActions(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user