feat: 添加用户额度与请求明细管理
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
|
||||
`cpa-ext` 是 CLIProxyAPI 的收敛式核心扩展,提供持久化用量与价格展示、下游 Key 管理、模型准入和上游凭证定向路由。一个 Key 代表一个调用用户;管理员可以创建、禁用或永久归档 Key,并按 Key 查看统计。
|
||||
|
||||
每个 Key 同时拥有独立的美元额度账户。管理员可以分配本周期额度、设置日/周/月自动重置和并发上限,并查看不可变的扣费、额度调整与重置账目。额度采用请求结束后结算的软限制:余额为正时放行,实际 Usage 到达后扣费,最后一个或多个在途请求可能产生负余额,之后的新请求返回 429。
|
||||
|
||||
## 当前兼容目标
|
||||
|
||||
- CLIProxyAPI 源码:`CLIProxyAPI/`,检查时 revision 为 `f43aad7637ad813745bf7d341acb5663617570c5`
|
||||
@@ -51,12 +53,23 @@ go test ./...
|
||||
- `active` / `disabled` 状态切换,以及不可恢复但保留历史的归档;
|
||||
- CPA 自动选择上游,或严格绑定一个 OAuth/API Key 上游凭证;
|
||||
- 按客户端请求模型配置允许列表;
|
||||
- 模型允许列表支持精确名称和 `*` 通配符,例如 `deepseek-*`;
|
||||
- 分配美元额度、设置自动重置时间和每用户并发上限;
|
||||
- 查看当前额度、已用、余额以及最近扣费账目;
|
||||
- 累计、今日和最近请求统计。
|
||||
|
||||
严格绑定的账号不可用或不支持目标模型时请求直接失败,不回退到其他账号。新 Key 在创建时复制 `default` 当时的路由与模型规则,之后独立维护。
|
||||
|
||||
现有和新建 Key 的初始额度都是 `$0`,默认不自动重置、并发上限为 4。管理员分配额度后才能发起模型请求。金额以微美元整数保存;修改额度不会清空本周期已用金额,手动或自动重置不会结转旧余额。允许模型没有价格配置时,请求会在触达上游前以 503 拒绝。
|
||||
|
||||
`config_yaml` 中包含宿主补充的 `enabled` 和 `priority`;插件会解析 `codex_only`、数据库路径和首次导入 Key。完整下游 Key 只通过受 CPA Management Key 保护的 Key 管理接口返回,不写入普通日志或错误消息;上游 Token、Cookie 和原始凭证不会由插件读取或保存。
|
||||
|
||||
## 请求明细与汇总
|
||||
|
||||
请求明细由服务端分页,每页 100 条,默认按请求时间和稳定 ID 倒序展示。管理接口支持时间范围、用户 Key、模型、结果、上游 Auth ID、端点和完整 Request ID 筛选;连续翻页使用与筛选条件绑定的游标,数字页码跳转使用 SQLite 索引定位。用户页的今日汇总、各用户用量和近 7 日 Token 由数据库独立聚合,不受当前明细页影响。
|
||||
|
||||
升级已有数据库时,插件会从原始 Usage 和请求终态事实自动建立轻量查询投影。事实表、计费账目和历史统计保持不变;重试执行继续分别展示,没有 Usage 的取消或失败请求仍然可见,缺少 Request ID 的旧回调沿用双向唯一时间匹配规则。
|
||||
|
||||
## 工程布局
|
||||
|
||||
- `cmd/cpa-ext`:仅负责 C ABI、请求字节复制和 C 内存释放。
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# 计划
|
||||
|
||||
- [x] 持久化用量、价格与管理面板
|
||||
- [x] 一个 Key 对应一个用户的认证、模型权限与上游路由
|
||||
- [x] 测试模式与真实数据隔离的紧凑管理台
|
||||
- [x] 美元额度、周期重置、并发限制与不可变账目
|
||||
- [x] 请求明细的服务端分页与筛选
|
||||
|
||||
@@ -15,6 +15,11 @@ const (
|
||||
|
||||
RouteAuto = "auto"
|
||||
RouteStrict = "strict"
|
||||
|
||||
ResetNone = "none"
|
||||
ResetDaily = "daily"
|
||||
ResetWeekly = "weekly"
|
||||
ResetMonthly = "monthly"
|
||||
)
|
||||
|
||||
type ManagedKey struct {
|
||||
@@ -59,6 +64,40 @@ type UsageSummary struct {
|
||||
CostMicros int64 `json:"cost_micros"`
|
||||
}
|
||||
|
||||
type BillingState struct {
|
||||
KeyID string `json:"key_id"`
|
||||
QuotaMicros int64 `json:"-"`
|
||||
SpentMicros int64 `json:"-"`
|
||||
BalanceMicros int64 `json:"-"`
|
||||
ResetPeriod string `json:"reset_period"`
|
||||
NextResetAt *time.Time `json:"next_reset_at,omitempty"`
|
||||
MaxConcurrency int `json:"max_concurrency"`
|
||||
ActiveRequests int `json:"active_requests"`
|
||||
CycleSequence int64 `json:"cycle_sequence"`
|
||||
CycleStartedAt time.Time `json:"cycle_started_at"`
|
||||
}
|
||||
|
||||
type BillingSettings struct {
|
||||
QuotaMicros int64
|
||||
ResetPeriod string
|
||||
NextResetAt *time.Time
|
||||
MaxConcurrency int
|
||||
}
|
||||
|
||||
type LedgerEntry struct {
|
||||
ID int64 `json:"id"`
|
||||
KeyID string `json:"key_id"`
|
||||
CycleSequence int64 `json:"cycle_sequence"`
|
||||
Kind string `json:"kind"`
|
||||
AmountMicros int64 `json:"-"`
|
||||
BalanceAfterMicros int64 `json:"-"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
ExecutionID string `json:"execution_id,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// CallerScope mirrors CLIProxyAPI's stable downstream principal namespace.
|
||||
func CallerScope(principal string) string {
|
||||
principal = strings.TrimSpace(principal)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package collection
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
UsageResultSucceeded = "succeeded"
|
||||
UsageResultFailed = "failed"
|
||||
UsageResultRejected = "rejected"
|
||||
UsageResultCanceled = "canceled"
|
||||
)
|
||||
|
||||
// UsageQuery describes one stable page of request details. Cursor is opaque to
|
||||
// callers and is bound to every filter in the query.
|
||||
type UsageQuery struct {
|
||||
Page int
|
||||
PageSize int
|
||||
Cursor string
|
||||
From *time.Time
|
||||
To *time.Time
|
||||
KeyID string
|
||||
Model string
|
||||
Result string
|
||||
AuthID string
|
||||
Endpoint string
|
||||
RequestID string
|
||||
}
|
||||
|
||||
type UsagePage struct {
|
||||
Records []Record
|
||||
Page int
|
||||
PageSize int
|
||||
Total int64
|
||||
TotalPages int
|
||||
PreviousCursor string
|
||||
NextCursor string
|
||||
}
|
||||
|
||||
type UsageDashboard struct {
|
||||
Today UsageSummary
|
||||
Users []UserUsageSummary
|
||||
Days []DailyUsageSummary
|
||||
}
|
||||
|
||||
type UsageSummary struct {
|
||||
Requests int64
|
||||
InputTokens int64
|
||||
OutputTokens int64
|
||||
TotalTokens int64
|
||||
CostMicros int64
|
||||
}
|
||||
|
||||
type UserUsageSummary struct {
|
||||
KeyID string
|
||||
KeyAlias string
|
||||
Today UsageSummary
|
||||
LastUsedAt *time.Time
|
||||
}
|
||||
|
||||
type DailyUsageSummary struct {
|
||||
Date time.Time
|
||||
TotalTokens int64
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
package collection
|
||||
|
||||
import "context"
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Repository 是采集模块需要的最小持久化接口。
|
||||
type Repository interface {
|
||||
Insert(context.Context, Record) error
|
||||
UpsertRequest(context.Context, RequestRecord) error
|
||||
ListRecent(context.Context, int) ([]Record, error)
|
||||
QueryUsage(context.Context, UsageQuery) (UsagePage, error)
|
||||
UsageDashboard(context.Context, time.Time, int) (UsageDashboard, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
@@ -31,6 +36,14 @@ func (s *Service) Recent(ctx context.Context, limit int) ([]Record, error) {
|
||||
return s.repository.ListRecent(ctx, limit)
|
||||
}
|
||||
|
||||
func (s *Service) Query(ctx context.Context, query UsageQuery) (UsagePage, error) {
|
||||
return s.repository.QueryUsage(ctx, query)
|
||||
}
|
||||
|
||||
func (s *Service) Dashboard(ctx context.Context, today time.Time, days int) (UsageDashboard, error) {
|
||||
return s.repository.UsageDashboard(ctx, today, days)
|
||||
}
|
||||
|
||||
func (s *Service) Close() error {
|
||||
return s.repository.Close()
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"time"
|
||||
|
||||
managedaccess "cpa-ext/internal/access"
|
||||
"cpa-ext/internal/pricing"
|
||||
"cpa-ext/internal/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -93,8 +95,32 @@ func (a *App) interceptRequest(raw []byte, afterAuth bool) ([]byte, error) {
|
||||
if !managedKeyAllowsModel(key, requestedModel) {
|
||||
return OKEnvelope(denyRequest(http.StatusForbidden, "model_not_allowed", "该 Key 不允许使用此模型"))
|
||||
}
|
||||
if req.RequestID != "" {
|
||||
a.pending.Store(req.RequestID, key.ID)
|
||||
if !afterAuth {
|
||||
store, ok := a.currentStore()
|
||||
if !ok {
|
||||
return OKEnvelope(denyRequest(http.StatusServiceUnavailable, "billing_unavailable", "额度数据库不可用"))
|
||||
}
|
||||
_, authorizeErr := store.AuthorizeBilling(context.Background(), key.ID, req.RequestID, time.Now().UTC())
|
||||
switch {
|
||||
case errors.Is(authorizeErr, repository.ErrBillingQuotaExhausted):
|
||||
return OKEnvelope(denyRequest(http.StatusTooManyRequests, "billing_quota_exhausted", "该 Key 的可用余额已用尽"))
|
||||
case errors.Is(authorizeErr, repository.ErrBillingConcurrency):
|
||||
return OKEnvelope(denyRequest(http.StatusTooManyRequests, "billing_concurrency_exceeded", "该 Key 的并发请求已达到上限"))
|
||||
case authorizeErr != nil:
|
||||
return OKEnvelope(denyRequest(http.StatusServiceUnavailable, "billing_unavailable", "额度检查暂时不可用"))
|
||||
}
|
||||
if req.RequestID != "" {
|
||||
a.pending.Store(req.RequestID, key.ID)
|
||||
}
|
||||
}
|
||||
if afterAuth {
|
||||
billingModel := strings.TrimSpace(req.Model)
|
||||
if billingModel == "" {
|
||||
billingModel = requestedModel
|
||||
}
|
||||
if _, found := a.priceForModel(billingModel); !found {
|
||||
return OKEnvelope(denyRequest(http.StatusServiceUnavailable, "billing_price_unavailable", "该模型尚未配置价格"))
|
||||
}
|
||||
}
|
||||
if afterAuth && key.RouteMode == managedaccess.RouteStrict {
|
||||
account, accountErr := a.upstreamAccount(key.UpstreamAccountID)
|
||||
@@ -174,16 +200,62 @@ func managedKeyAllowsModel(key managedaccess.ManagedKey, model string) bool {
|
||||
}
|
||||
model = strings.ToLower(strings.TrimSpace(model))
|
||||
if model == "" {
|
||||
return true
|
||||
return false
|
||||
}
|
||||
for _, allowed := range key.Models {
|
||||
if strings.EqualFold(strings.TrimSpace(allowed), model) {
|
||||
if modelPatternMatches(allowed, model) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func modelPatternMatches(pattern, model string) bool {
|
||||
pattern = normalizeModelName(pattern)
|
||||
model = normalizeModelName(model)
|
||||
if pattern == "" || model == "" {
|
||||
return false
|
||||
}
|
||||
if pattern == "*" {
|
||||
return true
|
||||
}
|
||||
parts := strings.Split(pattern, "*")
|
||||
position := 0
|
||||
if !strings.HasPrefix(pattern, "*") {
|
||||
if !strings.HasPrefix(model, parts[0]) {
|
||||
return false
|
||||
}
|
||||
position = len(parts[0])
|
||||
parts = parts[1:]
|
||||
}
|
||||
for index, part := range parts {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
last := index == len(parts)-1 && !strings.HasSuffix(pattern, "*")
|
||||
if last {
|
||||
return strings.HasSuffix(model[position:], part)
|
||||
}
|
||||
offset := strings.Index(model[position:], part)
|
||||
if offset < 0 {
|
||||
return false
|
||||
}
|
||||
position += offset + len(part)
|
||||
}
|
||||
return strings.HasSuffix(pattern, "*") || position == len(model)
|
||||
}
|
||||
|
||||
func normalizeModelName(model string) string {
|
||||
return strings.ToLower(strings.TrimSpace(model))
|
||||
}
|
||||
|
||||
func (a *App) priceForModel(model string) (pricing.Policy, bool) {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
policy, found := a.prices[normalizeModelName(model)]
|
||||
return policy, found
|
||||
}
|
||||
|
||||
func denyRequest(status int, code, message string) RequestInterceptResponse {
|
||||
body, _ := json.Marshal(map[string]any{"error": map[string]string{"code": code, "message": message}})
|
||||
return RequestInterceptResponse{
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -62,6 +63,10 @@ func TestModelAllowlistAndStrictUpstreamRouting(t *testing.T) {
|
||||
if _, err := app.HandleMethod(MethodPluginRegister, lifecycleRequest(t, SchemaVersion, testConfig(t, "enabled: true\ncodex_only: false\n"))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
price := []byte(`{"model":"gpt-5.6-sol","base":{"input_per_1m":"2.5","cache_read_per_1m":"0.25","cache_write_per_1m":"3.125","output_per_1m":"15"},"fast_pricing_enabled":false,"fast_multiplier":"2.5"}`)
|
||||
if response := managementCallBody(t, app, http.MethodPut, managementBase+routePrices, price); response.StatusCode != http.StatusOK {
|
||||
t.Fatalf("put price=%d %s", response.StatusCode, response.Body)
|
||||
}
|
||||
store, _ := app.currentStore()
|
||||
if err := store.SyncUpstreamAccounts(context.Background(), []managedaccess.UpstreamAccount{{
|
||||
CPAAuthID: "oauth-1", CPAAuthIndex: "index-1", Provider: "codex", DisplayName: "OAuth One", LastSeenAt: time.Now(),
|
||||
@@ -109,7 +114,7 @@ func TestModelAllowlistAndStrictUpstreamRouting(t *testing.T) {
|
||||
t.Fatalf("scheduler pick = %+v", pick)
|
||||
}
|
||||
afterRaw, _ := json.Marshal(RequestInterceptRequest{
|
||||
RequestID: "req-1", RequestedModel: "gpt-5.6-sol",
|
||||
RequestID: "req-1", Model: "gpt-5.6-sol", RequestedModel: "gpt-5.6-sol",
|
||||
Metadata: map[string]any{callerScopeMetadata: managedaccess.CallerScope(key.ID), selectedAuthMetadata: "oauth-1"},
|
||||
})
|
||||
afterResponse, _ := app.HandleMethod(MethodRequestAfter, afterRaw)
|
||||
@@ -133,6 +138,20 @@ func TestModelAllowlistAndStrictUpstreamRouting(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelAllowlistSupportsFullNameWildcards(t *testing.T) {
|
||||
key := managedaccess.ManagedKey{AllModels: false, Models: []string{"deepseek-*", "gpt-5.6-sol"}}
|
||||
for _, model := range []string{"deepseek-v4-flash", "DEEPSEEK-SOURCE-B", "gpt-5.6-sol"} {
|
||||
if !managedKeyAllowsModel(key, model) {
|
||||
t.Fatalf("expected %q to match", model)
|
||||
}
|
||||
}
|
||||
for _, model := range []string{"x-deepseek-v4-flash", "gpt-5.6-terra", ""} {
|
||||
if managedKeyAllowsModel(key, model) {
|
||||
t.Fatalf("expected %q to be denied", model)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateKeyCopiesDefaultRuleSnapshot(t *testing.T) {
|
||||
app := NewApp()
|
||||
if _, err := app.HandleMethod(MethodPluginRegister, lifecycleRequest(t, SchemaVersion, testConfig(t, "enabled: true\n"))); err != nil {
|
||||
@@ -150,3 +169,70 @@ func TestCreateKeyCopiesDefaultRuleSnapshot(t *testing.T) {
|
||||
t.Fatalf("created = %+v", created)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBillingAdmissionConcurrencyAndUsageSettlement(t *testing.T) {
|
||||
app := NewApp()
|
||||
if _, err := app.HandleMethod(MethodPluginRegister, lifecycleRequest(t, SchemaVersion, testConfig(t, "enabled: true\ncodex_only: false\n"))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
price := []byte(`{"model":"deepseek-v4-flash","base":{"input_per_1m":"2.5","cache_read_per_1m":"0.25","cache_write_per_1m":"3.125","output_per_1m":"15"},"fast_pricing_enabled":false,"fast_multiplier":"2.5"}`)
|
||||
if response := managementCallBody(t, app, http.MethodPut, managementBase+routePrices, price); response.StatusCode != http.StatusOK {
|
||||
t.Fatalf("put price=%d %s", response.StatusCode, response.Body)
|
||||
}
|
||||
key, _ := app.store.ManagedKeyByID(context.Background(), "key_default")
|
||||
key.AllModels = false
|
||||
key.Models = []string{"deepseek-*"}
|
||||
if err := app.store.UpdateManagedKey(context.Background(), key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
intercept := func(requestID string) RequestInterceptResponse {
|
||||
raw, _ := json.Marshal(RequestInterceptRequest{RequestID: requestID, Model: "deepseek-v4-flash", RequestedModel: "deepseek-source-a", Metadata: map[string]any{callerScopeMetadata: managedaccess.CallerScope(key.ID)}})
|
||||
response, err := app.HandleMethod(MethodRequestBefore, raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var envelope Envelope
|
||||
_ = json.Unmarshal(response, &envelope)
|
||||
var result RequestInterceptResponse
|
||||
_ = json.Unmarshal(envelope.Result, &result)
|
||||
return result
|
||||
}
|
||||
if denied := intercept("zero"); !denied.Terminate || denied.StatusCode != http.StatusTooManyRequests || !strings.Contains(string(denied.ResponseBody), "billing_quota_exhausted") {
|
||||
t.Fatalf("zero quota response=%+v body=%s", denied, denied.ResponseBody)
|
||||
}
|
||||
patch := []byte(`{"id":"key_default","billing":{"quota_usd":"1","reset_period":"none","max_concurrency":1}}`)
|
||||
if response := managementCallBody(t, app, http.MethodPatch, managementBase+routeKeys, patch); response.StatusCode != http.StatusOK {
|
||||
t.Fatalf("update billing=%d %s", response.StatusCode, response.Body)
|
||||
}
|
||||
if allowed := intercept("one"); allowed.Terminate {
|
||||
t.Fatalf("funded request denied: %+v", allowed)
|
||||
}
|
||||
afterRaw, _ := json.Marshal(RequestInterceptRequest{RequestID: "one", Model: "unpriced-resolved-model", RequestedModel: "deepseek-source-a", Metadata: map[string]any{callerScopeMetadata: managedaccess.CallerScope(key.ID)}})
|
||||
afterResponse, _ := app.HandleMethod(MethodRequestAfter, afterRaw)
|
||||
var afterEnvelope Envelope
|
||||
_ = json.Unmarshal(afterResponse, &afterEnvelope)
|
||||
var afterResult RequestInterceptResponse
|
||||
_ = json.Unmarshal(afterEnvelope.Result, &afterResult)
|
||||
if !afterResult.Terminate || afterResult.StatusCode != http.StatusServiceUnavailable || !strings.Contains(string(afterResult.ResponseBody), "billing_price_unavailable") {
|
||||
t.Fatalf("missing price response=%+v body=%s", afterResult, afterResult.ResponseBody)
|
||||
}
|
||||
if denied := intercept("two"); !denied.Terminate || !strings.Contains(string(denied.ResponseBody), "billing_concurrency_exceeded") {
|
||||
t.Fatalf("concurrency response=%+v body=%s", denied, denied.ResponseBody)
|
||||
}
|
||||
completionRaw, _ := json.Marshal(RequestCompletion{RequestID: "one", StartedAt: time.Now().Add(-time.Second), CompletedAt: time.Now(), Outcome: RequestCompletionSucceeded, Metadata: map[string]any{callerScopeMetadata: managedaccess.CallerScope(key.ID)}})
|
||||
if _, err := app.HandleMethod(MethodRequestComplete, completionRaw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
usage := UsageRecord{ExecutionID: "settled-once", APIKey: "000000", Model: "deepseek-v4-flash", RequestedAt: time.Now(), Detail: UsageDetail{OutputTokens: 10_000, TotalTokens: 10_000}}
|
||||
raw, _ := json.Marshal(usage)
|
||||
if _, err := app.HandleMethod(MethodUsageHandle, raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := app.HandleMethod(MethodUsageHandle, raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state, err := app.store.BillingState(context.Background(), key.ID, time.Now())
|
||||
if err != nil || state.SpentMicros != 150_000 || state.BalanceMicros != 850_000 || state.ActiveRequests != 0 {
|
||||
t.Fatalf("settled state=%+v err=%v", state, err)
|
||||
}
|
||||
}
|
||||
|
||||
+15
-2
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"cpa-ext/internal/collection"
|
||||
"cpa-ext/internal/pricing"
|
||||
@@ -100,7 +101,16 @@ func (a *App) configure(raw []byte) ([]byte, error) {
|
||||
}
|
||||
nextPrices := make(map[string]pricing.Policy, len(policies))
|
||||
for _, policy := range policies {
|
||||
nextPrices[policy.Model] = policy
|
||||
nextPrices[normalizeModelName(policy.Model)] = policy
|
||||
}
|
||||
a.mu.RLock()
|
||||
firstInitialization := a.usage == nil
|
||||
a.mu.RUnlock()
|
||||
if firstInitialization {
|
||||
if err := usageRepository.ReleaseStaleAdmissions(context.Background(), time.Now().UTC()); err != nil {
|
||||
_ = nextUsage.Close()
|
||||
return nil, fmt.Errorf("释放遗留并发占用: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
@@ -191,7 +201,7 @@ func (a *App) handleUsage(raw []byte) ([]byte, error) {
|
||||
observed.ManagedKeyID = key.ID
|
||||
observed.KeyAlias = key.Name
|
||||
}
|
||||
if policy, found := a.prices[strings.TrimSpace(record.Model)]; found {
|
||||
if policy, found := a.prices[normalizeModelName(record.Model)]; found {
|
||||
cost, calculateErr := pricing.Calculate(policy, pricing.Usage{
|
||||
InputTokens: record.Detail.InputTokens, CacheReadTokens: record.Detail.CacheReadTokens,
|
||||
CacheWriteTokens: record.Detail.CacheCreationTokens, OutputTokens: record.Detail.OutputTokens,
|
||||
@@ -227,6 +237,9 @@ func (a *App) handleRequestComplete(raw []byte) ([]byte, error) {
|
||||
if a.usage == nil {
|
||||
return nil, fmt.Errorf("用量数据库尚未初始化")
|
||||
}
|
||||
if err := a.store.CompleteBillingAdmission(context.Background(), completion.RequestID, completion.CompletedAt); err != nil {
|
||||
return nil, fmt.Errorf("释放并发占用: %w", err)
|
||||
}
|
||||
endpoint, _ := completion.Metadata["request_path"].(string)
|
||||
managedKeyID := metadataString(completion.Metadata, callerScopeMetadata)
|
||||
if key, keyErr := a.store.ManagedKeyByReference(context.Background(), managedKeyID); keyErr == nil {
|
||||
|
||||
@@ -7,31 +7,70 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
managedaccess "cpa-ext/internal/access"
|
||||
"cpa-ext/internal/collection"
|
||||
"cpa-ext/internal/repository"
|
||||
)
|
||||
|
||||
type createManagedKeyRequest struct {
|
||||
Name string `json:"name"`
|
||||
Secret string `json:"secret,omitempty"`
|
||||
RouteMode string `json:"route_mode,omitempty"`
|
||||
UpstreamAccountID string `json:"upstream_account_id,omitempty"`
|
||||
AllModels *bool `json:"all_models,omitempty"`
|
||||
Models *[]string `json:"models,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Secret string `json:"secret,omitempty"`
|
||||
RouteMode string `json:"route_mode,omitempty"`
|
||||
UpstreamAccountID string `json:"upstream_account_id,omitempty"`
|
||||
AllModels *bool `json:"all_models,omitempty"`
|
||||
Models *[]string `json:"models,omitempty"`
|
||||
Billing *billingSettingsRequest `json:"billing,omitempty"`
|
||||
}
|
||||
|
||||
type updateManagedKeyRequest struct {
|
||||
ID string `json:"id"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
Status *string `json:"status,omitempty"`
|
||||
RouteMode *string `json:"route_mode,omitempty"`
|
||||
UpstreamAccountID *string `json:"upstream_account_id,omitempty"`
|
||||
AllModels *bool `json:"all_models,omitempty"`
|
||||
Models *[]string `json:"models,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
Status *string `json:"status,omitempty"`
|
||||
RouteMode *string `json:"route_mode,omitempty"`
|
||||
UpstreamAccountID *string `json:"upstream_account_id,omitempty"`
|
||||
AllModels *bool `json:"all_models,omitempty"`
|
||||
Models *[]string `json:"models,omitempty"`
|
||||
Billing *billingSettingsRequest `json:"billing,omitempty"`
|
||||
}
|
||||
|
||||
type billingSettingsRequest struct {
|
||||
QuotaUSD string `json:"quota_usd"`
|
||||
ResetPeriod string `json:"reset_period"`
|
||||
NextResetAt *time.Time `json:"next_reset_at,omitempty"`
|
||||
MaxConcurrency int `json:"max_concurrency"`
|
||||
}
|
||||
|
||||
type billingStateDTO struct {
|
||||
QuotaUSD string `json:"quota_usd"`
|
||||
SpentUSD string `json:"spent_usd"`
|
||||
BalanceUSD string `json:"balance_usd"`
|
||||
ResetPeriod string `json:"reset_period"`
|
||||
NextResetAt *time.Time `json:"next_reset_at,omitempty"`
|
||||
MaxConcurrency int `json:"max_concurrency"`
|
||||
ActiveRequests int `json:"active_requests"`
|
||||
CycleStartedAt time.Time `json:"cycle_started_at"`
|
||||
}
|
||||
|
||||
type managedKeyDTO struct {
|
||||
managedaccess.ManagedKey
|
||||
Billing billingStateDTO `json:"billing"`
|
||||
}
|
||||
|
||||
type ledgerEntryDTO struct {
|
||||
ID int64 `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
AmountUSD string `json:"amount_usd"`
|
||||
BalanceAfterUSD string `json:"balance_after_usd"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
ExecutionID string `json:"execution_id,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
}
|
||||
|
||||
type archiveManagedKeyRequest struct {
|
||||
@@ -52,7 +91,15 @@ func (a *App) listManagedKeys(includeArchived bool) ManagementResponse {
|
||||
if err != nil {
|
||||
return managementError(http.StatusInternalServerError, "database_error", err.Error())
|
||||
}
|
||||
return jsonManagementResponse(http.StatusOK, map[string]any{"keys": keys})
|
||||
items := make([]managedKeyDTO, 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, managedKeyResponse(key, state))
|
||||
}
|
||||
return jsonManagementResponse(http.StatusOK, map[string]any{"keys": items})
|
||||
}
|
||||
|
||||
func (a *App) createManagedKey(body []byte) ManagementResponse {
|
||||
@@ -117,11 +164,25 @@ func (a *App) createManagedKey(body []byte) ManagementResponse {
|
||||
if err := validateManagedKeyRule(store, key); err != nil {
|
||||
return managementError(http.StatusBadRequest, "invalid_rule", err.Error())
|
||||
}
|
||||
var requestedBilling *managedaccess.BillingSettings
|
||||
if req.Billing != nil {
|
||||
settings, settingsErr := billingSettings(*req.Billing)
|
||||
if settingsErr != nil {
|
||||
return managementError(http.StatusBadRequest, "invalid_billing", settingsErr.Error())
|
||||
}
|
||||
requestedBilling = &settings
|
||||
}
|
||||
if err := store.CreateManagedKey(context.Background(), key); err != nil {
|
||||
return managementError(http.StatusConflict, "key_conflict", err.Error())
|
||||
}
|
||||
if requestedBilling != nil {
|
||||
if _, settingsErr := store.UpdateBilling(context.Background(), key.ID, *requestedBilling, time.Now().UTC()); settingsErr != nil {
|
||||
return managementError(http.StatusBadRequest, "invalid_billing", settingsErr.Error())
|
||||
}
|
||||
}
|
||||
created, _ := store.ManagedKeyByID(context.Background(), key.ID)
|
||||
return jsonManagementResponse(http.StatusCreated, created)
|
||||
state, _ := store.BillingState(context.Background(), key.ID, time.Now().UTC())
|
||||
return jsonManagementResponse(http.StatusCreated, managedKeyResponse(created, state))
|
||||
}
|
||||
|
||||
func (a *App) updateManagedKey(body []byte) ManagementResponse {
|
||||
@@ -170,6 +231,14 @@ func (a *App) updateManagedKey(body []byte) ManagementResponse {
|
||||
if err := validateManagedKeyRule(store, key); err != nil {
|
||||
return managementError(http.StatusBadRequest, "invalid_rule", err.Error())
|
||||
}
|
||||
var requestedBilling *managedaccess.BillingSettings
|
||||
if req.Billing != nil {
|
||||
settings, settingsErr := billingSettings(*req.Billing)
|
||||
if settingsErr != nil {
|
||||
return managementError(http.StatusBadRequest, "invalid_billing", settingsErr.Error())
|
||||
}
|
||||
requestedBilling = &settings
|
||||
}
|
||||
if err := store.UpdateManagedKey(context.Background(), key); err != nil {
|
||||
status := http.StatusConflict
|
||||
if errors.Is(err, repository.ErrManagedKeyNotFound) {
|
||||
@@ -177,8 +246,96 @@ func (a *App) updateManagedKey(body []byte) ManagementResponse {
|
||||
}
|
||||
return managementError(status, "update_failed", err.Error())
|
||||
}
|
||||
if requestedBilling != nil {
|
||||
if _, settingsErr := store.UpdateBilling(context.Background(), key.ID, *requestedBilling, time.Now().UTC()); settingsErr != nil {
|
||||
return managementError(http.StatusBadRequest, "invalid_billing", settingsErr.Error())
|
||||
}
|
||||
}
|
||||
updated, _ := store.ManagedKeyByID(context.Background(), key.ID)
|
||||
return jsonManagementResponse(http.StatusOK, updated)
|
||||
state, _ := store.BillingState(context.Background(), key.ID, time.Now().UTC())
|
||||
return jsonManagementResponse(http.StatusOK, managedKeyResponse(updated, state))
|
||||
}
|
||||
|
||||
func billingSettings(request billingSettingsRequest) (managedaccess.BillingSettings, error) {
|
||||
quota, err := parseDecimalMicros(request.QuotaUSD)
|
||||
if err != nil {
|
||||
return managedaccess.BillingSettings{}, errors.New("额度必须是非负美元金额,最多六位小数")
|
||||
}
|
||||
period := strings.TrimSpace(request.ResetPeriod)
|
||||
if period == "" {
|
||||
period = managedaccess.ResetNone
|
||||
}
|
||||
if period != managedaccess.ResetNone && period != managedaccess.ResetDaily && period != managedaccess.ResetWeekly && period != managedaccess.ResetMonthly {
|
||||
return managedaccess.BillingSettings{}, errors.New("重置周期只能是 none、daily、weekly 或 monthly")
|
||||
}
|
||||
if period != managedaccess.ResetNone && request.NextResetAt != nil && !request.NextResetAt.After(time.Now()) {
|
||||
return managedaccess.BillingSettings{}, errors.New("下次重置时间必须晚于当前时间")
|
||||
}
|
||||
maxConcurrency := request.MaxConcurrency
|
||||
if maxConcurrency == 0 {
|
||||
maxConcurrency = 4
|
||||
}
|
||||
if maxConcurrency < 1 || maxConcurrency > 64 {
|
||||
return managedaccess.BillingSettings{}, errors.New("并发上限必须为 1-64")
|
||||
}
|
||||
return managedaccess.BillingSettings{QuotaMicros: quota, ResetPeriod: period,
|
||||
NextResetAt: request.NextResetAt, MaxConcurrency: maxConcurrency}, nil
|
||||
}
|
||||
|
||||
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,
|
||||
}}
|
||||
}
|
||||
|
||||
func (a *App) resetManagedKeyBilling(body []byte) ManagementResponse {
|
||||
var request struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &request); err != nil || strings.TrimSpace(request.ID) == "" {
|
||||
return managementError(http.StatusBadRequest, "invalid_request", "缺少有效的 Key ID")
|
||||
}
|
||||
store, ok := a.currentStore()
|
||||
if !ok {
|
||||
return managementError(http.StatusServiceUnavailable, "database_unavailable", "额度数据库尚未初始化")
|
||||
}
|
||||
state, err := store.ResetBilling(context.Background(), request.ID, time.Now().UTC())
|
||||
if err != nil {
|
||||
return managementError(http.StatusBadRequest, "reset_failed", err.Error())
|
||||
}
|
||||
return jsonManagementResponse(http.StatusOK, 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,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) managedKeyLedger(query url.Values) ManagementResponse {
|
||||
keyID := strings.TrimSpace(query.Get("id"))
|
||||
if keyID == "" {
|
||||
return managementError(http.StatusBadRequest, "invalid_request", "缺少有效的 Key ID")
|
||||
}
|
||||
before, _ := strconv.ParseInt(query.Get("before"), 10, 64)
|
||||
limit, _ := strconv.Atoi(query.Get("limit"))
|
||||
store, ok := a.currentStore()
|
||||
if !ok {
|
||||
return managementError(http.StatusServiceUnavailable, "database_unavailable", "额度数据库尚未初始化")
|
||||
}
|
||||
entries, err := store.ListBillingLedger(context.Background(), keyID, before, limit)
|
||||
if err != nil {
|
||||
return managementError(http.StatusInternalServerError, "database_error", err.Error())
|
||||
}
|
||||
items := make([]ledgerEntryDTO, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
items = append(items, ledgerEntryDTO{ID: entry.ID, Kind: entry.Kind,
|
||||
AmountUSD: formatMicros(entry.AmountMicros), BalanceAfterUSD: formatMicros(entry.BalanceAfterMicros),
|
||||
RequestID: entry.RequestID, ExecutionID: entry.ExecutionID, Model: entry.Model, OccurredAt: entry.OccurredAt})
|
||||
}
|
||||
return jsonManagementResponse(http.StatusOK, map[string]any{"entries": items})
|
||||
}
|
||||
|
||||
func (a *App) archiveManagedKey(body []byte) ManagementResponse {
|
||||
@@ -211,18 +368,13 @@ func (a *App) managedKeyStats(id string) ManagementResponse {
|
||||
if err != nil {
|
||||
return managementError(http.StatusInternalServerError, "database_error", err.Error())
|
||||
}
|
||||
records, err := store.ListRecent(context.Background(), 1000)
|
||||
records, err := store.QueryUsage(context.Background(), collection.UsageQuery{Page: 1, PageSize: 50, KeyID: id})
|
||||
if err != nil {
|
||||
return managementError(http.StatusInternalServerError, "database_error", err.Error())
|
||||
}
|
||||
recent := make([]usageListItem, 0, 50)
|
||||
for _, record := range records {
|
||||
if record.ManagedKeyID == id {
|
||||
recent = append(recent, usageItem(record))
|
||||
if len(recent) == 50 {
|
||||
break
|
||||
}
|
||||
}
|
||||
recent := make([]usageListItem, 0, len(records.Records))
|
||||
for _, record := range records.Records {
|
||||
recent = append(recent, usageItem(record))
|
||||
}
|
||||
return jsonManagementResponse(http.StatusOK, managedKeyStatsResponse{Stats: stats, Recent: recent})
|
||||
}
|
||||
@@ -282,6 +434,11 @@ func validateManagedKeyRule(store *repository.SQLiteUsageRepository, key managed
|
||||
if !key.AllModels && len(normalizeModels(key.Models)) == 0 {
|
||||
return errors.New("未允许全部模型时至少配置一个模型")
|
||||
}
|
||||
for _, pattern := range key.Models {
|
||||
if len(pattern) > 128 || strings.IndexFunc(pattern, func(value rune) bool { return unicode.IsSpace(value) || unicode.IsControl(value) }) >= 0 {
|
||||
return errors.New("模型规则不能包含空白或控制字符,且最长 128 个字符")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
+122
-19
@@ -5,6 +5,8 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -13,21 +15,25 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
managementBase = "/v0/management/plugins/" + PluginName
|
||||
resourceBase = "/v0/resource/plugins/" + PluginName
|
||||
routeUsage = "/usage"
|
||||
routePrices = "/prices"
|
||||
routeKeys = "/keys"
|
||||
routeKeyStats = "/key-stats"
|
||||
routeUpstreams = "/upstreams"
|
||||
routeModels = "/model-suggestions"
|
||||
resourceUI = "/ui"
|
||||
managementBase = "/v0/management/plugins/" + PluginName
|
||||
resourceBase = "/v0/resource/plugins/" + PluginName
|
||||
routeUsage = "/usage"
|
||||
routeUsageSummary = "/usage-summary"
|
||||
routePrices = "/prices"
|
||||
routeKeys = "/keys"
|
||||
routeKeyStats = "/key-stats"
|
||||
routeUpstreams = "/upstreams"
|
||||
routeModels = "/model-suggestions"
|
||||
routeBillingReset = "/billing-reset"
|
||||
routeBillingLedger = "/billing-ledger"
|
||||
resourceUI = "/ui"
|
||||
)
|
||||
|
||||
func managementRegistration() ManagementRegistrationResponse {
|
||||
return ManagementRegistrationResponse{
|
||||
Routes: []ManagementRoute{
|
||||
{Method: http.MethodGet, Path: managementBase + routeUsage, Description: "查看最近的用量记录。"},
|
||||
{Method: http.MethodGet, Path: managementBase + routeUsageSummary, Description: "查看用户与每日用量汇总。"},
|
||||
{Method: http.MethodGet, Path: managementBase + routePrices, Description: "查看模型价格。"},
|
||||
{Method: http.MethodPut, Path: managementBase + routePrices, Description: "保存模型价格。"},
|
||||
{Method: http.MethodDelete, Path: managementBase + routePrices, Description: "删除模型价格。"},
|
||||
@@ -38,6 +44,8 @@ func managementRegistration() ManagementRegistrationResponse {
|
||||
{Method: http.MethodGet, Path: managementBase + routeKeyStats, Description: "查看 Key 统计。"},
|
||||
{Method: http.MethodGet, Path: managementBase + routeUpstreams, Description: "同步并查看上游账号。"},
|
||||
{Method: http.MethodGet, Path: managementBase + routeModels, Description: "查看模型建议。"},
|
||||
{Method: http.MethodPost, Path: managementBase + routeBillingReset, Description: "立即重置 Key 额度。"},
|
||||
{Method: http.MethodGet, Path: managementBase + routeBillingLedger, Description: "查看 Key 额度账目。"},
|
||||
},
|
||||
Resources: []ResourceRoute{
|
||||
{Path: resourceBase + resourceUI, Menu: "用量记录", Description: "查看 CPA 最近收到的请求用量。"},
|
||||
@@ -62,7 +70,10 @@ func (a *App) handleManagement(raw []byte) ([]byte, error) {
|
||||
})
|
||||
}
|
||||
if req.Method == http.MethodGet && path == managementBase+routeUsage {
|
||||
return OKEnvelope(a.usageResponse())
|
||||
return OKEnvelope(a.usageResponse(req.Query))
|
||||
}
|
||||
if req.Method == http.MethodGet && path == managementBase+routeUsageSummary {
|
||||
return OKEnvelope(a.usageDashboardResponse())
|
||||
}
|
||||
if path == managementBase+routePrices {
|
||||
switch req.Method {
|
||||
@@ -95,6 +106,12 @@ func (a *App) handleManagement(raw []byte) ([]byte, error) {
|
||||
if req.Method == http.MethodGet && path == managementBase+routeModels {
|
||||
return OKEnvelope(a.modelSuggestions())
|
||||
}
|
||||
if req.Method == http.MethodPost && path == managementBase+routeBillingReset {
|
||||
return OKEnvelope(a.resetManagedKeyBilling(req.Body))
|
||||
}
|
||||
if req.Method == http.MethodGet && path == managementBase+routeBillingLedger {
|
||||
return OKEnvelope(a.managedKeyLedger(req.Query))
|
||||
}
|
||||
return OKEnvelope(jsonManagementResponse(http.StatusNotFound, map[string]any{
|
||||
"error": map[string]string{
|
||||
"code": "not_found",
|
||||
@@ -104,8 +121,17 @@ func (a *App) handleManagement(raw []byte) ([]byte, error) {
|
||||
}
|
||||
|
||||
type usageListResponse struct {
|
||||
Records []usageListItem `json:"records"`
|
||||
Retained int `json:"retained"`
|
||||
Records []usageListItem `json:"records"`
|
||||
Pagination usagePaginationResult `json:"pagination"`
|
||||
}
|
||||
|
||||
type usagePaginationResult struct {
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Total int64 `json:"total"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
PreviousCursor string `json:"previous_cursor,omitempty"`
|
||||
NextCursor string `json:"next_cursor,omitempty"`
|
||||
}
|
||||
|
||||
// usageListItem 是请求明细表的稳定接口。当前回调拿不到的字段保留为空值。
|
||||
@@ -147,7 +173,11 @@ type usageListItem struct {
|
||||
ClientIP string `json:"client_ip"`
|
||||
}
|
||||
|
||||
func (a *App) usageResponse() ManagementResponse {
|
||||
func (a *App) usageResponse(values url.Values) ManagementResponse {
|
||||
query, err := parseUsageQuery(values)
|
||||
if err != nil {
|
||||
return managementError(http.StatusBadRequest, "invalid_query", err.Error())
|
||||
}
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
if a.usage == nil {
|
||||
@@ -155,17 +185,90 @@ func (a *App) usageResponse() ManagementResponse {
|
||||
"error": map[string]string{"code": "database_unavailable", "message": "用量数据库尚未初始化"},
|
||||
})
|
||||
}
|
||||
records, err := a.usage.Recent(context.Background(), 1000)
|
||||
page, err := a.usage.Query(context.Background(), query)
|
||||
if err != nil {
|
||||
return jsonManagementResponse(http.StatusInternalServerError, map[string]any{
|
||||
"error": map[string]string{"code": "database_error", "message": err.Error()},
|
||||
status := http.StatusInternalServerError
|
||||
code := "database_error"
|
||||
if strings.Contains(err.Error(), "cursor") || strings.Contains(err.Error(), "page_size") || strings.Contains(err.Error(), "from") || strings.Contains(err.Error(), "result") {
|
||||
status = http.StatusBadRequest
|
||||
code = "invalid_query"
|
||||
}
|
||||
return jsonManagementResponse(status, map[string]any{
|
||||
"error": map[string]string{"code": code, "message": err.Error()},
|
||||
})
|
||||
}
|
||||
items := make([]usageListItem, 0, len(records))
|
||||
for _, record := range records {
|
||||
items := make([]usageListItem, 0, len(page.Records))
|
||||
for _, record := range page.Records {
|
||||
items = append(items, usageItem(record))
|
||||
}
|
||||
return jsonManagementResponse(http.StatusOK, usageListResponse{Records: items, Retained: 1000})
|
||||
return jsonManagementResponse(http.StatusOK, usageListResponse{Records: items, Pagination: usagePaginationResult{
|
||||
Page: page.Page, PageSize: page.PageSize, Total: page.Total, TotalPages: page.TotalPages,
|
||||
PreviousCursor: page.PreviousCursor, NextCursor: page.NextCursor,
|
||||
}})
|
||||
}
|
||||
|
||||
func parseUsageQuery(values url.Values) (collection.UsageQuery, error) {
|
||||
query := collection.UsageQuery{Page: 1, PageSize: 100, Cursor: strings.TrimSpace(values.Get("cursor")), KeyID: values.Get("key_id"), Model: values.Get("model"), Result: values.Get("result"), AuthID: values.Get("auth_id"), Endpoint: values.Get("endpoint"), RequestID: values.Get("request_id")}
|
||||
if raw := strings.TrimSpace(values.Get("page")); raw != "" {
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil || value < 1 {
|
||||
return query, fmt.Errorf("page 必须是正整数")
|
||||
}
|
||||
query.Page = value
|
||||
}
|
||||
if raw := strings.TrimSpace(values.Get("page_size")); raw != "" {
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil || value < 1 || value > 100 {
|
||||
return query, fmt.Errorf("page_size 必须在 1 到 100 之间")
|
||||
}
|
||||
query.PageSize = value
|
||||
}
|
||||
for name, target := range map[string]**time.Time{"from": &query.From, "to": &query.To} {
|
||||
raw := strings.TrimSpace(values.Get(name))
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339Nano, raw)
|
||||
if err != nil {
|
||||
return query, fmt.Errorf("%s 必须是 RFC3339 时间", name)
|
||||
}
|
||||
*target = &parsed
|
||||
}
|
||||
return query, nil
|
||||
}
|
||||
|
||||
type usageSummaryDTO struct {
|
||||
Requests int64 `json:"requests"`
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
TotalTokens int64 `json:"total_tokens"`
|
||||
CostUSD float64 `json:"cost_usd"`
|
||||
}
|
||||
|
||||
func usageSummaryItem(summary collection.UsageSummary) usageSummaryDTO {
|
||||
return usageSummaryDTO{Requests: summary.Requests, InputTokens: summary.InputTokens, OutputTokens: summary.OutputTokens, TotalTokens: summary.TotalTokens, CostUSD: float64(summary.CostMicros) / 1_000_000}
|
||||
}
|
||||
|
||||
func (a *App) usageDashboardResponse() ManagementResponse {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
if a.usage == nil {
|
||||
return managementError(http.StatusServiceUnavailable, "database_unavailable", "用量数据库尚未初始化")
|
||||
}
|
||||
location, _ := time.LoadLocation("Asia/Shanghai")
|
||||
dashboard, err := a.usage.Dashboard(context.Background(), time.Now().In(location), 7)
|
||||
if err != nil {
|
||||
return managementError(http.StatusInternalServerError, "database_error", err.Error())
|
||||
}
|
||||
users := make([]map[string]any, 0, len(dashboard.Users))
|
||||
for _, user := range dashboard.Users {
|
||||
users = append(users, map[string]any{"key_id": user.KeyID, "key_alias": user.KeyAlias, "today": usageSummaryItem(user.Today), "last_used_at": user.LastUsedAt})
|
||||
}
|
||||
days := make([]map[string]any, 0, len(dashboard.Days))
|
||||
for _, day := range dashboard.Days {
|
||||
days = append(days, map[string]any{"date": day.Date.Format("2006-01-02"), "total_tokens": day.TotalTokens})
|
||||
}
|
||||
return jsonManagementResponse(http.StatusOK, map[string]any{"today": usageSummaryItem(dashboard.Today), "users": users, "days": days})
|
||||
}
|
||||
|
||||
func usageItem(record collection.Record) usageListItem {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -14,8 +15,12 @@ func managementCall(t *testing.T, app *App, method, path string) ManagementRespo
|
||||
}
|
||||
|
||||
func managementCallBody(t *testing.T, app *App, method, path string, body []byte) ManagementResponse {
|
||||
return managementCallRequest(t, app, ManagementRequest{Method: method, Path: path, Body: body})
|
||||
}
|
||||
|
||||
func managementCallRequest(t *testing.T, app *App, requestValue ManagementRequest) ManagementResponse {
|
||||
t.Helper()
|
||||
request, err := json.Marshal(ManagementRequest{Method: method, Path: path, Body: body})
|
||||
request, err := json.Marshal(requestValue)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -47,7 +52,7 @@ func TestManagementRegistrationDeclaresUsageAPIAndUI(t *testing.T) {
|
||||
if err := json.Unmarshal(envelope.Result, ®istration); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(registration.Routes) != 11 || registration.Routes[0].Path != managementBase+routeUsage || registration.Routes[1].Path != managementBase+routePrices {
|
||||
if len(registration.Routes) != 14 || registration.Routes[0].Path != managementBase+routeUsage || registration.Routes[1].Path != managementBase+routeUsageSummary || registration.Routes[2].Path != managementBase+routePrices {
|
||||
t.Fatalf("unexpected management routes: %+v", registration.Routes)
|
||||
}
|
||||
if len(registration.Resources) != 1 || registration.Resources[0].Path != resourceBase+resourceUI {
|
||||
@@ -55,6 +60,49 @@ func TestManagementRegistrationDeclaresUsageAPIAndUI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsageManagementSupportsServerPaginationFiltersAndSummary(t *testing.T) {
|
||||
app := NewApp()
|
||||
if _, err := app.HandleMethod(MethodPluginRegister, lifecycleRequest(t, SchemaVersion, testConfig(t, "enabled: true\ncodex_only: false\n"))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := time.Date(2026, 8, 15, 8, 0, 0, 0, time.UTC)
|
||||
for index := 0; index < 205; index++ {
|
||||
record := UsageRecord{RequestID: fmt.Sprintf("page-request-%03d", index), ExecutionID: fmt.Sprintf("page-execution-%03d", index), Provider: "openai", Model: fmt.Sprintf("model-%d", index%2), AuthID: fmt.Sprintf("auth-%d", index%2), RequestedAt: base.Add(time.Duration(index) * time.Second), Endpoint: "POST /v1/responses", Failed: index%10 == 0, Detail: UsageDetail{TotalTokens: int64(index + 1)}}
|
||||
raw, _ := json.Marshal(record)
|
||||
if _, err := app.HandleMethod(MethodUsageHandle, raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
response := managementCallRequest(t, app, ManagementRequest{Method: http.MethodGet, Path: managementBase + routeUsage, Query: url.Values{"page": {"2"}, "page_size": {"100"}, "model": {"model-1"}}})
|
||||
if response.StatusCode != http.StatusOK {
|
||||
t.Fatalf("usage query status=%d body=%s", response.StatusCode, response.Body)
|
||||
}
|
||||
var payload usageListResponse
|
||||
if err := json.Unmarshal(response.Body, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload.Pagination.Total != 102 || payload.Pagination.Page != 2 || payload.Pagination.TotalPages != 2 || len(payload.Records) != 2 {
|
||||
t.Fatalf("unexpected paginated response: %+v", payload.Pagination)
|
||||
}
|
||||
if payload.Records[0].Model != "model-1" || payload.Records[1].Model != "model-1" {
|
||||
t.Fatalf("filter leaked records: %+v", payload.Records)
|
||||
}
|
||||
invalid := managementCallRequest(t, app, ManagementRequest{Method: http.MethodGet, Path: managementBase + routeUsage, Query: url.Values{"page_size": {"101"}}})
|
||||
if invalid.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("invalid page size status=%d", invalid.StatusCode)
|
||||
}
|
||||
summary := managementCall(t, app, http.MethodGet, managementBase+routeUsageSummary)
|
||||
if summary.StatusCode != http.StatusOK || !strings.Contains(string(summary.Body), `"users"`) || !strings.Contains(string(summary.Body), `"days"`) {
|
||||
t.Fatalf("unexpected summary: status=%d body=%s", summary.StatusCode, summary.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatMicrosSupportsNegativeBalances(t *testing.T) {
|
||||
if got := formatMicros(-125_000); got != "-0.125" {
|
||||
t.Fatalf("formatMicros(-125000)=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriceManagementCalculatesUsageWithLongContextAndFastPolicy(t *testing.T) {
|
||||
app := NewApp()
|
||||
if _, err := app.HandleMethod(MethodPluginRegister, lifecycleRequest(t, SchemaVersion, testConfig(t, "enabled: true\ncodex_only: true\n"))); err != nil {
|
||||
@@ -387,7 +435,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"`, `overflow-y: hidden`, "测试模式禁止访问真实管理接口"} {
|
||||
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-*", "请求结束后按实际费用扣款"} {
|
||||
if !strings.Contains(page, feature) {
|
||||
t.Fatalf("UI does not contain workspace feature %q", feature)
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ func (a *App) putPrice(body []byte) ManagementResponse {
|
||||
if err := a.store.UpsertPrice(context.Background(), policy); err != nil {
|
||||
return jsonManagementResponse(http.StatusInternalServerError, map[string]any{"error": map[string]string{"code": "database_error", "message": err.Error()}})
|
||||
}
|
||||
a.prices[policy.Model] = policy
|
||||
a.prices[normalizeModelName(policy.Model)] = policy
|
||||
if _, err := a.store.BackfillMissingCosts(context.Background(), policy); err != nil {
|
||||
return jsonManagementResponse(http.StatusInternalServerError, map[string]any{"error": map[string]string{"code": "database_error", "message": err.Error()}})
|
||||
}
|
||||
@@ -95,7 +95,7 @@ func (a *App) deletePrice(body []byte) ManagementResponse {
|
||||
if err := a.store.DeletePrice(context.Background(), request.Model); err != nil {
|
||||
return jsonManagementResponse(http.StatusInternalServerError, map[string]any{"error": map[string]string{"code": "database_error", "message": err.Error()}})
|
||||
}
|
||||
delete(a.prices, request.Model)
|
||||
delete(a.prices, normalizeModelName(request.Model))
|
||||
return jsonManagementResponse(http.StatusOK, map[string]any{"deleted": request.Model})
|
||||
}
|
||||
|
||||
@@ -195,13 +195,18 @@ func gcd(a, b int64) int64 {
|
||||
}
|
||||
|
||||
func formatMicros(value int64) string {
|
||||
prefix := ""
|
||||
if value < 0 {
|
||||
prefix = "-"
|
||||
value = -value
|
||||
}
|
||||
whole := value / 1_000_000
|
||||
fraction := strconv.FormatInt(value%1_000_000+1_000_000, 10)[1:]
|
||||
fraction = strings.TrimRight(fraction, "0")
|
||||
if fraction == "" {
|
||||
return strconv.FormatInt(whole, 10)
|
||||
return prefix + strconv.FormatInt(whole, 10)
|
||||
}
|
||||
return strconv.FormatInt(whole, 10) + "." + fraction
|
||||
return prefix + strconv.FormatInt(whole, 10) + "." + fraction
|
||||
}
|
||||
|
||||
func formatRatio(value pricing.Ratio) string {
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"time"
|
||||
|
||||
managedaccess "cpa-ext/internal/access"
|
||||
"cpa-ext/internal/collection"
|
||||
)
|
||||
|
||||
var ErrManagedKeyNotFound = errors.New("managed key not found")
|
||||
@@ -62,6 +61,16 @@ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, key.ID, key.Name, key.Secret, key.Status, k
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO billing_accounts (managed_key_id, quota_micros, reset_period, max_concurrency, current_cycle_sequence, updated_at)
|
||||
VALUES (?, 0, 'none', 4, 1, ?)`, key.ID, formatTime(key.CreatedAt)); err != nil {
|
||||
return fmt.Errorf("创建 Key 额度账户: %w", err)
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO billing_cycles (managed_key_id, sequence, started_at, quota_micros, spent_micros)
|
||||
VALUES (?, 1, ?, 0, 0)`, key.ID, formatTime(key.CreatedAt)); err != nil {
|
||||
return fmt.Errorf("创建 Key 额度周期: %w", err)
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `
|
||||
UPDATE usage_records SET managed_key_id = ?, key_alias = ?
|
||||
WHERE managed_key_id = '' AND api_key = ?`, key.ID, key.Name, key.Secret); err != nil {
|
||||
return fmt.Errorf("关联历史用量: %w", err)
|
||||
@@ -326,74 +335,33 @@ SELECT id, cpa_auth_id, cpa_auth_index, provider, display_name, status, status_m
|
||||
|
||||
func (r *SQLiteUsageRepository) KeyStats(ctx context.Context, keyID string, today time.Time) (managedaccess.KeyStats, error) {
|
||||
stats := managedaccess.KeyStats{KeyID: keyID}
|
||||
facts, err := r.keyUsageFacts(ctx, keyID)
|
||||
var last sql.NullString
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*),
|
||||
COALESCE(SUM(u.input_tokens),0), COALESCE(SUM(u.output_tokens),0),
|
||||
COALESCE(SUM(u.total_tokens),0), COALESCE(SUM(u.cost_micros),0),
|
||||
COALESCE(SUM(CASE WHEN d.requested_at>=? THEN 1 ELSE 0 END),0),
|
||||
COALESCE(SUM(CASE WHEN d.requested_at>=? THEN u.input_tokens ELSE 0 END),0),
|
||||
COALESCE(SUM(CASE WHEN d.requested_at>=? THEN u.output_tokens ELSE 0 END),0),
|
||||
COALESCE(SUM(CASE WHEN d.requested_at>=? THEN u.total_tokens ELSE 0 END),0),
|
||||
COALESCE(SUM(CASE WHEN d.requested_at>=? THEN u.cost_micros ELSE 0 END),0),
|
||||
MAX(d.requested_at)
|
||||
FROM request_detail_index d
|
||||
LEFT JOIN usage_records u ON u.id=d.usage_id
|
||||
WHERE d.managed_key_id=?`, formatTime(today), formatTime(today), formatTime(today), formatTime(today), formatTime(today), keyID).Scan(
|
||||
&stats.Total.Requests, &stats.Total.InputTokens, &stats.Total.OutputTokens, &stats.Total.TotalTokens, &stats.Total.CostMicros,
|
||||
&stats.Today.Requests, &stats.Today.InputTokens, &stats.Today.OutputTokens, &stats.Today.TotalTokens, &stats.Today.CostMicros, &last)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
return stats, fmt.Errorf("汇总 Key 用量: %w", err)
|
||||
}
|
||||
for _, fact := range mergeOrphanLifecycleRecords(facts) {
|
||||
addUsageSummary(&stats.Total, fact)
|
||||
if !fact.RequestedAt.Before(today) {
|
||||
addUsageSummary(&stats.Today, fact)
|
||||
}
|
||||
if stats.LastUsedAt == nil || fact.RequestedAt.After(*stats.LastUsedAt) {
|
||||
value := fact.RequestedAt
|
||||
stats.LastUsedAt = &value
|
||||
if last.Valid {
|
||||
if parsed, parseErr := time.Parse(time.RFC3339Nano, last.String); parseErr == nil {
|
||||
stats.LastUsedAt = &parsed
|
||||
}
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (r *SQLiteUsageRepository) keyUsageFacts(ctx context.Context, keyID string) ([]collection.Record, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT request_id, requested_at, model, api_key, executor_type,
|
||||
input_tokens, output_tokens, total_tokens, cost_micros, ''
|
||||
FROM usage_records WHERE managed_key_id=?
|
||||
UNION ALL
|
||||
SELECT request_id, requested_at, COALESCE(NULLIF(model, ''), requested_model), '', '',
|
||||
0, 0, 0, NULL, outcome
|
||||
FROM request_records r
|
||||
WHERE managed_key_id=? AND NOT EXISTS (SELECT 1 FROM usage_records u WHERE u.request_id=r.request_id)
|
||||
ORDER BY requested_at DESC`, keyID, keyID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询 Key 用量事实: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
facts := make([]collection.Record, 0)
|
||||
for rows.Next() {
|
||||
var fact collection.Record
|
||||
var requestedAt string
|
||||
var cost sql.NullInt64
|
||||
if err := rows.Scan(&fact.RequestID, &requestedAt, &fact.Model, &fact.APIKey, &fact.ExecutorType,
|
||||
&fact.InputTokens, &fact.OutputTokens, &fact.TotalTokens, &cost, &fact.Outcome); err != nil {
|
||||
return nil, fmt.Errorf("读取 Key 用量事实: %w", err)
|
||||
}
|
||||
fact.ManagedKeyID = keyID
|
||||
fact.RequestedAt, err = time.Parse(time.RFC3339Nano, requestedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析 Key 用量时间: %w", err)
|
||||
}
|
||||
if cost.Valid {
|
||||
value := cost.Int64
|
||||
fact.CostMicros = &value
|
||||
}
|
||||
facts = append(facts, fact)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("遍历 Key 用量事实: %w", err)
|
||||
}
|
||||
return facts, nil
|
||||
}
|
||||
|
||||
func addUsageSummary(summary *managedaccess.UsageSummary, fact collection.Record) {
|
||||
summary.Requests++
|
||||
summary.InputTokens += fact.InputTokens
|
||||
summary.OutputTokens += fact.OutputTokens
|
||||
summary.TotalTokens += fact.TotalTokens
|
||||
if fact.CostMicros != nil {
|
||||
summary.CostMicros += *fact.CostMicros
|
||||
}
|
||||
}
|
||||
|
||||
func (r *SQLiteUsageRepository) ModelSuggestions(ctx context.Context) ([]string, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT model FROM model_prices
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
managedaccess "cpa-ext/internal/access"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrBillingQuotaExhausted = errors.New("billing quota exhausted")
|
||||
ErrBillingConcurrency = errors.New("billing concurrency exceeded")
|
||||
)
|
||||
|
||||
func (r *SQLiteUsageRepository) BillingState(ctx context.Context, keyID string, now time.Time) (managedaccess.BillingState, error) {
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return managedaccess.BillingState{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
if err := ensureBillingReset(ctx, tx, keyID, now); err != nil {
|
||||
return managedaccess.BillingState{}, err
|
||||
}
|
||||
state, err := scanBillingState(ctx, tx, keyID)
|
||||
if err != nil {
|
||||
return state, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return state, err
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (r *SQLiteUsageRepository) UpdateBilling(ctx context.Context, keyID string, settings managedaccess.BillingSettings, now time.Time) (managedaccess.BillingState, error) {
|
||||
if settings.QuotaMicros < 0 {
|
||||
return managedaccess.BillingState{}, errors.New("额度不能小于 0")
|
||||
}
|
||||
if settings.MaxConcurrency < 1 || settings.MaxConcurrency > 64 {
|
||||
return managedaccess.BillingState{}, errors.New("并发上限必须为 1-64")
|
||||
}
|
||||
if !validResetPeriod(settings.ResetPeriod) {
|
||||
return managedaccess.BillingState{}, errors.New("重置周期只能是 none、daily、weekly 或 monthly")
|
||||
}
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return managedaccess.BillingState{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
if err := ensureBillingReset(ctx, tx, keyID, now); err != nil {
|
||||
return managedaccess.BillingState{}, err
|
||||
}
|
||||
current, err := scanBillingState(ctx, tx, keyID)
|
||||
if err != nil {
|
||||
return current, err
|
||||
}
|
||||
nextReset := settings.NextResetAt
|
||||
anchorDay := 0
|
||||
if settings.ResetPeriod == managedaccess.ResetNone {
|
||||
nextReset = nil
|
||||
} else {
|
||||
if nextReset == nil || nextReset.IsZero() {
|
||||
var createdRaw string
|
||||
if err := tx.QueryRowContext(ctx, `SELECT created_at FROM managed_keys WHERE id=?`, keyID).Scan(&createdRaw); err != nil {
|
||||
return current, err
|
||||
}
|
||||
created, _ := time.Parse(time.RFC3339Nano, createdRaw)
|
||||
value := nextResetAfter(created, settings.ResetPeriod, now)
|
||||
nextReset = &value
|
||||
}
|
||||
if !nextReset.After(now) {
|
||||
return current, errors.New("下次重置时间必须晚于当前时间")
|
||||
}
|
||||
anchorDay = nextReset.In(shanghaiLocation()).Day()
|
||||
}
|
||||
nextValue := any(nil)
|
||||
if nextReset != nil {
|
||||
nextValue = formatTime(*nextReset)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE billing_accounts SET quota_micros=?, reset_period=?, next_reset_at=?, reset_anchor_day=?,
|
||||
max_concurrency=?, updated_at=? WHERE managed_key_id=?`, settings.QuotaMicros, settings.ResetPeriod,
|
||||
nextValue, anchorDay, settings.MaxConcurrency, formatTime(now), keyID); err != nil {
|
||||
return current, fmt.Errorf("更新额度设置: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE billing_cycles SET quota_micros=? WHERE managed_key_id=? AND sequence=?`,
|
||||
settings.QuotaMicros, keyID, current.CycleSequence); err != nil {
|
||||
return current, fmt.Errorf("更新当前额度周期: %w", err)
|
||||
}
|
||||
if settings.QuotaMicros != current.QuotaMicros {
|
||||
balance := settings.QuotaMicros - current.SpentMicros
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO billing_ledger (event_key, managed_key_id, cycle_sequence, kind, amount_micros,
|
||||
balance_after_micros, occurred_at, created_at) VALUES (?, ?, ?, 'quota_change', ?, ?, ?, ?)`,
|
||||
fmt.Sprintf("quota:%s:%d", keyID, now.UnixNano()), keyID, current.CycleSequence,
|
||||
settings.QuotaMicros-current.QuotaMicros, balance, formatTime(now), formatTime(now))
|
||||
if err != nil {
|
||||
return current, fmt.Errorf("记录额度调整: %w", err)
|
||||
}
|
||||
}
|
||||
state, err := scanBillingState(ctx, tx, keyID)
|
||||
if err != nil {
|
||||
return state, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return state, err
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (r *SQLiteUsageRepository) ResetBilling(ctx context.Context, keyID string, now time.Time) (managedaccess.BillingState, error) {
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return managedaccess.BillingState{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
var period string
|
||||
var anchorDay, sequence int64
|
||||
var quota int64
|
||||
if err := tx.QueryRowContext(ctx, `
|
||||
SELECT reset_period, reset_anchor_day, current_cycle_sequence, quota_micros
|
||||
FROM billing_accounts WHERE managed_key_id=?`, keyID).Scan(&period, &anchorDay, &sequence, "a); err != nil {
|
||||
return managedaccess.BillingState{}, err
|
||||
}
|
||||
var next *time.Time
|
||||
if period != managedaccess.ResetNone {
|
||||
value := advanceReset(now, period, int(now.In(shanghaiLocation()).Day()))
|
||||
next = &value
|
||||
anchorDay = int64(now.In(shanghaiLocation()).Day())
|
||||
}
|
||||
if err := resetBillingCycle(ctx, tx, keyID, sequence, quota, now, next, int(anchorDay)); err != nil {
|
||||
return managedaccess.BillingState{}, err
|
||||
}
|
||||
state, err := scanBillingState(ctx, tx, keyID)
|
||||
if err != nil {
|
||||
return state, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return state, err
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (r *SQLiteUsageRepository) AuthorizeBilling(ctx context.Context, keyID, requestID string, now time.Time) (managedaccess.BillingState, error) {
|
||||
requestID = strings.TrimSpace(requestID)
|
||||
if requestID == "" {
|
||||
return managedaccess.BillingState{}, errors.New("request_id 不能为空")
|
||||
}
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return managedaccess.BillingState{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
if err := ensureBillingReset(ctx, tx, keyID, now); err != nil {
|
||||
return managedaccess.BillingState{}, err
|
||||
}
|
||||
state, err := scanBillingState(ctx, tx, keyID)
|
||||
if err != nil {
|
||||
return state, err
|
||||
}
|
||||
var existingStatus string
|
||||
err = tx.QueryRowContext(ctx, `SELECT status FROM billing_admissions WHERE request_id=?`, requestID).Scan(&existingStatus)
|
||||
if err == nil {
|
||||
if err := tx.Commit(); err != nil {
|
||||
return state, err
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return state, err
|
||||
}
|
||||
if state.BalanceMicros <= 0 {
|
||||
return state, ErrBillingQuotaExhausted
|
||||
}
|
||||
if state.ActiveRequests >= state.MaxConcurrency {
|
||||
return state, ErrBillingConcurrency
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO billing_admissions (request_id, managed_key_id, cycle_sequence, opened_at, status)
|
||||
VALUES (?, ?, ?, ?, 'open')`, requestID, keyID, state.CycleSequence, formatTime(now)); err != nil {
|
||||
return state, fmt.Errorf("占用并发额度: %w", err)
|
||||
}
|
||||
state.ActiveRequests++
|
||||
if err := tx.Commit(); err != nil {
|
||||
return state, err
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (r *SQLiteUsageRepository) CompleteBillingAdmission(ctx context.Context, requestID string, now time.Time) error {
|
||||
if strings.TrimSpace(requestID) == "" {
|
||||
return nil
|
||||
}
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
UPDATE billing_admissions SET status='closed', closed_at=? WHERE request_id=? AND status='open'`,
|
||||
formatTime(now), requestID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *SQLiteUsageRepository) ReleaseStaleAdmissions(ctx context.Context, now time.Time) error {
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
UPDATE billing_admissions SET status='abandoned', closed_at=? WHERE status='open'`, formatTime(now))
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *SQLiteUsageRepository) ListBillingLedger(ctx context.Context, keyID string, beforeID int64, limit int) ([]managedaccess.LedgerEntry, error) {
|
||||
if limit < 1 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
query := `
|
||||
SELECT id, managed_key_id, cycle_sequence, kind, amount_micros, balance_after_micros,
|
||||
request_id, execution_id, model, occurred_at, created_at
|
||||
FROM billing_ledger WHERE managed_key_id=?`
|
||||
args := []any{keyID}
|
||||
if beforeID > 0 {
|
||||
query += ` AND id < ?`
|
||||
args = append(args, beforeID)
|
||||
}
|
||||
query += ` ORDER BY id DESC LIMIT ?`
|
||||
args = append(args, limit)
|
||||
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
entries := make([]managedaccess.LedgerEntry, 0, limit)
|
||||
for rows.Next() {
|
||||
var entry managedaccess.LedgerEntry
|
||||
var occurredRaw, createdRaw string
|
||||
if err := rows.Scan(&entry.ID, &entry.KeyID, &entry.CycleSequence, &entry.Kind,
|
||||
&entry.AmountMicros, &entry.BalanceAfterMicros, &entry.RequestID, &entry.ExecutionID,
|
||||
&entry.Model, &occurredRaw, &createdRaw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entry.OccurredAt, _ = time.Parse(time.RFC3339Nano, occurredRaw)
|
||||
entry.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdRaw)
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
return entries, rows.Err()
|
||||
}
|
||||
|
||||
func scanBillingState(ctx context.Context, tx *sql.Tx, keyID string) (managedaccess.BillingState, error) {
|
||||
var state managedaccess.BillingState
|
||||
var nextRaw sql.NullString
|
||||
var startedRaw string
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
SELECT a.managed_key_id, a.quota_micros, c.spent_micros, a.reset_period, a.next_reset_at,
|
||||
a.max_concurrency, a.current_cycle_sequence, c.started_at,
|
||||
(SELECT COUNT(*) FROM billing_admissions d WHERE d.managed_key_id=a.managed_key_id AND d.status='open')
|
||||
FROM billing_accounts a JOIN billing_cycles c
|
||||
ON c.managed_key_id=a.managed_key_id AND c.sequence=a.current_cycle_sequence
|
||||
WHERE a.managed_key_id=?`, keyID).Scan(&state.KeyID, &state.QuotaMicros, &state.SpentMicros,
|
||||
&state.ResetPeriod, &nextRaw, &state.MaxConcurrency, &state.CycleSequence,
|
||||
&startedRaw, &state.ActiveRequests)
|
||||
if err != nil {
|
||||
return state, err
|
||||
}
|
||||
state.BalanceMicros = state.QuotaMicros - state.SpentMicros
|
||||
state.CycleStartedAt, _ = time.Parse(time.RFC3339Nano, startedRaw)
|
||||
if nextRaw.Valid && nextRaw.String != "" {
|
||||
value, err := time.Parse(time.RFC3339Nano, nextRaw.String)
|
||||
if err == nil {
|
||||
state.NextResetAt = &value
|
||||
}
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func ensureBillingReset(ctx context.Context, tx *sql.Tx, keyID string, now time.Time) error {
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
var period string
|
||||
var nextRaw sql.NullString
|
||||
var anchorDay int
|
||||
var sequence, quota int64
|
||||
if err := tx.QueryRowContext(ctx, `
|
||||
SELECT reset_period, next_reset_at, reset_anchor_day, current_cycle_sequence, quota_micros
|
||||
FROM billing_accounts WHERE managed_key_id=?`, keyID).Scan(&period, &nextRaw, &anchorDay, &sequence, "a); err != nil {
|
||||
return err
|
||||
}
|
||||
if period == managedaccess.ResetNone || !nextRaw.Valid || nextRaw.String == "" {
|
||||
return nil
|
||||
}
|
||||
next, err := time.Parse(time.RFC3339Nano, nextRaw.String)
|
||||
if err != nil || now.Before(next) {
|
||||
return err
|
||||
}
|
||||
boundary := next
|
||||
future := advanceReset(next, period, anchorDay)
|
||||
for !future.After(now) {
|
||||
boundary = future
|
||||
future = advanceReset(future, period, anchorDay)
|
||||
}
|
||||
return resetBillingCycle(ctx, tx, keyID, sequence, quota, boundary, &future, anchorDay)
|
||||
}
|
||||
|
||||
func resetBillingCycle(ctx context.Context, tx *sql.Tx, keyID string, sequence, quota int64, boundary time.Time, next *time.Time, anchorDay int) error {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE billing_cycles SET ended_at=? WHERE managed_key_id=? AND sequence=?`, formatTime(boundary), keyID, sequence); err != nil {
|
||||
return err
|
||||
}
|
||||
newSequence := sequence + 1
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO billing_cycles (managed_key_id, sequence, started_at, quota_micros, spent_micros)
|
||||
VALUES (?, ?, ?, ?, 0)`, keyID, newSequence, formatTime(boundary), quota); err != nil {
|
||||
return err
|
||||
}
|
||||
nextValue := any(nil)
|
||||
if next != nil {
|
||||
nextValue = formatTime(*next)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE billing_accounts SET current_cycle_sequence=?, next_reset_at=?, reset_anchor_day=?, updated_at=?
|
||||
WHERE managed_key_id=?`, newSequence, nextValue, anchorDay, formatTime(boundary), keyID); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT OR IGNORE INTO billing_ledger (event_key, managed_key_id, cycle_sequence, kind, amount_micros,
|
||||
balance_after_micros, occurred_at, created_at) VALUES (?, ?, ?, 'cycle_reset', ?, ?, ?, ?)`,
|
||||
fmt.Sprintf("reset:%s:%d", keyID, newSequence), keyID, newSequence, quota, quota,
|
||||
formatTime(boundary), formatTime(time.Now().UTC()))
|
||||
return err
|
||||
}
|
||||
|
||||
func validResetPeriod(period string) bool {
|
||||
switch period {
|
||||
case managedaccess.ResetNone, managedaccess.ResetDaily, managedaccess.ResetWeekly, managedaccess.ResetMonthly:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func shanghaiLocation() *time.Location {
|
||||
location, err := time.LoadLocation("Asia/Shanghai")
|
||||
if err != nil {
|
||||
return time.FixedZone("Asia/Shanghai", 8*60*60)
|
||||
}
|
||||
return location
|
||||
}
|
||||
|
||||
func nextResetAfter(anchor time.Time, period string, now time.Time) time.Time {
|
||||
if anchor.IsZero() {
|
||||
anchor = now
|
||||
}
|
||||
anchorDay := anchor.In(shanghaiLocation()).Day()
|
||||
next := advanceReset(anchor, period, anchorDay)
|
||||
for !next.After(now) {
|
||||
next = advanceReset(next, period, anchorDay)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func advanceReset(value time.Time, period string, anchorDay int) time.Time {
|
||||
location := shanghaiLocation()
|
||||
local := value.In(location)
|
||||
switch period {
|
||||
case managedaccess.ResetDaily:
|
||||
return local.AddDate(0, 0, 1).UTC()
|
||||
case managedaccess.ResetWeekly:
|
||||
return local.AddDate(0, 0, 7).UTC()
|
||||
case managedaccess.ResetMonthly:
|
||||
year, month := local.Year(), local.Month()+1
|
||||
if month > 12 {
|
||||
year++
|
||||
month = 1
|
||||
}
|
||||
lastDay := time.Date(year, month+1, 0, local.Hour(), local.Minute(), local.Second(), local.Nanosecond(), location).Day()
|
||||
day := anchorDay
|
||||
if day < 1 {
|
||||
day = local.Day()
|
||||
}
|
||||
if day > lastDay {
|
||||
day = lastDay
|
||||
}
|
||||
return time.Date(year, month, day, local.Hour(), local.Minute(), local.Second(), local.Nanosecond(), location).UTC()
|
||||
default:
|
||||
return time.Time{}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
managedaccess "cpa-ext/internal/access"
|
||||
"cpa-ext/internal/collection"
|
||||
)
|
||||
|
||||
func TestBillingQuotaConcurrencySettlementAndReset(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, err := OpenSQLiteUsage(filepath.Join(t.TempDir(), "billing.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
if _, err := store.BootstrapManagedKey(ctx, "default", "000000"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now().UTC().Add(time.Second)
|
||||
state, err := store.BillingState(ctx, "key_default", now)
|
||||
if err != nil || state.QuotaMicros != 0 || state.MaxConcurrency != 4 {
|
||||
t.Fatalf("initial billing state=%+v err=%v", state, err)
|
||||
}
|
||||
if _, err := store.AuthorizeBilling(ctx, "key_default", "zero", now); !errors.Is(err, ErrBillingQuotaExhausted) {
|
||||
t.Fatalf("zero quota authorization err=%v", err)
|
||||
}
|
||||
state, err = store.UpdateBilling(ctx, "key_default", managedaccess.BillingSettings{
|
||||
QuotaMicros: 1_000_000, ResetPeriod: managedaccess.ResetNone, MaxConcurrency: 2,
|
||||
}, now)
|
||||
if err != nil || state.BalanceMicros != 1_000_000 {
|
||||
t.Fatalf("updated billing state=%+v err=%v", state, err)
|
||||
}
|
||||
for _, requestID := range []string{"one", "two"} {
|
||||
if _, err := store.AuthorizeBilling(ctx, "key_default", requestID, now); err != nil {
|
||||
t.Fatalf("authorize %s: %v", requestID, err)
|
||||
}
|
||||
}
|
||||
if _, err := store.AuthorizeBilling(ctx, "key_default", "three", now); !errors.Is(err, ErrBillingConcurrency) {
|
||||
t.Fatalf("third concurrent authorization err=%v", err)
|
||||
}
|
||||
if err := store.CompleteBillingAdmission(ctx, "one", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.AuthorizeBilling(ctx, "key_default", "three", now); err != nil {
|
||||
t.Fatalf("released slot not reusable: %v", err)
|
||||
}
|
||||
|
||||
cost := int64(600_000)
|
||||
record := collection.Record{ManagedKeyID: "key_default", ExecutionID: "exec-1", RequestID: "one",
|
||||
RequestedAt: now, Model: "deepseek-v4-flash", CostMicros: &cost}
|
||||
if err := store.Insert(ctx, record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.Insert(ctx, record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state, _ = store.BillingState(ctx, "key_default", now)
|
||||
if state.SpentMicros != 600_000 || state.BalanceMicros != 400_000 {
|
||||
t.Fatalf("duplicate settlement changed balance: %+v", state)
|
||||
}
|
||||
cost = 500_000
|
||||
record.ExecutionID = "exec-2"
|
||||
record.CostMicros = &cost
|
||||
if err := store.Insert(ctx, record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state, _ = store.BillingState(ctx, "key_default", now)
|
||||
if state.BalanceMicros != -100_000 {
|
||||
t.Fatalf("soft quota did not preserve negative balance: %+v", state)
|
||||
}
|
||||
if _, err := store.AuthorizeBilling(ctx, "key_default", "blocked", now); !errors.Is(err, ErrBillingQuotaExhausted) {
|
||||
t.Fatalf("negative balance authorization err=%v", err)
|
||||
}
|
||||
state, err = store.ResetBilling(ctx, "key_default", now.Add(time.Minute))
|
||||
if err != nil || state.SpentMicros != 0 || state.BalanceMicros != 1_000_000 || state.CycleSequence != 2 {
|
||||
t.Fatalf("manual reset state=%+v err=%v", state, err)
|
||||
}
|
||||
entries, err := store.ListBillingLedger(ctx, "key_default", 0, 50)
|
||||
if err != nil || len(entries) != 4 {
|
||||
t.Fatalf("ledger=%+v err=%v", entries, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBillingAutomaticMonthlyResetPreservesAnchorDay(t *testing.T) {
|
||||
location := shanghaiLocation()
|
||||
anchor := time.Date(2026, time.January, 31, 10, 0, 0, 0, location)
|
||||
february := advanceReset(anchor, managedaccess.ResetMonthly, 31).In(location)
|
||||
march := advanceReset(february, managedaccess.ResetMonthly, 31).In(location)
|
||||
if february.Day() != 28 || february.Month() != time.February || march.Day() != 31 || march.Month() != time.March {
|
||||
t.Fatalf("monthly sequence: %s then %s", february, march)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cpa-ext/internal/collection"
|
||||
)
|
||||
|
||||
const requestDetailProjectionMigration = "request-detail-index-v1"
|
||||
|
||||
func (r *SQLiteUsageRepository) ensureRequestDetailProjection(ctx context.Context) error {
|
||||
var completed string
|
||||
err := r.db.QueryRowContext(ctx, `SELECT completed_at FROM cpa_ext_migrations WHERE name=?`, requestDetailProjectionMigration).Scan(&completed)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("检查请求明细索引迁移: %w", err)
|
||||
}
|
||||
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("开始请求明细索引迁移: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM request_detail_index`); err != nil {
|
||||
return fmt.Errorf("清理请求明细索引: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, usageProjectionInsertSQL+`
|
||||
SELECT u.id, CASE WHEN r.request_id IS NULL THEN '' ELSE r.request_id END,
|
||||
u.requested_at, COALESCE(NULLIF(u.managed_key_id, ''), r.managed_key_id, ''),
|
||||
u.model,
|
||||
CASE WHEN u.failed=1 THEN 'failed'
|
||||
WHEN r.outcome IN ('failed','rejected','canceled') THEN r.outcome
|
||||
ELSE 'succeeded' END,
|
||||
u.auth_id, `+endpointKindSQL("COALESCE(NULLIF(u.endpoint, ''), r.endpoint, '')")+`,
|
||||
COALESCE(NULLIF(u.request_id, ''), r.request_id, '')
|
||||
FROM usage_records u
|
||||
LEFT JOIN request_records r ON u.request_id <> '' AND r.request_id=u.request_id`); err != nil {
|
||||
return fmt.Errorf("回填用量请求明细索引: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, lifecycleProjectionInsertSQL+`
|
||||
SELECT NULL, r.request_id, r.requested_at, r.managed_key_id,
|
||||
COALESCE(NULLIF(r.model, ''), r.requested_model),
|
||||
CASE WHEN r.outcome IN ('failed','rejected','canceled') THEN r.outcome ELSE 'succeeded' END,
|
||||
'', `+endpointKindSQL("r.endpoint")+`, r.request_id
|
||||
FROM request_records r
|
||||
WHERE NOT EXISTS (SELECT 1 FROM usage_records u WHERE u.request_id=r.request_id)`); err != nil {
|
||||
return fmt.Errorf("回填终态请求明细索引: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("提交请求明细索引迁移: %w", err)
|
||||
}
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT requested_at FROM usage_records WHERE request_id='' ORDER BY requested_at`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("扫描孤立用量时间: %w", err)
|
||||
}
|
||||
var orphanTimes []time.Time
|
||||
for rows.Next() {
|
||||
var raw string
|
||||
if err := rows.Scan(&raw); err != nil {
|
||||
_ = rows.Close()
|
||||
return fmt.Errorf("读取孤立用量时间: %w", err)
|
||||
}
|
||||
parsed, parseErr := time.Parse(time.RFC3339Nano, raw)
|
||||
if parseErr != nil {
|
||||
_ = rows.Close()
|
||||
return fmt.Errorf("解析孤立用量时间: %w", parseErr)
|
||||
}
|
||||
orphanTimes = append(orphanTimes, parsed)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return fmt.Errorf("关闭孤立用量扫描: %w", err)
|
||||
}
|
||||
var last time.Time
|
||||
for _, candidate := range orphanTimes {
|
||||
if !last.IsZero() && candidate.Sub(last) < orphanLifecycleMatchWindow {
|
||||
continue
|
||||
}
|
||||
if err := r.reconcileOrphanProjection(ctx, candidate); err != nil {
|
||||
return err
|
||||
}
|
||||
last = candidate
|
||||
}
|
||||
if _, err := r.db.ExecContext(ctx, `INSERT OR REPLACE INTO cpa_ext_migrations(name, completed_at) VALUES(?, ?)`, requestDetailProjectionMigration, formatTime(time.Now().UTC())); err != nil {
|
||||
return fmt.Errorf("完成请求明细索引迁移: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const usageProjectionInsertSQL = `
|
||||
INSERT OR IGNORE INTO request_detail_index
|
||||
(usage_id, lifecycle_request_id, requested_at, managed_key_id, model, result, auth_id, endpoint_kind, request_id) `
|
||||
|
||||
const lifecycleProjectionInsertSQL = `
|
||||
INSERT OR IGNORE INTO request_detail_index
|
||||
(usage_id, lifecycle_request_id, requested_at, managed_key_id, model, result, auth_id, endpoint_kind, request_id) `
|
||||
|
||||
func endpointKindSQL(expression string) string {
|
||||
return `CASE
|
||||
WHEN LOWER(TRIM(` + expression + `)) LIKE '%/responses/compact' THEN 'compact'
|
||||
WHEN LOWER(TRIM(` + expression + `)) LIKE '%/chat/completions' THEN 'chat'
|
||||
WHEN LOWER(TRIM(` + expression + `)) LIKE '%/responses' THEN 'responses'
|
||||
ELSE LOWER(TRIM(` + expression + `)) END`
|
||||
}
|
||||
|
||||
func endpointKind(value string) string {
|
||||
path := strings.ToLower(strings.TrimSpace(value))
|
||||
if fields := strings.Fields(path); len(fields) > 1 {
|
||||
path = fields[len(fields)-1]
|
||||
}
|
||||
if index := strings.IndexByte(path, '?'); index >= 0 {
|
||||
path = path[:index]
|
||||
}
|
||||
path = strings.TrimRight(path, "/")
|
||||
switch {
|
||||
case strings.HasSuffix(path, "/responses/compact"):
|
||||
return "compact"
|
||||
case strings.HasSuffix(path, "/chat/completions"):
|
||||
return "chat"
|
||||
case strings.HasSuffix(path, "/responses"):
|
||||
return "responses"
|
||||
default:
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
func usageResult(failed bool, outcome string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(outcome)) {
|
||||
case collection.UsageResultCanceled:
|
||||
return collection.UsageResultCanceled
|
||||
case collection.UsageResultRejected:
|
||||
return collection.UsageResultRejected
|
||||
case collection.UsageResultFailed:
|
||||
return collection.UsageResultFailed
|
||||
}
|
||||
if failed {
|
||||
return collection.UsageResultFailed
|
||||
}
|
||||
return collection.UsageResultSucceeded
|
||||
}
|
||||
|
||||
func insertUsageProjection(ctx context.Context, tx *sql.Tx, usageID int64) error {
|
||||
_, err := tx.ExecContext(ctx, usageProjectionInsertSQL+`
|
||||
SELECT u.id, CASE WHEN r.request_id IS NULL THEN '' ELSE r.request_id END,
|
||||
u.requested_at, COALESCE(NULLIF(u.managed_key_id, ''), r.managed_key_id, ''),
|
||||
u.model,
|
||||
CASE WHEN u.failed=1 THEN 'failed'
|
||||
WHEN r.outcome IN ('failed','rejected','canceled') THEN r.outcome
|
||||
ELSE 'succeeded' END,
|
||||
u.auth_id, `+endpointKindSQL("COALESCE(NULLIF(u.endpoint, ''), r.endpoint, '')")+`,
|
||||
COALESCE(NULLIF(u.request_id, ''), r.request_id, '')
|
||||
FROM usage_records u
|
||||
LEFT JOIN request_records r ON u.request_id <> '' AND r.request_id=u.request_id
|
||||
WHERE u.id=?`, usageID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("写入请求明细索引: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncRequestProjection(ctx context.Context, tx *sql.Tx, requestID string) (bool, error) {
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
UPDATE request_detail_index
|
||||
SET lifecycle_request_id=?, request_id=?,
|
||||
managed_key_id=COALESCE(NULLIF((SELECT managed_key_id FROM usage_records WHERE id=request_detail_index.usage_id), ''),
|
||||
(SELECT managed_key_id FROM request_records WHERE request_id=?), ''),
|
||||
result=CASE
|
||||
WHEN (SELECT failed FROM usage_records WHERE id=request_detail_index.usage_id)=1 THEN 'failed'
|
||||
WHEN (SELECT outcome FROM request_records WHERE request_id=?) IN ('failed','rejected','canceled')
|
||||
THEN (SELECT outcome FROM request_records WHERE request_id=?)
|
||||
ELSE 'succeeded' END
|
||||
WHERE usage_id IN (SELECT id FROM usage_records WHERE request_id=?)`, requestID, requestID, requestID, requestID, requestID, requestID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("关联请求终态索引: %w", err)
|
||||
}
|
||||
linked, _ := result.RowsAffected()
|
||||
if linked > 0 {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE request_detail_index SET endpoint_kind=`+endpointKindSQL(`COALESCE(NULLIF((SELECT endpoint FROM usage_records WHERE id=request_detail_index.usage_id), ''), (SELECT endpoint FROM request_records WHERE request_id=?), '')`)+` WHERE usage_id IN (SELECT id FROM usage_records WHERE request_id=?)`, requestID, requestID, requestID, requestID, requestID); err != nil {
|
||||
return false, fmt.Errorf("更新请求端点索引: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM request_detail_index WHERE usage_id IS NULL AND lifecycle_request_id=?`, requestID); err != nil {
|
||||
return false, fmt.Errorf("删除重复请求终态索引: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, lifecycleProjectionInsertSQL+`
|
||||
SELECT NULL, r.request_id, r.requested_at, r.managed_key_id,
|
||||
COALESCE(NULLIF(r.model, ''), r.requested_model),
|
||||
CASE WHEN r.outcome IN ('failed','rejected','canceled') THEN r.outcome ELSE 'succeeded' END,
|
||||
'', `+endpointKindSQL("r.endpoint")+`, r.request_id
|
||||
FROM request_records r WHERE r.request_id=?`, requestID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("写入终态请求明细索引: %w", err)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
type orphanUsageProjection struct {
|
||||
ID int64
|
||||
ManagedKeyID string
|
||||
RequestedAt time.Time
|
||||
Model string
|
||||
Failed bool
|
||||
AuthID string
|
||||
Endpoint string
|
||||
}
|
||||
|
||||
type orphanLifecycleProjection struct {
|
||||
RequestID string
|
||||
ManagedKeyID string
|
||||
RequestedAt time.Time
|
||||
Model string
|
||||
Outcome string
|
||||
Endpoint string
|
||||
}
|
||||
|
||||
func (r *SQLiteUsageRepository) reconcileOrphanProjection(ctx context.Context, at time.Time) error {
|
||||
window := time.Second
|
||||
from, to := formatTime(at.Add(-window)), formatTime(at.Add(window))
|
||||
usageRows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, managed_key_id, requested_at, model, failed, auth_id, endpoint
|
||||
FROM usage_records WHERE request_id='' AND requested_at BETWEEN ? AND ?`, from, to)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查询孤立用量候选: %w", err)
|
||||
}
|
||||
var usages []orphanUsageProjection
|
||||
for usageRows.Next() {
|
||||
var item orphanUsageProjection
|
||||
var requestedAt string
|
||||
if err := usageRows.Scan(&item.ID, &item.ManagedKeyID, &requestedAt, &item.Model, &item.Failed, &item.AuthID, &item.Endpoint); err != nil {
|
||||
_ = usageRows.Close()
|
||||
return fmt.Errorf("读取孤立用量候选: %w", err)
|
||||
}
|
||||
item.RequestedAt, err = time.Parse(time.RFC3339Nano, requestedAt)
|
||||
if err != nil {
|
||||
_ = usageRows.Close()
|
||||
return fmt.Errorf("解析孤立用量候选时间: %w", err)
|
||||
}
|
||||
usages = append(usages, item)
|
||||
}
|
||||
_ = usageRows.Close()
|
||||
|
||||
lifecycleRows, err := r.db.QueryContext(ctx, `
|
||||
SELECT r.request_id, r.managed_key_id, r.requested_at,
|
||||
COALESCE(NULLIF(r.model, ''), r.requested_model), r.outcome, r.endpoint
|
||||
FROM request_records r
|
||||
WHERE r.requested_at BETWEEN ? AND ?
|
||||
AND NOT EXISTS (SELECT 1 FROM usage_records u WHERE u.request_id=r.request_id)`, from, to)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查询孤立终态候选: %w", err)
|
||||
}
|
||||
var lifecycles []orphanLifecycleProjection
|
||||
for lifecycleRows.Next() {
|
||||
var item orphanLifecycleProjection
|
||||
var requestedAt string
|
||||
if err := lifecycleRows.Scan(&item.RequestID, &item.ManagedKeyID, &requestedAt, &item.Model, &item.Outcome, &item.Endpoint); err != nil {
|
||||
_ = lifecycleRows.Close()
|
||||
return fmt.Errorf("读取孤立终态候选: %w", err)
|
||||
}
|
||||
item.RequestedAt, err = time.Parse(time.RFC3339Nano, requestedAt)
|
||||
if err != nil {
|
||||
_ = lifecycleRows.Close()
|
||||
return fmt.Errorf("解析孤立终态候选时间: %w", err)
|
||||
}
|
||||
lifecycles = append(lifecycles, item)
|
||||
}
|
||||
_ = lifecycleRows.Close()
|
||||
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("开始重建孤立请求索引: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
for _, usage := range usages {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE request_detail_index SET lifecycle_request_id='', request_id='', managed_key_id=?, model=?, result=?, auth_id=?, endpoint_kind=? WHERE usage_id=?`, usage.ManagedKeyID, usage.Model, usageResult(usage.Failed, ""), usage.AuthID, endpointKind(usage.Endpoint), usage.ID); err != nil {
|
||||
return fmt.Errorf("重置孤立用量索引: %w", err)
|
||||
}
|
||||
}
|
||||
for _, lifecycle := range lifecycles {
|
||||
if _, err := tx.ExecContext(ctx, lifecycleProjectionInsertSQL+`VALUES(NULL, ?, ?, ?, ?, ?, '', ?, ?)`, lifecycle.RequestID, formatTime(lifecycle.RequestedAt), lifecycle.ManagedKeyID, lifecycle.Model, usageResult(false, lifecycle.Outcome), endpointKind(lifecycle.Endpoint), lifecycle.RequestID); err != nil {
|
||||
return fmt.Errorf("恢复孤立终态索引: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
usageMatches := make(map[int]lifecycleMatch)
|
||||
requestMatches := make(map[int]lifecycleMatch)
|
||||
for usageIndex, usage := range usages {
|
||||
for requestIndex, lifecycle := range lifecycles {
|
||||
if !sameOrphanProjection(usage, lifecycle) {
|
||||
continue
|
||||
}
|
||||
distance := usage.RequestedAt.Sub(lifecycle.RequestedAt)
|
||||
if distance < 0 {
|
||||
distance = -distance
|
||||
}
|
||||
if distance > orphanLifecycleMatchWindow {
|
||||
continue
|
||||
}
|
||||
if current, ok := usageMatches[usageIndex]; ok {
|
||||
usageMatches[usageIndex] = betterLifecycleMatch(current, requestIndex, distance)
|
||||
} else {
|
||||
usageMatches[usageIndex] = lifecycleMatch{index: requestIndex, distance: distance}
|
||||
}
|
||||
if current, ok := requestMatches[requestIndex]; ok {
|
||||
requestMatches[requestIndex] = betterLifecycleMatch(current, usageIndex, distance)
|
||||
} else {
|
||||
requestMatches[requestIndex] = lifecycleMatch{index: usageIndex, distance: distance}
|
||||
}
|
||||
}
|
||||
}
|
||||
for usageIndex, requestMatch := range usageMatches {
|
||||
usageMatch, ok := requestMatches[requestMatch.index]
|
||||
if requestMatch.ambiguous || !ok || usageMatch.ambiguous || usageMatch.index != usageIndex {
|
||||
continue
|
||||
}
|
||||
usage, lifecycle := usages[usageIndex], lifecycles[requestMatch.index]
|
||||
managedKeyID := usage.ManagedKeyID
|
||||
if managedKeyID == "" {
|
||||
managedKeyID = lifecycle.ManagedKeyID
|
||||
}
|
||||
endpoint := usage.Endpoint
|
||||
if strings.TrimSpace(endpoint) == "" {
|
||||
endpoint = lifecycle.Endpoint
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE request_detail_index SET lifecycle_request_id=?, request_id=?, managed_key_id=?, result=?, endpoint_kind=? WHERE usage_id=?`, lifecycle.RequestID, lifecycle.RequestID, managedKeyID, usageResult(usage.Failed, lifecycle.Outcome), endpointKind(endpoint), usage.ID); err != nil {
|
||||
return fmt.Errorf("合并孤立请求索引: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM request_detail_index WHERE usage_id IS NULL AND lifecycle_request_id=?`, lifecycle.RequestID); err != nil {
|
||||
return fmt.Errorf("删除已合并终态索引: %w", err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("提交孤立请求索引: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sameOrphanProjection(usage orphanUsageProjection, lifecycle orphanLifecycleProjection) bool {
|
||||
if usage.ManagedKeyID != "" && lifecycle.ManagedKeyID != "" {
|
||||
return usage.ManagedKeyID == lifecycle.ManagedKeyID
|
||||
}
|
||||
return strings.TrimSpace(usage.Model) != "" && strings.EqualFold(strings.TrimSpace(usage.Model), strings.TrimSpace(lifecycle.Model))
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cpa-ext/internal/collection"
|
||||
)
|
||||
|
||||
type usageCursor struct {
|
||||
Version int `json:"v"`
|
||||
RequestedAt string `json:"t"`
|
||||
ID int64 `json:"id"`
|
||||
Direction string `json:"d"`
|
||||
Page int `json:"p"`
|
||||
Signature string `json:"s"`
|
||||
}
|
||||
|
||||
type detailKey struct {
|
||||
ID int64
|
||||
RequestedAt string
|
||||
}
|
||||
|
||||
func normalizeUsageQuery(query collection.UsageQuery) (collection.UsageQuery, error) {
|
||||
if query.Page < 1 {
|
||||
query.Page = 1
|
||||
}
|
||||
if query.PageSize < 1 {
|
||||
query.PageSize = 100
|
||||
}
|
||||
if query.PageSize > 100 {
|
||||
return query, errors.New("page_size 不能超过 100")
|
||||
}
|
||||
query.KeyID = strings.TrimSpace(query.KeyID)
|
||||
query.Model = strings.TrimSpace(query.Model)
|
||||
query.Result = strings.ToLower(strings.TrimSpace(query.Result))
|
||||
query.AuthID = strings.TrimSpace(query.AuthID)
|
||||
query.Endpoint = endpointKind(query.Endpoint)
|
||||
query.RequestID = strings.TrimSpace(query.RequestID)
|
||||
if query.From != nil && query.To != nil && !query.From.Before(*query.To) {
|
||||
return query, errors.New("from 必须早于 to")
|
||||
}
|
||||
if query.Result != "" && query.Result != collection.UsageResultSucceeded && query.Result != collection.UsageResultFailed && query.Result != collection.UsageResultRejected && query.Result != collection.UsageResultCanceled {
|
||||
return query, errors.New("result 必须是 succeeded、failed、rejected 或 canceled")
|
||||
}
|
||||
return query, nil
|
||||
}
|
||||
|
||||
func usageQuerySignature(query collection.UsageQuery) string {
|
||||
payload := struct {
|
||||
PageSize int
|
||||
From string
|
||||
To string
|
||||
KeyID string
|
||||
Model string
|
||||
Result string
|
||||
AuthID string
|
||||
Endpoint string
|
||||
RequestID string
|
||||
}{PageSize: query.PageSize, KeyID: query.KeyID, Model: strings.ToLower(query.Model), Result: query.Result, AuthID: query.AuthID, Endpoint: query.Endpoint, RequestID: query.RequestID}
|
||||
if query.From != nil {
|
||||
payload.From = formatTime(*query.From)
|
||||
}
|
||||
if query.To != nil {
|
||||
payload.To = formatTime(*query.To)
|
||||
}
|
||||
raw, _ := json.Marshal(payload)
|
||||
sum := sha256.Sum256(raw)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func encodeUsageCursor(cursor usageCursor) string {
|
||||
raw, _ := json.Marshal(cursor)
|
||||
return base64.RawURLEncoding.EncodeToString(raw)
|
||||
}
|
||||
|
||||
func decodeUsageCursor(raw, signature string) (usageCursor, error) {
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return usageCursor{}, errors.New("cursor 无效")
|
||||
}
|
||||
var cursor usageCursor
|
||||
if err := json.Unmarshal(decoded, &cursor); err != nil || cursor.Version != 1 || cursor.ID < 1 || cursor.RequestedAt == "" || cursor.Page < 1 || (cursor.Direction != "next" && cursor.Direction != "previous") {
|
||||
return usageCursor{}, errors.New("cursor 无效")
|
||||
}
|
||||
if cursor.Signature != signature {
|
||||
return usageCursor{}, errors.New("cursor 与当前筛选条件不匹配")
|
||||
}
|
||||
return cursor, nil
|
||||
}
|
||||
|
||||
func usageWhere(query collection.UsageQuery) (string, []any) {
|
||||
conditions := []string{"1=1"}
|
||||
args := make([]any, 0, 8)
|
||||
if query.From != nil {
|
||||
conditions = append(conditions, "d.requested_at>=?")
|
||||
args = append(args, formatTime(*query.From))
|
||||
}
|
||||
if query.To != nil {
|
||||
conditions = append(conditions, "d.requested_at<?")
|
||||
args = append(args, formatTime(*query.To))
|
||||
}
|
||||
if query.KeyID != "" {
|
||||
conditions = append(conditions, "d.managed_key_id=?")
|
||||
args = append(args, query.KeyID)
|
||||
}
|
||||
if query.Model != "" {
|
||||
conditions = append(conditions, "d.model=? COLLATE NOCASE")
|
||||
args = append(args, query.Model)
|
||||
}
|
||||
if query.Result != "" {
|
||||
conditions = append(conditions, "d.result=?")
|
||||
args = append(args, query.Result)
|
||||
}
|
||||
if query.AuthID != "" {
|
||||
conditions = append(conditions, "d.auth_id=?")
|
||||
args = append(args, query.AuthID)
|
||||
}
|
||||
if query.Endpoint != "" {
|
||||
conditions = append(conditions, "d.endpoint_kind=?")
|
||||
args = append(args, query.Endpoint)
|
||||
}
|
||||
if query.RequestID != "" {
|
||||
conditions = append(conditions, "d.request_id=?")
|
||||
args = append(args, query.RequestID)
|
||||
}
|
||||
return strings.Join(conditions, " AND "), args
|
||||
}
|
||||
|
||||
func (r *SQLiteUsageRepository) QueryUsage(ctx context.Context, input collection.UsageQuery) (collection.UsagePage, error) {
|
||||
query, err := normalizeUsageQuery(input)
|
||||
if err != nil {
|
||||
return collection.UsagePage{}, err
|
||||
}
|
||||
where, args := usageWhere(query)
|
||||
var total int64
|
||||
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM request_detail_index d WHERE `+where, args...).Scan(&total); err != nil {
|
||||
return collection.UsagePage{}, fmt.Errorf("统计请求明细: %w", err)
|
||||
}
|
||||
totalPages := 0
|
||||
if total > 0 {
|
||||
totalPages = int(math.Ceil(float64(total) / float64(query.PageSize)))
|
||||
}
|
||||
signature := usageQuerySignature(query)
|
||||
page := query.Page
|
||||
order := "d.requested_at DESC, d.id DESC"
|
||||
limitArgs := append([]any{}, args...)
|
||||
if query.Cursor != "" {
|
||||
cursor, cursorErr := decodeUsageCursor(query.Cursor, signature)
|
||||
if cursorErr != nil {
|
||||
return collection.UsagePage{}, cursorErr
|
||||
}
|
||||
page = cursor.Page
|
||||
if cursor.Direction == "next" {
|
||||
where += " AND (d.requested_at<? OR (d.requested_at=? AND d.id<?))"
|
||||
limitArgs = append(limitArgs, cursor.RequestedAt, cursor.RequestedAt, cursor.ID)
|
||||
} else {
|
||||
where += " AND (d.requested_at>? OR (d.requested_at=? AND d.id>?))"
|
||||
limitArgs = append(limitArgs, cursor.RequestedAt, cursor.RequestedAt, cursor.ID)
|
||||
order = "d.requested_at ASC, d.id ASC"
|
||||
}
|
||||
} else {
|
||||
limitArgs = append(limitArgs, query.PageSize, (page-1)*query.PageSize)
|
||||
}
|
||||
|
||||
statement := usageDetailSelectSQL + " WHERE " + where + " ORDER BY " + order
|
||||
if query.Cursor != "" {
|
||||
statement += " LIMIT ?"
|
||||
limitArgs = append(limitArgs, query.PageSize)
|
||||
} else {
|
||||
statement += " LIMIT ? OFFSET ?"
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx, statement, limitArgs...)
|
||||
if err != nil {
|
||||
return collection.UsagePage{}, fmt.Errorf("查询请求明细: %w", err)
|
||||
}
|
||||
records, keys, err := scanUsageDetailRows(rows)
|
||||
if err != nil {
|
||||
return collection.UsagePage{}, err
|
||||
}
|
||||
if order == "d.requested_at ASC, d.id ASC" {
|
||||
reverseRecords(records)
|
||||
reverseKeys(keys)
|
||||
}
|
||||
result := collection.UsagePage{Records: records, Page: page, PageSize: query.PageSize, Total: total, TotalPages: totalPages}
|
||||
if len(keys) > 0 {
|
||||
if page > 1 {
|
||||
result.PreviousCursor = encodeUsageCursor(usageCursor{Version: 1, RequestedAt: keys[0].RequestedAt, ID: keys[0].ID, Direction: "previous", Page: page - 1, Signature: signature})
|
||||
}
|
||||
if page < totalPages {
|
||||
last := keys[len(keys)-1]
|
||||
result.NextCursor = encodeUsageCursor(usageCursor{Version: 1, RequestedAt: last.RequestedAt, ID: last.ID, Direction: "next", Page: page + 1, Signature: signature})
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
const usageDetailSelectSQL = `
|
||||
SELECT d.id, d.requested_at,
|
||||
d.managed_key_id, d.request_id, COALESCE(u.execution_id, ''),
|
||||
COALESCE(NULLIF(u.trace_id, ''), r.trace_id, ''), COALESCE(u.api_key, ''),
|
||||
COALESCE(k.name, u.key_alias, ''), COALESCE(u.auth_id, ''), COALESCE(u.auth_index, ''), COALESCE(u.auth_type, ''),
|
||||
COALESCE(NULLIF(u.model, ''), NULLIF(r.model, ''), r.requested_model, d.model, ''),
|
||||
COALESCE(u.reasoning_effort, ''), COALESCE(u.service_tier, ''), COALESCE(u.speed, ''),
|
||||
CASE WHEN d.result='succeeded' THEN 0 ELSE 1 END,
|
||||
COALESCE(u.executor_type, ''),
|
||||
CASE WHEN r.request_id IS NOT NULL THEN CASE WHEN r.stream THEN 'SSE' ELSE 'JSON' END ELSE COALESCE(u.request_type, '') END,
|
||||
COALESCE(NULLIF(u.endpoint, ''), r.endpoint, ''),
|
||||
COALESCE(u.input_tokens, 0), COALESCE(u.output_tokens, 0), COALESCE(u.reasoning_tokens, 0), COALESCE(u.cached_tokens, 0),
|
||||
COALESCE(u.cache_read_tokens, 0), COALESCE(u.cache_write_tokens, 0), COALESCE(u.total_tokens, 0),
|
||||
COALESCE(u.ttft_ns, 0), COALESCE(u.latency_ns, r.latency_ns, 0), u.cost_micros,
|
||||
COALESCE(u.price_tier, ''), COALESCE(u.fast_requested, 0), COALESCE(u.fast_pricing_applied, 0),
|
||||
COALESCE(u.price_multiplier_numerator, 1), COALESCE(u.price_multiplier_denominator, 1), COALESCE(u.client_ip, ''),
|
||||
COALESCE(r.outcome, ''), COALESCE(r.status_code, 0), COALESCE(r.error, '')
|
||||
FROM request_detail_index d
|
||||
LEFT JOIN usage_records u ON u.id=d.usage_id
|
||||
LEFT JOIN request_records r ON r.request_id=d.lifecycle_request_id
|
||||
LEFT JOIN managed_keys k ON k.id=d.managed_key_id`
|
||||
|
||||
func scanUsageDetailRows(rows *sql.Rows) ([]collection.Record, []detailKey, error) {
|
||||
defer rows.Close()
|
||||
var records []collection.Record
|
||||
var keys []detailKey
|
||||
for rows.Next() {
|
||||
var record collection.Record
|
||||
var key detailKey
|
||||
var failed bool
|
||||
var ttftNS, latencyNS int64
|
||||
var cost sql.NullInt64
|
||||
if err := rows.Scan(&key.ID, &key.RequestedAt, &record.ManagedKeyID, &record.RequestID, &record.ExecutionID,
|
||||
&record.TraceID, &record.APIKey, &record.KeyAlias, &record.AuthID, &record.AuthIndex, &record.AuthType,
|
||||
&record.Model, &record.ReasoningEffort, &record.ServiceTier, &record.Speed, &failed, &record.ExecutorType,
|
||||
&record.RequestType, &record.Endpoint, &record.InputTokens, &record.OutputTokens, &record.ReasoningTokens,
|
||||
&record.CachedTokens, &record.CacheReadTokens, &record.CacheWriteTokens, &record.TotalTokens, &ttftNS,
|
||||
&latencyNS, &cost, &record.PriceTier, &record.FastRequested, &record.FastPricingApplied,
|
||||
&record.PriceMultiplierNumerator, &record.PriceMultiplierDenominator, &record.ClientIP,
|
||||
&record.Outcome, &record.StatusCode, &record.Error); err != nil {
|
||||
return nil, nil, fmt.Errorf("读取请求明细: %w", err)
|
||||
}
|
||||
requestedAt, err := time.Parse(time.RFC3339Nano, key.RequestedAt)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("解析请求明细时间: %w", err)
|
||||
}
|
||||
record.RequestedAt = requestedAt
|
||||
record.Failed = failed
|
||||
record.TTFT = time.Duration(ttftNS)
|
||||
record.Latency = time.Duration(latencyNS)
|
||||
if cost.Valid {
|
||||
value := cost.Int64
|
||||
record.CostMicros = &value
|
||||
}
|
||||
records = append(records, record)
|
||||
keys = append(keys, key)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, nil, fmt.Errorf("遍历请求明细: %w", err)
|
||||
}
|
||||
return records, keys, nil
|
||||
}
|
||||
|
||||
func reverseRecords(values []collection.Record) {
|
||||
for left, right := 0, len(values)-1; left < right; left, right = left+1, right-1 {
|
||||
values[left], values[right] = values[right], values[left]
|
||||
}
|
||||
}
|
||||
|
||||
func reverseKeys(values []detailKey) {
|
||||
for left, right := 0, len(values)-1; left < right; left, right = left+1, right-1 {
|
||||
values[left], values[right] = values[right], values[left]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cpa-ext/internal/collection"
|
||||
)
|
||||
|
||||
func TestRequestDetailProjectionBackfillsExistingFacts(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "usage.db")
|
||||
store, err := OpenSQLiteUsage(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
startedAt := time.Date(2026, 8, 15, 12, 0, 0, 0, time.UTC)
|
||||
if err := store.Insert(context.Background(), collection.Record{RequestedAt: startedAt.Add(5 * time.Millisecond), APIKey: "000000", Model: "deepseek-v4-flash", TotalTokens: 99}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.UpsertRequest(context.Background(), collection.RequestRecord{RequestID: "migrated-request", RequestedAt: startedAt, CompletedAt: startedAt.Add(time.Second), Model: "deepseek-v4-flash", Outcome: "succeeded", StatusCode: 200, Endpoint: "/v1/responses"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.db.Exec(`DELETE FROM request_detail_index`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.db.Exec(`DELETE FROM cpa_ext_migrations WHERE name=?`, requestDetailProjectionMigration); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reopened, err := OpenSQLiteUsage(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer reopened.Close()
|
||||
page, err := reopened.QueryUsage(context.Background(), collection.UsageQuery{RequestID: "migrated-request", PageSize: 100})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if page.Total != 1 || len(page.Records) != 1 || page.Records[0].TotalTokens != 99 || page.Records[0].StatusCode != 200 {
|
||||
t.Fatalf("backfilled projection mismatch: %+v", page)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cpa-ext/internal/collection"
|
||||
)
|
||||
|
||||
func TestSQLiteUsageQueryAtMillionRows(t *testing.T) {
|
||||
if os.Getenv("CPA_EXT_MILLION_TEST") != "1" {
|
||||
t.Skip("set CPA_EXT_MILLION_TEST=1 to run the million-row query check")
|
||||
}
|
||||
store, err := OpenSQLiteUsage(filepath.Join(t.TempDir(), "usage.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
started := time.Now()
|
||||
_, err = store.db.Exec(`
|
||||
WITH RECURSIVE sequence(value) AS (
|
||||
SELECT 1 UNION ALL SELECT value+1 FROM sequence WHERE value<1000000
|
||||
)
|
||||
INSERT INTO request_detail_index
|
||||
(lifecycle_request_id, requested_at, managed_key_id, model, result, auth_id, endpoint_kind, request_id)
|
||||
SELECT 'load-' || value,
|
||||
printf('2026-08-%02dT%02d:%02d:%02d.000000000Z', 1 + ((value / 86400) % 15), (value / 3600) % 24, (value / 60) % 60, value % 60),
|
||||
'key-' || (value % 5), 'model-' || (value % 3),
|
||||
CASE WHEN value % 17=0 THEN 'failed' ELSE 'succeeded' END,
|
||||
'auth-' || (value % 2), 'responses', 'load-' || value
|
||||
FROM sequence`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Logf("seeded one million indexed details in %s", time.Since(started))
|
||||
|
||||
started = time.Now()
|
||||
page, err := store.QueryUsage(context.Background(), collection.UsageQuery{Page: 600, PageSize: 100, KeyID: "key-1", Model: "model-1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if page.Total == 0 || len(page.Records) == 0 {
|
||||
t.Fatalf("deep filtered page is empty: %+v", page)
|
||||
}
|
||||
t.Logf("counted and loaded deep filtered page in %s", time.Since(started))
|
||||
|
||||
rows, err := store.db.Query(`EXPLAIN QUERY PLAN SELECT id FROM request_detail_index d WHERE d.managed_key_id=? ORDER BY d.requested_at DESC, d.id DESC LIMIT 100 OFFSET 900000`, "key-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var plan strings.Builder
|
||||
for rows.Next() {
|
||||
var id, parent, unused int
|
||||
var detail string
|
||||
if err := rows.Scan(&id, &parent, &unused, &detail); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plan.WriteString(detail)
|
||||
}
|
||||
if !strings.Contains(plan.String(), "idx_request_detail_key") {
|
||||
t.Fatalf("key query did not use projection index: %s", plan.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package repository_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cpa-ext/internal/collection"
|
||||
"cpa-ext/internal/repository"
|
||||
)
|
||||
|
||||
func TestSQLiteUsageQueryPaginatesAndFilters(t *testing.T) {
|
||||
store, err := repository.OpenSQLiteUsage(filepath.Join(t.TempDir(), "usage.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
base := time.Date(2026, 8, 15, 0, 0, 0, 0, time.UTC)
|
||||
for index := 0; index < 235; index++ {
|
||||
failed := index%11 == 0
|
||||
record := collection.Record{
|
||||
ManagedKeyID: fmt.Sprintf("key-%d", index%3), RequestID: fmt.Sprintf("request-%03d", index),
|
||||
ExecutionID: fmt.Sprintf("execution-%03d", index), RequestedAt: base.Add(time.Duration(index) * time.Second),
|
||||
Model: fmt.Sprintf("model-%d", index%2), Failed: failed, AuthID: fmt.Sprintf("auth-%d", index%2),
|
||||
Endpoint: "/v1/responses", TotalTokens: int64(index + 1),
|
||||
}
|
||||
if err := store.Insert(context.Background(), record); err != nil {
|
||||
t.Fatalf("insert %d: %v", index, err)
|
||||
}
|
||||
}
|
||||
|
||||
first, err := store.QueryUsage(context.Background(), collection.UsageQuery{Page: 1, PageSize: 100})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(first.Records) != 100 || first.Total != 235 || first.TotalPages != 3 || first.Page != 1 || first.NextCursor == "" || first.Records[0].RequestID != "request-234" {
|
||||
t.Fatalf("unexpected first page: %+v first=%+v", first, first.Records[0])
|
||||
}
|
||||
second, err := store.QueryUsage(context.Background(), collection.UsageQuery{PageSize: 100, Cursor: first.NextCursor})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(second.Records) != 100 || second.Page != 2 || second.PreviousCursor == "" || second.Records[0].RequestID != "request-134" {
|
||||
t.Fatalf("unexpected second page: %+v first=%+v", second, second.Records[0])
|
||||
}
|
||||
previous, err := store.QueryUsage(context.Background(), collection.UsageQuery{PageSize: 100, Cursor: second.PreviousCursor})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(previous.Records) != 100 || previous.Records[0].RequestID != first.Records[0].RequestID || previous.Records[99].RequestID != first.Records[99].RequestID {
|
||||
t.Fatalf("previous page did not return the original first page")
|
||||
}
|
||||
|
||||
filters := []struct {
|
||||
name string
|
||||
query collection.UsageQuery
|
||||
check func(collection.Record) bool
|
||||
}{
|
||||
{name: "key", query: collection.UsageQuery{KeyID: "key-1"}, check: func(record collection.Record) bool { return record.ManagedKeyID == "key-1" }},
|
||||
{name: "model", query: collection.UsageQuery{Model: "MODEL-1"}, check: func(record collection.Record) bool { return record.Model == "model-1" }},
|
||||
{name: "failed", query: collection.UsageQuery{Result: collection.UsageResultFailed}, check: func(record collection.Record) bool { return record.Failed }},
|
||||
{name: "auth", query: collection.UsageQuery{AuthID: "auth-1"}, check: func(record collection.Record) bool { return record.AuthID == "auth-1" }},
|
||||
{name: "endpoint", query: collection.UsageQuery{Endpoint: "responses"}, check: func(record collection.Record) bool { return record.Endpoint == "/v1/responses" }},
|
||||
{name: "request", query: collection.UsageQuery{RequestID: "request-123"}, check: func(record collection.Record) bool { return record.RequestID == "request-123" }},
|
||||
}
|
||||
for _, test := range filters {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
test.query.PageSize = 100
|
||||
page, err := store.QueryUsage(context.Background(), test.query)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page.Records) == 0 {
|
||||
t.Fatal("filter returned no records")
|
||||
}
|
||||
for _, record := range page.Records {
|
||||
if !test.check(record) {
|
||||
t.Fatalf("unexpected filtered record: %+v", record)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if _, err := store.QueryUsage(context.Background(), collection.UsageQuery{PageSize: 100, Cursor: first.NextCursor, Model: "model-1"}); err == nil {
|
||||
t.Fatal("cursor from another filter unexpectedly accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteUsageQueryKeepsCursorStableAndProjectsOrphans(t *testing.T) {
|
||||
store, err := repository.OpenSQLiteUsage(filepath.Join(t.TempDir(), "usage.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
base := time.Date(2026, 8, 15, 12, 0, 0, 0, time.UTC)
|
||||
for index := 0; index < 4; index++ {
|
||||
if err := store.Insert(context.Background(), collection.Record{RequestID: fmt.Sprintf("request-%d", index), ExecutionID: fmt.Sprintf("execution-%d", index), RequestedAt: base.Add(time.Duration(index) * time.Second), Model: "model", TotalTokens: 10}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
first, err := store.QueryUsage(context.Background(), collection.UsageQuery{PageSize: 2})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.Insert(context.Background(), collection.Record{RequestID: "newest", ExecutionID: "newest", RequestedAt: base.Add(10 * time.Second), Model: "model"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := store.QueryUsage(context.Background(), collection.UsageQuery{PageSize: 2, Cursor: first.NextCursor})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second.Records[0].RequestID != "request-1" || second.Records[1].RequestID != "request-0" {
|
||||
t.Fatalf("cursor shifted after a new insert: %+v", second.Records)
|
||||
}
|
||||
|
||||
startedAt := base.Add(time.Minute)
|
||||
if err := store.Insert(context.Background(), collection.Record{RequestedAt: startedAt.Add(5 * time.Millisecond), APIKey: "000000", Model: "deepseek-v4-flash", TotalTokens: 99}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.UpsertRequest(context.Background(), collection.RequestRecord{RequestID: "orphan-request", RequestedAt: startedAt, CompletedAt: startedAt.Add(time.Second), Model: "deepseek-v4-flash", Stream: true, Outcome: "canceled", StatusCode: 499, Endpoint: "/v1/responses"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page, err := store.QueryUsage(context.Background(), collection.UsageQuery{RequestID: "orphan-request", PageSize: 100})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if page.Total != 1 || len(page.Records) != 1 || page.Records[0].TotalTokens != 99 || page.Records[0].Outcome != "canceled" || page.Records[0].StatusCode != 499 {
|
||||
t.Fatalf("orphan projection mismatch: %+v", page)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteUsageDashboardUsesAllIndexedHistory(t *testing.T) {
|
||||
store, err := repository.OpenSQLiteUsage(filepath.Join(t.TempDir(), "usage.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
location, _ := time.LoadLocation("Asia/Shanghai")
|
||||
today := time.Date(2026, 8, 15, 12, 0, 0, 0, location)
|
||||
if err := store.Insert(context.Background(), collection.Record{ManagedKeyID: "key-a", RequestedAt: today.Add(-time.Hour), Model: "model", InputTokens: 10, OutputTokens: 2, TotalTokens: 12, CostMicros: nil}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dashboard, err := store.UsageDashboard(context.Background(), today, 7)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dashboard.Today.Requests != 1 || dashboard.Today.TotalTokens != 12 || len(dashboard.Users) != 1 || len(dashboard.Days) != 7 {
|
||||
t.Fatalf("unexpected dashboard: %+v", dashboard)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"cpa-ext/internal/collection"
|
||||
)
|
||||
|
||||
func (r *SQLiteUsageRepository) UsageDashboard(ctx context.Context, today time.Time, days int) (collection.UsageDashboard, error) {
|
||||
if days < 1 {
|
||||
days = 7
|
||||
}
|
||||
start := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, today.Location())
|
||||
dashboard := collection.UsageDashboard{}
|
||||
if err := r.db.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*), COALESCE(SUM(u.input_tokens),0), COALESCE(SUM(u.output_tokens),0),
|
||||
COALESCE(SUM(u.total_tokens),0), COALESCE(SUM(u.cost_micros),0)
|
||||
FROM request_detail_index d LEFT JOIN usage_records u ON u.id=d.usage_id
|
||||
WHERE d.requested_at>=?`, formatTime(start)).Scan(&dashboard.Today.Requests, &dashboard.Today.InputTokens, &dashboard.Today.OutputTokens, &dashboard.Today.TotalTokens, &dashboard.Today.CostMicros); err != nil {
|
||||
return dashboard, fmt.Errorf("汇总今日用量: %w", err)
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT d.managed_key_id, COALESCE(k.name, '未识别'),
|
||||
SUM(CASE WHEN d.requested_at>=? THEN 1 ELSE 0 END),
|
||||
COALESCE(SUM(CASE WHEN d.requested_at>=? THEN u.input_tokens ELSE 0 END),0),
|
||||
COALESCE(SUM(CASE WHEN d.requested_at>=? THEN u.output_tokens ELSE 0 END),0),
|
||||
COALESCE(SUM(CASE WHEN d.requested_at>=? THEN u.total_tokens ELSE 0 END),0),
|
||||
COALESCE(SUM(CASE WHEN d.requested_at>=? THEN u.cost_micros ELSE 0 END),0),
|
||||
MAX(d.requested_at)
|
||||
FROM request_detail_index d
|
||||
LEFT JOIN usage_records u ON u.id=d.usage_id
|
||||
LEFT JOIN managed_keys k ON k.id=d.managed_key_id
|
||||
GROUP BY d.managed_key_id, COALESCE(k.name, '未识别')
|
||||
ORDER BY 6 DESC, 2`, formatTime(start), formatTime(start), formatTime(start), formatTime(start), formatTime(start))
|
||||
if err != nil {
|
||||
return dashboard, fmt.Errorf("汇总用户用量: %w", err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var item collection.UserUsageSummary
|
||||
var last string
|
||||
if err := rows.Scan(&item.KeyID, &item.KeyAlias, &item.Today.Requests, &item.Today.InputTokens, &item.Today.OutputTokens, &item.Today.TotalTokens, &item.Today.CostMicros, &last); err != nil {
|
||||
_ = rows.Close()
|
||||
return dashboard, fmt.Errorf("读取用户用量汇总: %w", err)
|
||||
}
|
||||
if parsed, parseErr := time.Parse(time.RFC3339Nano, last); parseErr == nil {
|
||||
item.LastUsedAt = &parsed
|
||||
}
|
||||
dashboard.Users = append(dashboard.Users, item)
|
||||
}
|
||||
_ = rows.Close()
|
||||
|
||||
dailyStart := start.AddDate(0, 0, -(days - 1))
|
||||
rows, err = r.db.QueryContext(ctx, `
|
||||
SELECT DATE(d.requested_at, '+8 hours'), COALESCE(SUM(u.total_tokens),0)
|
||||
FROM request_detail_index d LEFT JOIN usage_records u ON u.id=d.usage_id
|
||||
WHERE d.requested_at>=? GROUP BY DATE(d.requested_at, '+8 hours') ORDER BY 1`, formatTime(dailyStart))
|
||||
if err != nil {
|
||||
return dashboard, fmt.Errorf("汇总每日用量: %w", err)
|
||||
}
|
||||
byDate := make(map[string]int64)
|
||||
for rows.Next() {
|
||||
var date string
|
||||
var tokens int64
|
||||
if err := rows.Scan(&date, &tokens); err != nil {
|
||||
_ = rows.Close()
|
||||
return dashboard, fmt.Errorf("读取每日用量: %w", err)
|
||||
}
|
||||
byDate[date] = tokens
|
||||
}
|
||||
_ = rows.Close()
|
||||
for index := 0; index < days; index++ {
|
||||
date := dailyStart.AddDate(0, 0, index)
|
||||
dashboard.Days = append(dashboard.Days, collection.DailyUsageSummary{Date: date, TotalTokens: byDate[date.Format("2006-01-02")]})
|
||||
}
|
||||
return dashboard, nil
|
||||
}
|
||||
@@ -3,7 +3,9 @@ package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
@@ -56,6 +58,7 @@ CREATE TABLE IF NOT EXISTS usage_records (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_records_requested_at ON usage_records(requested_at DESC, id DESC);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_usage_records_execution_id ON usage_records(execution_id) WHERE execution_id <> '';
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_records_model ON usage_records(model COLLATE NOCASE) WHERE model <> '';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS request_records (
|
||||
request_id TEXT PRIMARY KEY,
|
||||
@@ -74,6 +77,44 @@ CREATE TABLE IF NOT EXISTS request_records (
|
||||
latency_ns INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_request_records_requested_at ON request_records(requested_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_records_request_id ON usage_records(request_id) WHERE request_id <> '';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS request_detail_index (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
usage_id INTEGER,
|
||||
lifecycle_request_id TEXT NOT NULL DEFAULT '',
|
||||
requested_at TEXT NOT NULL,
|
||||
managed_key_id TEXT NOT NULL DEFAULT '',
|
||||
model TEXT NOT NULL DEFAULT '' COLLATE NOCASE,
|
||||
result TEXT NOT NULL DEFAULT 'succeeded',
|
||||
auth_id TEXT NOT NULL DEFAULT '',
|
||||
endpoint_kind TEXT NOT NULL DEFAULT '',
|
||||
request_id TEXT NOT NULL DEFAULT '',
|
||||
FOREIGN KEY(usage_id) REFERENCES usage_records(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_request_detail_usage
|
||||
ON request_detail_index(usage_id) WHERE usage_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_request_detail_lifecycle_only
|
||||
ON request_detail_index(lifecycle_request_id) WHERE usage_id IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_request_detail_time
|
||||
ON request_detail_index(requested_at DESC, id DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_request_detail_key
|
||||
ON request_detail_index(managed_key_id, requested_at DESC, id DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_request_detail_model
|
||||
ON request_detail_index(model COLLATE NOCASE, requested_at DESC, id DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_request_detail_result
|
||||
ON request_detail_index(result, requested_at DESC, id DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_request_detail_auth
|
||||
ON request_detail_index(auth_id, requested_at DESC, id DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_request_detail_endpoint
|
||||
ON request_detail_index(endpoint_kind, requested_at DESC, id DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_request_detail_request
|
||||
ON request_detail_index(request_id, requested_at DESC, id DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cpa_ext_migrations (
|
||||
name TEXT PRIMARY KEY,
|
||||
completed_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS managed_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -127,6 +168,59 @@ CREATE TABLE IF NOT EXISTS model_prices (
|
||||
fast_multiplier_denominator INTEGER NOT NULL DEFAULT 2,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS billing_accounts (
|
||||
managed_key_id TEXT PRIMARY KEY,
|
||||
quota_micros INTEGER NOT NULL DEFAULT 0 CHECK(quota_micros >= 0),
|
||||
reset_period TEXT NOT NULL DEFAULT 'none' CHECK(reset_period IN ('none', 'daily', 'weekly', 'monthly')),
|
||||
next_reset_at TEXT,
|
||||
reset_anchor_day INTEGER NOT NULL DEFAULT 0,
|
||||
max_concurrency INTEGER NOT NULL DEFAULT 4 CHECK(max_concurrency BETWEEN 1 AND 64),
|
||||
current_cycle_sequence INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(managed_key_id) REFERENCES managed_keys(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS billing_cycles (
|
||||
managed_key_id TEXT NOT NULL,
|
||||
sequence INTEGER NOT NULL,
|
||||
started_at TEXT NOT NULL,
|
||||
ended_at TEXT,
|
||||
quota_micros INTEGER NOT NULL DEFAULT 0,
|
||||
spent_micros INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY(managed_key_id, sequence),
|
||||
FOREIGN KEY(managed_key_id) REFERENCES managed_keys(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS billing_admissions (
|
||||
request_id TEXT PRIMARY KEY,
|
||||
managed_key_id TEXT NOT NULL,
|
||||
cycle_sequence INTEGER NOT NULL,
|
||||
opened_at TEXT NOT NULL,
|
||||
closed_at TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
FOREIGN KEY(managed_key_id) REFERENCES managed_keys(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_billing_admissions_active
|
||||
ON billing_admissions(managed_key_id, status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS billing_ledger (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_key TEXT NOT NULL UNIQUE,
|
||||
managed_key_id TEXT NOT NULL,
|
||||
cycle_sequence INTEGER NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
amount_micros INTEGER NOT NULL,
|
||||
balance_after_micros INTEGER NOT NULL,
|
||||
request_id TEXT NOT NULL DEFAULT '',
|
||||
execution_id TEXT NOT NULL DEFAULT '',
|
||||
model TEXT NOT NULL DEFAULT '',
|
||||
occurred_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(managed_key_id) REFERENCES managed_keys(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_billing_ledger_key
|
||||
ON billing_ledger(managed_key_id, id DESC);
|
||||
`
|
||||
|
||||
const (
|
||||
@@ -170,12 +264,30 @@ func OpenSQLiteUsage(databasePath string) (*SQLiteUsageRepository, error) {
|
||||
`ALTER TABLE usage_records ADD COLUMN auth_id TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE usage_records ADD COLUMN auth_index TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE usage_records ADD COLUMN auth_type TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE usage_records ADD COLUMN billing_event_key TEXT NOT NULL DEFAULT ''`,
|
||||
} {
|
||||
if _, err := db.Exec(migration); err != nil && !strings.Contains(strings.ToLower(err.Error()), "duplicate column") {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("迁移 SQLite schema: %w", err)
|
||||
}
|
||||
}
|
||||
if _, err := db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_usage_records_billing_event ON usage_records(billing_event_key) WHERE billing_event_key <> ''`); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("创建用量计费幂等索引: %w", err)
|
||||
}
|
||||
now := formatTime(time.Now().UTC())
|
||||
if _, err := db.Exec(`
|
||||
INSERT OR IGNORE INTO billing_accounts (managed_key_id, updated_at)
|
||||
SELECT id, ? FROM managed_keys`, now); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("迁移 Key 额度账户: %w", err)
|
||||
}
|
||||
if _, err := db.Exec(`
|
||||
INSERT OR IGNORE INTO billing_cycles (managed_key_id, sequence, started_at, quota_micros, spent_micros)
|
||||
SELECT k.id, 1, k.created_at, 0, 0 FROM managed_keys k`); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("迁移 Key 额度周期: %w", err)
|
||||
}
|
||||
if _, err := db.Exec(`CREATE INDEX IF NOT EXISTS idx_usage_records_managed_key ON usage_records(managed_key_id, requested_at DESC)`); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("创建 Key 用量索引: %w", err)
|
||||
@@ -184,7 +296,12 @@ func OpenSQLiteUsage(databasePath string) (*SQLiteUsageRepository, error) {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("创建 Key 请求索引: %w", err)
|
||||
}
|
||||
return &SQLiteUsageRepository{db: db}, nil
|
||||
repository := &SQLiteUsageRepository{db: db}
|
||||
if err := repository.ensureRequestDetailProjection(context.Background()); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return repository, nil
|
||||
}
|
||||
|
||||
func sqliteDSN(databasePath string) (string, error) {
|
||||
@@ -214,16 +331,22 @@ func sqliteDSN(databasePath string) (string, error) {
|
||||
}
|
||||
|
||||
func (r *SQLiteUsageRepository) Insert(ctx context.Context, record collection.Record) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
eventKey := usageBillingEventKey(record)
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("开始写入用量: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
INSERT OR IGNORE INTO usage_records (
|
||||
managed_key_id, request_id, execution_id, trace_id, requested_at, api_key, key_alias, auth_id, auth_index, auth_type,
|
||||
managed_key_id, request_id, execution_id, trace_id, billing_event_key, requested_at, api_key, key_alias, auth_id, auth_index, auth_type,
|
||||
model, reasoning_effort, service_tier, speed,
|
||||
failed, executor_type, request_type, endpoint, input_tokens, output_tokens, reasoning_tokens,
|
||||
cached_tokens, cache_read_tokens, cache_write_tokens, total_tokens,
|
||||
ttft_ns, latency_ns, cost_micros, price_tier, fast_requested, fast_pricing_applied,
|
||||
price_multiplier_numerator, price_multiplier_denominator, client_ip
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
record.ManagedKeyID, record.RequestID, record.ExecutionID, record.TraceID,
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
record.ManagedKeyID, record.RequestID, record.ExecutionID, record.TraceID, eventKey,
|
||||
record.RequestedAt.UTC().Format(time.RFC3339Nano), record.APIKey, record.KeyAlias,
|
||||
record.AuthID, record.AuthIndex, record.AuthType,
|
||||
record.Model, record.ReasoningEffort, record.ServiceTier, record.Speed, record.Failed,
|
||||
@@ -236,6 +359,86 @@ INSERT OR IGNORE INTO usage_records (
|
||||
if err != nil {
|
||||
return fmt.Errorf("写入用量记录: %w", err)
|
||||
}
|
||||
inserted, _ := result.RowsAffected()
|
||||
var usageID int64
|
||||
if inserted > 0 {
|
||||
usageID, _ = result.LastInsertId()
|
||||
if err := insertUsageProjection(ctx, tx, usageID); err != nil {
|
||||
return err
|
||||
}
|
||||
if record.RequestID != "" {
|
||||
if _, err := syncRequestProjection(ctx, tx, record.RequestID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if inserted > 0 && record.ManagedKeyID != "" && record.CostMicros != nil {
|
||||
if err := chargeUsage(ctx, tx, record, eventKey); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("提交用量和账目: %w", err)
|
||||
}
|
||||
if inserted > 0 && record.RequestID == "" {
|
||||
if err := r.reconcileOrphanProjection(ctx, record.RequestedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func usageBillingEventKey(record collection.Record) string {
|
||||
if executionID := strings.TrimSpace(record.ExecutionID); executionID != "" {
|
||||
return "execution:" + record.ManagedKeyID + ":" + executionID
|
||||
}
|
||||
hash := sha256.New()
|
||||
_, _ = fmt.Fprintf(hash, "%s\x00%s\x00%s\x00%s\x00%s\x00%s\x00%d\x00%d\x00%d\x00%d\x00%d\x00%d\x00%d\x00%d\x00%t",
|
||||
record.ManagedKeyID, record.RequestID, record.AuthID, record.AuthIndex, record.Model,
|
||||
record.RequestedAt.UTC().Format(time.RFC3339Nano), record.InputTokens, record.OutputTokens,
|
||||
record.CacheReadTokens, record.CacheWriteTokens, record.TotalTokens, record.TTFT,
|
||||
record.Latency, record.StatusCode, record.Failed)
|
||||
return "usage:" + hex.EncodeToString(hash.Sum(nil))
|
||||
}
|
||||
|
||||
func chargeUsage(ctx context.Context, tx *sql.Tx, record collection.Record, eventKey string) error {
|
||||
requestedAt := record.RequestedAt
|
||||
if requestedAt.IsZero() {
|
||||
requestedAt = time.Now().UTC()
|
||||
}
|
||||
var sequence, quota, spent int64
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
SELECT sequence, quota_micros, spent_micros FROM billing_cycles
|
||||
WHERE managed_key_id=? AND started_at <= ? AND (ended_at IS NULL OR ? < ended_at)
|
||||
ORDER BY sequence DESC LIMIT 1`, record.ManagedKeyID, formatTime(requestedAt), formatTime(requestedAt)).Scan(&sequence, "a, &spent)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
err = tx.QueryRowContext(ctx, `
|
||||
SELECT c.sequence, c.quota_micros, c.spent_micros FROM billing_accounts a
|
||||
JOIN billing_cycles c ON c.managed_key_id=a.managed_key_id AND c.sequence=a.current_cycle_sequence
|
||||
WHERE a.managed_key_id=?`, record.ManagedKeyID).Scan(&sequence, "a, &spent)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("定位用量额度周期: %w", err)
|
||||
}
|
||||
cost := *record.CostMicros
|
||||
balance := quota - spent - cost
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
INSERT OR IGNORE INTO billing_ledger (event_key, managed_key_id, cycle_sequence, kind, amount_micros,
|
||||
balance_after_micros, request_id, execution_id, model, occurred_at, created_at)
|
||||
VALUES (?, ?, ?, 'charge', ?, ?, ?, ?, ?, ?, ?)`, "charge:"+eventKey, record.ManagedKeyID,
|
||||
sequence, -cost, balance, record.RequestID, record.ExecutionID, record.Model,
|
||||
formatTime(requestedAt), formatTime(time.Now().UTC()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("记录用量扣费: %w", err)
|
||||
}
|
||||
inserted, _ := result.RowsAffected()
|
||||
if inserted > 0 {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE billing_cycles SET spent_micros=spent_micros+? WHERE managed_key_id=? AND sequence=?`,
|
||||
cost, record.ManagedKeyID, sequence); err != nil {
|
||||
return fmt.Errorf("更新额度消费: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -256,7 +459,12 @@ func (r *SQLiteUsageRepository) UpsertRequest(ctx context.Context, record collec
|
||||
if latency < 0 {
|
||||
latency = 0
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("开始写入请求终态: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO request_records (
|
||||
request_id, managed_key_id, trace_id, requested_at, completed_at, model, requested_model,
|
||||
source_format, stream, outcome, status_code, error, endpoint, latency_ns
|
||||
@@ -283,6 +491,18 @@ ON CONFLICT(request_id) DO UPDATE SET
|
||||
if err != nil {
|
||||
return fmt.Errorf("写入请求终态: %w", err)
|
||||
}
|
||||
linked, err := syncRequestProjection(ctx, tx, requestID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("提交请求终态和索引: %w", err)
|
||||
}
|
||||
if !linked {
|
||||
if err := r.reconcileOrphanProjection(ctx, requestedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
+280
-68
@@ -139,12 +139,26 @@
|
||||
.ok { color: #34d399; }
|
||||
.failed { color: #f87171; }
|
||||
.drawer-overlay { position: fixed; z-index: 40; inset: 0; background: #020617a6; backdrop-filter: blur(2px); }
|
||||
.drawer { position: fixed; z-index: 50; top: 0; right: 0; display: flex; flex-direction: column; width: min(430px, 100vw); height: 100vh; border-left: 1px solid var(--border); background: #0f1621; box-shadow: -20px 0 60px #0008; }
|
||||
.drawer { position: fixed; z-index: 50; top: 0; right: 0; display: flex; flex-direction: column; width: min(540px, 100vw); height: 100vh; border-left: 1px solid var(--border); background: #0f1621; box-shadow: -20px 0 60px #0008; }
|
||||
.drawer-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; padding: 17px 18px 13px; border-bottom: 1px solid var(--border-soft); }
|
||||
.drawer-header h2 { margin-bottom: 3px; }
|
||||
.drawer-header p { margin-bottom: 0; color: var(--muted); font-size: 11px; }
|
||||
.drawer-body { flex: 1; overflow: auto; padding: 16px 18px; }
|
||||
.drawer-form { display: grid; gap: 13px; }
|
||||
.drawer-section { display: grid; gap: 10px; padding: 12px; border: 1px solid var(--border-soft); border-radius: 8px; background: #0c131e; }
|
||||
.drawer-section h3 { margin: 0; font-size: 11px; }
|
||||
.field-hint { margin: -2px 0 0; color: #64748b; font-size: 9px; }
|
||||
.billing-preview { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; }
|
||||
.billing-preview div { padding: 9px; border: 1px solid var(--border-soft); border-radius: 7px; background: #111925; }
|
||||
.billing-preview span { display: block; color: var(--muted); font-size: 9px; }
|
||||
.billing-preview strong { display: block; margin-top: 3px; font-size: 14px; }
|
||||
.billing-tools { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.ledger-list { display: grid; max-height: 210px; overflow: auto; gap: 5px; }
|
||||
.ledger-item { display: grid; grid-template-columns: 1fr auto; gap: 3px 10px; padding: 8px; border: 1px solid var(--border-soft); border-radius: 7px; background: #101824; }
|
||||
.ledger-item strong { font-size: 10px; }
|
||||
.ledger-item .positive { color: var(--success); }
|
||||
.ledger-item .negative { color: var(--danger); }
|
||||
.ledger-item small { grid-column: 1 / -1; overflow: hidden; color: var(--muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.drawer-footer { display: flex; justify-content: space-between; gap: 8px; padding: 12px 18px; border-top: 1px solid var(--border-soft); }
|
||||
.drawer-footer div { display: flex; gap: 7px; }
|
||||
@@ -163,8 +177,14 @@
|
||||
.page-buttons button.active { border-color: #0e7490; background: #0e7490; color: white; }
|
||||
.page-buttons button:disabled { cursor: default; opacity: .4; }
|
||||
.page-ellipsis { width: 22px; color: var(--muted); text-align: center; font-size: 10px; }
|
||||
.usage-filters { display: grid; grid-template-columns: repeat(6, minmax(120px, 1fr)); gap: 9px; margin-bottom: 10px; padding: 11px; }
|
||||
.usage-filter { display: grid; gap: 5px; min-width: 0; color: var(--muted); font-size: 9px; }
|
||||
.usage-filter input, .usage-filter select { height: 32px; padding: 6px 8px; font-size: 11px; }
|
||||
.usage-filter.request-filter { grid-column: span 2; }
|
||||
.usage-filter-actions { display: flex; align-items: end; justify-content: flex-end; gap: 7px; grid-column: span 2; }
|
||||
@media (max-width: 1100px) { .usage-filters { grid-template-columns: repeat(3, minmax(140px, 1fr)); } }
|
||||
@media (max-width: 900px) { .price-layout { grid-template-columns: 220px minmax(0, 1fr); } .price-grid, .price-grid.long-grid { grid-template-columns: repeat(2, minmax(130px, 1fr)); } .summary-grid { grid-template-columns: repeat(3, minmax(130px, 1fr)); overflow-x: auto; } .chart-grid { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 680px) { .topbar { align-items: flex-start; height: auto; padding: 11px 12px; } .brand span, .auth-box label { display: none; } .auth-box { flex-wrap: wrap; justify-content: flex-end; } #key { width: 128px; } .demo-banner { padding-right: 12px; padding-left: 12px; } .workspace-nav { padding-right: 12px; padding-left: 12px; } main { padding: 14px 12px 22px; } .section-heading { align-items: stretch; flex-direction: column; } .section-heading .tools { justify-content: flex-start; } .price-layout { grid-template-columns: 1fr; } .price-sidebar { border-right: 0; border-bottom: 1px solid var(--border-soft); } .price-grid, .price-grid.long-grid, .field-row, .fast-row { grid-template-columns: 1fr; } .drawer { width: 100vw; } .summary-grid { margin-right: -12px; padding-right: 12px; } .pagination { align-items: flex-start; flex-direction: column; } }
|
||||
@media (max-width: 680px) { .topbar { align-items: flex-start; height: auto; padding: 11px 12px; } .brand span, .auth-box label { display: none; } .auth-box { flex-wrap: wrap; justify-content: flex-end; } #key { width: 128px; } .demo-banner { padding-right: 12px; padding-left: 12px; } .workspace-nav { padding-right: 12px; padding-left: 12px; } main { padding: 14px 12px 22px; } .section-heading { align-items: stretch; flex-direction: column; } .section-heading .tools { justify-content: flex-start; } .price-layout { grid-template-columns: 1fr; } .price-sidebar { border-right: 0; border-bottom: 1px solid var(--border-soft); } .price-grid, .price-grid.long-grid, .field-row, .fast-row { grid-template-columns: 1fr; } .drawer { width: 100vw; } .summary-grid { margin-right: -12px; padding-right: 12px; } .pagination { align-items: flex-start; flex-direction: column; } .usage-filters { grid-template-columns: 1fr 1fr; } .usage-filter.request-filter, .usage-filter-actions { grid-column: span 2; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -189,13 +209,24 @@
|
||||
</div>
|
||||
<div class="chart-grid"><article class="chart-card"><header><div><h2>今日用户用量</h2></div></header><div id="user-usage" class="user-usage"></div></article><article class="chart-card"><header><div><h2>近 7 日 Token</h2></div></header><div id="daily-chart" class="bar-chart"></div></article></div>
|
||||
<div class="surface">
|
||||
<div class="surface-toolbar"><span id="key-status" class="status"></span><span class="subtle">点击“管理”修改路由与权限</span></div>
|
||||
<div class="table-wrap"><table class="key-table"><thead><tr><th class="left">用户</th><th class="left">完整 Key</th><th class="left">状态</th><th class="left">路由</th><th class="left">允许模型</th><th class="left">操作</th></tr></thead><tbody id="key-rows"></tbody></table></div>
|
||||
<div class="surface-toolbar"><span id="key-status" class="status"></span><span class="subtle">点击“管理”修改访问、路由与额度</span></div>
|
||||
<div class="table-wrap"><table class="key-table"><thead><tr><th class="left">用户</th><th class="left">完整 Key</th><th class="left">状态</th><th class="left">余额 / 额度</th><th class="left">并发</th><th class="left">路由</th><th class="left">允许模型</th><th class="left">操作</th></tr></thead><tbody id="key-rows"></tbody></table></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="view-usage" class="view">
|
||||
<div class="section-heading"><div><div id="page-buttons" class="page-buttons"></div><span id="status" class="status"></span></div><div class="tools"><details><summary>选择列</summary><div id="column-options" class="column-options"></div></details><button id="refresh" type="button">刷新</button></div></div>
|
||||
<div class="surface usage-filters">
|
||||
<label class="usage-filter">开始时间<input id="usage-from" type="datetime-local" step="1"></label>
|
||||
<label class="usage-filter">结束时间<input id="usage-to" type="datetime-local" step="1"></label>
|
||||
<label class="usage-filter">用户<select id="usage-key"><option value="">全部用户</option></select></label>
|
||||
<label class="usage-filter">模型<input id="usage-model" list="usage-model-options" placeholder="全部模型"><datalist id="usage-model-options"></datalist></label>
|
||||
<label class="usage-filter">结果<select id="usage-result"><option value="">全部结果</option><option value="succeeded">成功</option><option value="failed">失败</option><option value="rejected">已拒绝</option><option value="canceled">已取消</option></select></label>
|
||||
<label class="usage-filter">上游<select id="usage-auth"><option value="">全部上游</option></select></label>
|
||||
<label class="usage-filter">端点<select id="usage-endpoint"><option value="">全部端点</option><option value="responses">responses</option><option value="compact">compact</option><option value="chat">chat</option></select></label>
|
||||
<label class="usage-filter request-filter">请求 ID<input id="usage-request-id" placeholder="粘贴完整 Request ID"></label>
|
||||
<div class="usage-filter-actions"><button id="clear-usage-filters" type="button">清空</button><button id="apply-usage-filters" class="primary" type="button">查询</button></div>
|
||||
</div>
|
||||
<div class="surface"><div class="table-wrap"><table><thead><tr id="headers"></tr></thead><tbody id="rows"></tbody></table></div></div>
|
||||
</section>
|
||||
|
||||
@@ -225,13 +256,22 @@
|
||||
<div class="drawer-header"><div><h2 id="drawer-title">管理 Key</h2><p id="drawer-subtitle">修改用户状态、路由和模型权限</p></div><button id="close-drawer" class="icon-button" type="button" aria-label="关闭">×</button></div>
|
||||
<div class="drawer-body">
|
||||
<div id="key-editor" class="drawer-form">
|
||||
<label class="field">名称<input id="editor-name" placeholder="例如 alice"></label>
|
||||
<label class="field">完整 Key<input id="editor-secret" class="secret" placeholder="留空自动生成"></label>
|
||||
<div class="drawer-section"><h3>用户</h3><label class="field">名称<input id="editor-name" placeholder="例如 alice"></label>
|
||||
<label class="field">完整 Key<input id="editor-secret" class="secret" placeholder="留空自动生成"></label></div>
|
||||
<div id="managed-fields" class="drawer-form">
|
||||
<div class="drawer-section"><h3>访问与路由</h3>
|
||||
<div class="field-row"><label class="field">状态<select id="editor-status"><option value="active">启用</option><option value="disabled">禁用</option><option value="archived">已归档</option></select></label><label class="field">路由<select id="editor-route"><option value="auto">自动选择</option><option value="strict">指定账号</option></select></label></div>
|
||||
<label class="field">上游账号<select id="editor-upstream"></select></label>
|
||||
<label class="inline-check"><input id="editor-all-models" type="checkbox">允许全部模型</label>
|
||||
<label class="field">允许模型<input id="editor-models" placeholder="多个模型用逗号分隔"></label>
|
||||
<label class="field">允许模型规则<input id="editor-models" placeholder="deepseek-*,gpt-5.6-sol"></label><p class="field-hint">多个规则用逗号分隔;* 匹配任意字符,未匹配的模型会被拒绝。</p></div>
|
||||
<div class="drawer-section"><h3>额度与周期</h3>
|
||||
<div id="billing-preview" class="billing-preview"><div><span>额度</span><strong id="billing-quota-view">$0.0000</strong></div><div><span>已用</span><strong id="billing-spent-view">$0.0000</strong></div><div><span>余额</span><strong id="billing-balance-view">$0.0000</strong></div></div>
|
||||
<div class="field-row"><label class="field">本周期额度(USD)<input id="editor-quota" inputmode="decimal" value="0"></label><label class="field">并发上限<input id="editor-concurrency" type="number" min="1" max="64" step="1" value="4"></label></div>
|
||||
<div class="field-row"><label class="field">自动重置<select id="editor-reset-period"><option value="none">不重置</option><option value="daily">每天</option><option value="weekly">每周</option><option value="monthly">每月</option></select></label><label class="field">下次重置时间<input id="editor-next-reset" type="datetime-local" step="1"></label></div>
|
||||
<p class="field-hint">请求结束后按实际费用扣款;最后一个并发请求可能产生负余额。</p>
|
||||
<div class="billing-tools"><span id="billing-status" class="status"></span><button id="reset-billing" class="small" type="button">立即重置额度</button></div>
|
||||
</div>
|
||||
<div id="ledger-section" class="drawer-section"><h3>最近账目</h3><div id="billing-ledger" class="ledger-list"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="key-stats" class="hidden"><div id="stats-grid" class="stats-grid"></div><h3>最近请求</h3><div id="stats-recent" class="recent-list"></div></div>
|
||||
@@ -240,11 +280,14 @@
|
||||
</aside>
|
||||
<script>
|
||||
const API = "/v0/management/plugins/cpa-ext/usage";
|
||||
const SUMMARY_API = "/v0/management/plugins/cpa-ext/usage-summary";
|
||||
const PRICE_API = "/v0/management/plugins/cpa-ext/prices";
|
||||
const KEYS_API = "/v0/management/plugins/cpa-ext/keys";
|
||||
const KEY_STATS_API = "/v0/management/plugins/cpa-ext/key-stats";
|
||||
const UPSTREAMS_API = "/v0/management/plugins/cpa-ext/upstreams";
|
||||
const MODELS_API = "/v0/management/plugins/cpa-ext/model-suggestions";
|
||||
const BILLING_RESET_API = "/v0/management/plugins/cpa-ext/billing-reset";
|
||||
const BILLING_LEDGER_API = "/v0/management/plugins/cpa-ext/billing-ledger";
|
||||
const keyInput = document.querySelector("#key");
|
||||
const statusNode = document.querySelector("#status");
|
||||
const rowsNode = document.querySelector("#rows");
|
||||
@@ -265,9 +308,11 @@
|
||||
const statsRecentNode = document.querySelector("#stats-recent");
|
||||
const pageButtonsNode = document.querySelector("#page-buttons");
|
||||
const PAGE_SIZE = 100;
|
||||
const DEMO_STORE_KEY = "cpa-ext:demo-state:v2";
|
||||
const DEMO_STORE_KEY = "cpa-ext:demo-state:v3";
|
||||
let currentRecords = [];
|
||||
let currentPage = 1;
|
||||
let currentPagination = { page: 1, page_size: PAGE_SIZE, total: 0, total_pages: 0, previous_cursor: "", next_cursor: "" };
|
||||
let usageDashboard = { today: { requests: 0, total_tokens: 0, cost_usd: 0 }, users: [], days: [] };
|
||||
let lastSignature = "";
|
||||
let loading = false;
|
||||
let currentPrices = [];
|
||||
@@ -294,9 +339,9 @@
|
||||
disabled: false, unavailable: false
|
||||
}));
|
||||
const keys = [
|
||||
{ id: "demo-key-alice", name: "alice", secret: "alice-000000", status: "active", route_mode: "strict", upstream_account_id: upstreams[0].id, all_models: true, models: [] },
|
||||
{ id: "demo-key-alice", name: "alice", secret: "alice-000000", status: "active", route_mode: "strict", upstream_account_id: upstreams[0].id, all_models: false, models: ["deepseek-*"] },
|
||||
{ id: "demo-key-bob", name: "bob", secret: "bob-000000", status: "active", route_mode: "strict", upstream_account_id: upstreams[0].id, all_models: true, models: [] },
|
||||
{ id: "demo-key-carol", name: "carol", secret: "carol-000000", status: "active", route_mode: "strict", upstream_account_id: upstreams[1].id, all_models: true, models: [] },
|
||||
{ id: "demo-key-carol", name: "carol", secret: "carol-000000", status: "active", route_mode: "strict", upstream_account_id: upstreams[1].id, all_models: false, models: ["deepseek-source-b", "deepseek-v4-flash"] },
|
||||
{ id: "demo-key-eve", name: "eve", secret: "eve-000000", status: "disabled", route_mode: "auto", upstream_account_id: "", all_models: false, models: ["deepseek-source-c"] }
|
||||
];
|
||||
const now = Date.now();
|
||||
@@ -307,7 +352,7 @@
|
||||
const output = 24 + (index * 17) % 420;
|
||||
return {
|
||||
request_id: "demo-request-" + String(index + 1).padStart(4, "0"), execution_id: "demo-exec-" + index,
|
||||
requested_at: new Date(now - index * 187000).toISOString(), key_alias: key.name, api_key: key.secret,
|
||||
requested_at: new Date(now - index * 187000).toISOString(), managed_key_id: key.id, key_alias: key.name, api_key: key.secret,
|
||||
auth_id: upstreams[index % upstreams.length].cpa_auth_id, auth_index: "demo-" + (index % upstreams.length + 1),
|
||||
model: index % 7 === 0 ? "deepseek-source-c" : "deepseek-v4-flash", reasoning_effort: "-", service_tier: "auto", speed: "",
|
||||
failed, outcome: failed ? "failed" : "succeeded", status_code: failed ? 503 : 200,
|
||||
@@ -317,13 +362,21 @@
|
||||
cost_available: true, client_ip: "127.0.0.1"
|
||||
};
|
||||
});
|
||||
return { keys, upstreams, usage, prices: [demoPrice()] };
|
||||
const ledger = [];
|
||||
keys.forEach((key, keyIndex) => {
|
||||
const records = usage.filter(item => item.key_alias === key.name);
|
||||
const spent = records.reduce((sum, item) => sum + (item.cost_usd || 0), 0);
|
||||
const quota = 250 + keyIndex * 50;
|
||||
key.billing = { quota_usd: String(quota), spent_usd: spent.toFixed(6), balance_usd: (quota - spent).toFixed(6), reset_period: keyIndex % 2 ? "monthly" : "none", next_reset_at: keyIndex % 2 ? new Date(now + 86400000 * 20).toISOString() : null, max_concurrency: 4, active_requests: keyIndex % 3, cycle_started_at: new Date(now - 86400000 * 10).toISOString() };
|
||||
records.slice(0, 12).forEach((record, index) => ledger.push({ id: keyIndex * 100 + index + 1, key_id: key.id, kind: "charge", amount_usd: (-record.cost_usd).toFixed(6), balance_after_usd: (quota - spent + index * record.cost_usd).toFixed(6), request_id: record.request_id, model: record.model, occurred_at: record.requested_at }));
|
||||
});
|
||||
return { keys, upstreams, usage, prices: [demoPrice()], ledger };
|
||||
}
|
||||
|
||||
function loadDemoState() {
|
||||
try {
|
||||
const value = JSON.parse(localStorage.getItem(DEMO_STORE_KEY));
|
||||
if (value && Array.isArray(value.keys) && Array.isArray(value.usage) && Array.isArray(value.prices)) return value;
|
||||
if (value && Array.isArray(value.keys) && Array.isArray(value.usage) && Array.isArray(value.prices) && Array.isArray(value.ledger)) return value;
|
||||
} catch (_) {}
|
||||
return freshDemoState();
|
||||
}
|
||||
@@ -370,6 +423,29 @@
|
||||
return record.cost_available && Number.isFinite(record.cost_usd) ? "$" + record.cost_usd.toFixed(4) : "-";
|
||||
}
|
||||
|
||||
function money(value) {
|
||||
const amount = Number(value || 0);
|
||||
return "$" + (Number.isFinite(amount) ? amount : 0).toFixed(4);
|
||||
}
|
||||
|
||||
function localDateTimeValue(value) {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
|
||||
return local.toISOString().slice(0, 19);
|
||||
}
|
||||
|
||||
function billingPayload() {
|
||||
const period = document.querySelector("#editor-reset-period").value;
|
||||
const nextValue = document.querySelector("#editor-next-reset").value;
|
||||
return {
|
||||
quota_usd: document.querySelector("#editor-quota").value.trim() || "0",
|
||||
reset_period: period,
|
||||
next_reset_at: period === "none" || !nextValue ? null : new Date(nextValue).toISOString(),
|
||||
max_concurrency: Number(document.querySelector("#editor-concurrency").value || 4)
|
||||
};
|
||||
}
|
||||
|
||||
function isCompactEndpoint(value) {
|
||||
const path = String(value || "").replace(/^\s*(GET|POST|PUT|PATCH|DELETE)\s+/i, "").split("?", 1)[0].replace(/\/+$/, "");
|
||||
return path.endsWith("/responses/compact");
|
||||
@@ -455,12 +531,8 @@
|
||||
|
||||
function render(records) {
|
||||
currentRecords = records;
|
||||
const pageCount = Math.max(1, Math.ceil(currentRecords.length / PAGE_SIZE));
|
||||
currentPage = Math.min(Math.max(1, currentPage), pageCount);
|
||||
const activeColumns = columns.filter(column => visibleColumns.has(column.id));
|
||||
const start = (currentPage - 1) * PAGE_SIZE;
|
||||
const pageRecords = currentRecords.slice(start, start + PAGE_SIZE);
|
||||
rowsNode.replaceChildren(...pageRecords.map(record => {
|
||||
rowsNode.replaceChildren(...currentRecords.map(record => {
|
||||
const row = document.createElement("tr");
|
||||
activeColumns.forEach(column => {
|
||||
const cell = document.createElement("td");
|
||||
@@ -471,7 +543,7 @@
|
||||
});
|
||||
return row;
|
||||
}));
|
||||
renderPagination(pageCount, start, pageRecords.length);
|
||||
renderPagination();
|
||||
}
|
||||
|
||||
function pageItems(page, count) {
|
||||
@@ -486,22 +558,96 @@
|
||||
return items;
|
||||
}
|
||||
|
||||
function renderPagination(pageCount, start, visibleCount) {
|
||||
function renderPagination() {
|
||||
const pageCount = currentPagination.total_pages;
|
||||
currentPage = currentPagination.page || 1;
|
||||
const controls = [];
|
||||
const button = (label, page, disabled = false, active = false) => {
|
||||
const button = (label, page, disabled = false, active = false, cursor = "") => {
|
||||
const node = document.createElement("button"); node.type = "button"; node.textContent = label; node.disabled = disabled; node.classList.toggle("active", active);
|
||||
if (!disabled && !active) node.addEventListener("click", () => { currentPage = page; render(currentRecords); });
|
||||
if (!disabled && !active) node.addEventListener("click", () => load(true, page, cursor));
|
||||
return node;
|
||||
};
|
||||
controls.push(button("上一页", currentPage - 1, currentPage === 1));
|
||||
pageItems(currentPage, pageCount).forEach(item => {
|
||||
controls.push(button("上一页", currentPage - 1, currentPage <= 1, false, currentPagination.previous_cursor || ""));
|
||||
pageItems(currentPage, Math.max(1, pageCount)).forEach(item => {
|
||||
if (typeof item === "string") { const ellipsis = document.createElement("span"); ellipsis.className = "page-ellipsis"; ellipsis.textContent = "…"; controls.push(ellipsis); }
|
||||
else controls.push(button(String(item), item, false, item === currentPage));
|
||||
});
|
||||
controls.push(button("下一页", currentPage + 1, currentPage === pageCount));
|
||||
controls.push(button("下一页", currentPage + 1, pageCount === 0 || currentPage >= pageCount, false, currentPagination.next_cursor || ""));
|
||||
pageButtonsNode.replaceChildren(...controls);
|
||||
}
|
||||
|
||||
function usageFilterQuery() {
|
||||
const params = new URLSearchParams();
|
||||
const from = document.querySelector("#usage-from").value;
|
||||
const to = document.querySelector("#usage-to").value;
|
||||
if (from) params.set("from", new Date(from).toISOString());
|
||||
if (to) params.set("to", new Date(to).toISOString());
|
||||
[["key_id", "#usage-key"], ["model", "#usage-model"], ["result", "#usage-result"], ["auth_id", "#usage-auth"], ["endpoint", "#usage-endpoint"], ["request_id", "#usage-request-id"]].forEach(([name, selector]) => {
|
||||
const value = document.querySelector(selector).value.trim();
|
||||
if (value) params.set(name, value);
|
||||
});
|
||||
return params;
|
||||
}
|
||||
|
||||
function demoUsagePage(page) {
|
||||
const params = usageFilterQuery();
|
||||
const from = params.get("from") ? new Date(params.get("from")) : null;
|
||||
const to = params.get("to") ? new Date(params.get("to")) : null;
|
||||
const resultValue = record => record.outcome === "canceled" ? "canceled" : record.outcome === "rejected" ? "rejected" : (record.failed || record.outcome === "failed") ? "failed" : "succeeded";
|
||||
const endpointValue = record => endpoint(record.endpoint);
|
||||
const records = demoState.usage.filter(record => {
|
||||
const requestedAt = new Date(record.requested_at);
|
||||
return (!from || requestedAt >= from) && (!to || requestedAt < to) &&
|
||||
(!params.get("key_id") || record.managed_key_id === params.get("key_id")) &&
|
||||
(!params.get("model") || String(record.model || "").toLowerCase() === params.get("model").toLowerCase()) &&
|
||||
(!params.get("result") || resultValue(record) === params.get("result")) &&
|
||||
(!params.get("auth_id") || record.auth_id === params.get("auth_id")) &&
|
||||
(!params.get("endpoint") || endpointValue(record) === params.get("endpoint")) &&
|
||||
(!params.get("request_id") || record.request_id === params.get("request_id"));
|
||||
});
|
||||
const totalPages = Math.ceil(records.length / PAGE_SIZE);
|
||||
const safePage = Math.max(1, Math.min(page || 1, Math.max(1, totalPages)));
|
||||
const start = (safePage - 1) * PAGE_SIZE;
|
||||
return { records: records.slice(start, start + PAGE_SIZE), pagination: { page: safePage, page_size: PAGE_SIZE, total: records.length, total_pages: totalPages, previous_cursor: safePage > 1 ? "demo-previous" : "", next_cursor: safePage < totalPages ? "demo-next" : "" } };
|
||||
}
|
||||
|
||||
function demoUsageDashboard() {
|
||||
const today = startOfDay(new Date());
|
||||
const summarize = records => ({ requests: records.length, input_tokens: records.reduce((sum, item) => sum + (item.input_tokens || 0), 0), output_tokens: records.reduce((sum, item) => sum + (item.output_tokens || 0), 0), total_tokens: records.reduce((sum, item) => sum + (item.total_tokens || 0), 0), cost_usd: records.reduce((sum, item) => sum + (item.cost_available ? item.cost_usd || 0 : 0), 0) });
|
||||
const todayRecords = demoState.usage.filter(record => new Date(record.requested_at) >= today);
|
||||
const users = demoState.keys.map(key => {
|
||||
const records = demoState.usage.filter(record => record.managed_key_id === key.id);
|
||||
return { key_id: key.id, key_alias: key.name, today: summarize(records.filter(record => new Date(record.requested_at) >= today)), last_used_at: records[0]?.requested_at || null };
|
||||
});
|
||||
const days = Array.from({ length: 7 }, (_, index) => {
|
||||
const date = startOfDay(new Date()); date.setDate(date.getDate() - (6 - index));
|
||||
const end = new Date(date); end.setDate(end.getDate() + 1);
|
||||
return { date: `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`, total_tokens: demoState.usage.filter(record => { const value = new Date(record.requested_at); return value >= date && value < end; }).reduce((sum, record) => sum + (record.total_tokens || 0), 0) };
|
||||
});
|
||||
return { today: summarize(todayRecords), users, days };
|
||||
}
|
||||
|
||||
function populateUsageFilterOptions(keys, upstreams, models) {
|
||||
const keySelect = document.querySelector("#usage-key");
|
||||
const authSelect = document.querySelector("#usage-auth");
|
||||
const selectedKey = keySelect.value, selectedAuth = authSelect.value;
|
||||
keySelect.replaceChildren(option("", "全部用户"), ...keys.map(key => option(key.id, key.name, key.id === selectedKey)));
|
||||
authSelect.replaceChildren(option("", "全部上游"), ...upstreams.map(account => option(account.cpa_auth_id, account.display_name || account.cpa_auth_id, account.cpa_auth_id === selectedAuth)));
|
||||
document.querySelector("#usage-model-options").replaceChildren(...models.map(model => option(model, model)));
|
||||
}
|
||||
|
||||
async function loadUsageFilterOptions() {
|
||||
if (testMode) {
|
||||
populateUsageFilterOptions(demoState.keys, demoState.upstreams, [...new Set(demoState.usage.map(record => record.model).filter(Boolean))]);
|
||||
return;
|
||||
}
|
||||
if (!keyInput.value.trim()) return;
|
||||
try {
|
||||
const [keys, upstreams, models] = await Promise.all([managedFetch(KEYS_API + "?include_archived=1"), managedFetch(UPSTREAMS_API), managedFetch(MODELS_API)]);
|
||||
populateUsageFilterOptions(keys.keys || [], upstreams.accounts || [], models.models || []);
|
||||
} catch (error) { statusNode.textContent = "读取筛选项失败: " + error.message; }
|
||||
}
|
||||
|
||||
function authHeaders(json = false) {
|
||||
const headers = { Authorization: "Bearer " + keyInput.value.trim() };
|
||||
if (json) headers["Content-Type"] = "application/json";
|
||||
@@ -560,15 +706,25 @@
|
||||
const strict = document.querySelector("#editor-route").value === "strict";
|
||||
document.querySelector("#editor-upstream").disabled = archived || !strict;
|
||||
document.querySelector("#editor-models").disabled = archived || document.querySelector("#editor-all-models").checked;
|
||||
const period = document.querySelector("#editor-reset-period").value;
|
||||
const nextReset = document.querySelector("#editor-next-reset");
|
||||
nextReset.disabled = archived || period === "none";
|
||||
if (period !== "none" && !nextReset.value) {
|
||||
const next = new Date();
|
||||
if (period === "daily") next.setDate(next.getDate() + 1);
|
||||
else if (period === "weekly") next.setDate(next.getDate() + 7);
|
||||
else next.setMonth(next.getMonth() + 1);
|
||||
nextReset.value = localDateTimeValue(next);
|
||||
}
|
||||
}
|
||||
|
||||
function openKeyEditor(key = null) {
|
||||
editingKey = key;
|
||||
const creating = !key;
|
||||
document.querySelector("#drawer-title").textContent = creating ? "创建 Key" : `管理 ${key.name}`;
|
||||
document.querySelector("#drawer-subtitle").textContent = creating ? "创建后可继续配置路由和模型权限" : "修改用户状态、路由和模型权限";
|
||||
document.querySelector("#drawer-subtitle").textContent = creating ? "配置访问、模型、路由与初始额度" : "管理访问、模型、路由与额度";
|
||||
editorNode.classList.remove("hidden"); keyStatsNode.classList.add("hidden"); editorFooterNode.classList.remove("hidden");
|
||||
managedFieldsNode.classList.toggle("hidden", creating);
|
||||
managedFieldsNode.classList.remove("hidden");
|
||||
document.querySelector("#editor-name").value = key?.name || "";
|
||||
const secret = document.querySelector("#editor-secret"); secret.value = key?.secret || ""; secret.readOnly = !creating;
|
||||
document.querySelector("#editor-status").value = key?.status || "active";
|
||||
@@ -577,11 +733,24 @@
|
||||
upstream.replaceChildren(option("", "未指定", !key?.upstream_account_id), ...upstreamAccounts.map(account => option(account.id, `${upstreamLabel(account)}${account.disabled || account.unavailable ? "(不可用)" : ""}`, account.id === key?.upstream_account_id)));
|
||||
document.querySelector("#editor-all-models").checked = key?.all_models ?? true;
|
||||
const models = document.querySelector("#editor-models"); models.value = (key?.models || []).join(", "); models.placeholder = modelSuggestions.slice(0, 3).join(", ") || "gpt-5.6-sol";
|
||||
const billing = key?.billing || { quota_usd: "0", spent_usd: "0", balance_usd: "0", reset_period: "none", max_concurrency: 4, active_requests: 0 };
|
||||
document.querySelector("#editor-quota").value = billing.quota_usd || "0";
|
||||
document.querySelector("#editor-concurrency").value = billing.max_concurrency || 4;
|
||||
document.querySelector("#editor-reset-period").value = billing.reset_period || "none";
|
||||
document.querySelector("#editor-next-reset").value = localDateTimeValue(billing.next_reset_at);
|
||||
document.querySelector("#billing-quota-view").textContent = money(billing.quota_usd);
|
||||
document.querySelector("#billing-spent-view").textContent = money(billing.spent_usd);
|
||||
document.querySelector("#billing-balance-view").textContent = money(billing.balance_usd);
|
||||
document.querySelector("#billing-status").textContent = creating ? "新用户默认额度为 $0" : `${billing.active_requests || 0} 个请求正在执行`;
|
||||
document.querySelector("#billing-preview").classList.toggle("hidden", creating);
|
||||
document.querySelector("#ledger-section").classList.toggle("hidden", creating);
|
||||
document.querySelector("#reset-billing").classList.toggle("hidden", creating || key?.status === "archived");
|
||||
document.querySelector("#archive-key").classList.toggle("hidden", creating || key.status === "archived");
|
||||
document.querySelector("#save-key").classList.toggle("hidden", key?.status === "archived");
|
||||
document.querySelector("#save-key").textContent = creating ? "创建 Key" : "保存修改";
|
||||
["editor-name", "editor-status", "editor-route", "editor-all-models"].forEach(id => document.querySelector("#" + id).disabled = key?.status === "archived");
|
||||
["editor-name", "editor-status", "editor-route", "editor-all-models", "editor-quota", "editor-concurrency", "editor-reset-period"].forEach(id => document.querySelector("#" + id).disabled = key?.status === "archived");
|
||||
syncEditorRoute(); openDrawer();
|
||||
if (!creating) loadBillingLedger(key);
|
||||
}
|
||||
|
||||
function renderManagedKeys() {
|
||||
@@ -594,20 +763,20 @@
|
||||
const credential = document.createElement("div"); credential.className = "credential"; const secret = document.createElement("code"); secret.textContent = key.secret;
|
||||
const copy = document.createElement("button"); copy.type = "button"; copy.className = "small"; copy.textContent = "复制"; copy.addEventListener("click", () => copyText(key.secret, copy)); credential.append(secret, copy);
|
||||
const status = document.createElement("span"); status.className = `badge ${key.status}`; status.textContent = key.status === "active" ? "启用" : key.status === "disabled" ? "禁用" : "已归档";
|
||||
const billing = key.billing || {}; const balance = document.createElement("div"); const balanceMain = document.createElement("div"); balanceMain.className = "route-main"; balanceMain.textContent = money(billing.balance_usd); const balanceSub = document.createElement("div"); balanceSub.className = "route-sub"; balanceSub.textContent = `额度 ${money(billing.quota_usd)}${billing.reset_period && billing.reset_period !== "none" ? " · " + ({ daily: "每天", weekly: "每周", monthly: "每月" }[billing.reset_period] || billing.reset_period) : ""}`; balance.append(balanceMain, balanceSub);
|
||||
const concurrency = document.createElement("span"); concurrency.textContent = `${billing.active_requests || 0} / ${billing.max_concurrency || 4}`;
|
||||
const route = routeLabel(key); const routeNode = document.createElement("div"); const routeMain = document.createElement("div"); routeMain.className = "route-main"; routeMain.textContent = route.main; const routeSub = document.createElement("div"); routeSub.className = "route-sub"; routeSub.textContent = route.sub; routeNode.append(routeMain, routeSub);
|
||||
const models = document.createElement("span"); models.textContent = key.all_models ? "全部模型" : (key.models || []).join(", ") || "未配置";
|
||||
const actions = document.createElement("div"); actions.className = "key-actions";
|
||||
const stats = document.createElement("button"); stats.type = "button"; stats.className = "small"; stats.textContent = "统计"; stats.addEventListener("click", () => loadKeyStats(key));
|
||||
const manage = document.createElement("button"); manage.type = "button"; manage.className = "small"; manage.textContent = archived ? "查看" : "管理"; manage.addEventListener("click", () => openKeyEditor(key)); actions.append(stats, manage);
|
||||
[identity, credential, status, routeNode, models, actions].forEach(node => { const cell = document.createElement("td"); cell.className = "left"; cell.append(node); row.append(cell); });
|
||||
[identity, credential, status, balance, concurrency, routeNode, models, actions].forEach(node => { const cell = document.createElement("td"); cell.className = "left"; cell.append(node); row.append(cell); });
|
||||
return row;
|
||||
}));
|
||||
const today = startOfDay(new Date());
|
||||
const todayRecords = currentRecords.filter(record => new Date(record.requested_at) >= today);
|
||||
document.querySelector("#metric-requests").textContent = number(todayRecords.length);
|
||||
document.querySelector("#metric-tokens").textContent = compactNumber(todayRecords.reduce((sum, record) => sum + (record.total_tokens || 0), 0));
|
||||
const todayCost = todayRecords.reduce((sum, record) => sum + (record.cost_available && Number.isFinite(record.cost_usd) ? record.cost_usd : 0), 0);
|
||||
document.querySelector("#metric-cost").textContent = "$" + todayCost.toFixed(4);
|
||||
const todaySummary = usageDashboard.today || {};
|
||||
document.querySelector("#metric-requests").textContent = number(todaySummary.requests || 0);
|
||||
document.querySelector("#metric-tokens").textContent = compactNumber(todaySummary.total_tokens || 0);
|
||||
document.querySelector("#metric-cost").textContent = "$" + Number(todaySummary.cost_usd || 0).toFixed(4);
|
||||
renderUserCharts();
|
||||
}
|
||||
|
||||
@@ -620,25 +789,20 @@
|
||||
}
|
||||
|
||||
function renderUserCharts() {
|
||||
const today = startOfDay(new Date());
|
||||
const byUser = new Map(managedKeys.map(key => [key.name, { name: key.name, requests: 0, tokens: 0, cost: 0, last: null }]));
|
||||
currentRecords.forEach(record => {
|
||||
const name = record.key_alias || record.api_key || "未识别";
|
||||
(usageDashboard.users || []).forEach(user => {
|
||||
const name = user.key_alias || "未识别";
|
||||
if (!byUser.has(name)) byUser.set(name, { name, requests: 0, tokens: 0, cost: 0, last: null });
|
||||
const value = byUser.get(name); const requestedAt = new Date(record.requested_at);
|
||||
if (!value.last || requestedAt > value.last) value.last = requestedAt;
|
||||
if (requestedAt >= today) {
|
||||
value.requests++; value.tokens += record.total_tokens || 0;
|
||||
if (record.cost_available && Number.isFinite(record.cost_usd)) value.cost += record.cost_usd;
|
||||
}
|
||||
const value = byUser.get(name); const today = user.today || {};
|
||||
value.requests = today.requests || 0; value.tokens = today.total_tokens || 0; value.cost = today.cost_usd || 0;
|
||||
value.last = user.last_used_at ? new Date(user.last_used_at) : null;
|
||||
});
|
||||
const userRows = [...byUser.values()].sort((left, right) => right.tokens - left.tokens || left.name.localeCompare(right.name));
|
||||
const usageNode = document.querySelector("#user-usage");
|
||||
const header = document.createElement("div"); header.className = "user-usage-row header"; ["用户", "今日请求", "今日 Token", "今日成本", "最近使用"].forEach(label => { const cell = document.createElement("span"); cell.textContent = label; header.append(cell); });
|
||||
usageNode.replaceChildren(header, ...userRows.map(item => { const row = document.createElement("div"); row.className = "user-usage-row"; [item.name, number(item.requests), number(item.tokens), "$" + item.cost.toFixed(4), item.last ? item.last.toLocaleString() : "从未"].forEach(value => { const cell = document.createElement("span"); cell.textContent = value; row.append(cell); }); return row; }));
|
||||
|
||||
const days = Array.from({ length: 7 }, (_, index) => { const date = startOfDay(new Date()); date.setDate(date.getDate() - (6 - index)); return { date, tokens: 0 }; });
|
||||
currentRecords.forEach(record => { const requestedAt = new Date(record.requested_at); const day = days.find(item => requestedAt >= item.date && requestedAt < new Date(item.date.getTime() + 86400000)); if (day) day.tokens += record.total_tokens || 0; });
|
||||
const days = (usageDashboard.days || []).map(item => ({ date: new Date(item.date + "T00:00:00"), tokens: item.total_tokens || 0 }));
|
||||
const maximum = Math.max(1, ...days.map(day => day.tokens));
|
||||
document.querySelector("#daily-chart").replaceChildren(...days.map(day => { const row = document.createElement("div"); row.className = "bar-row"; const label = document.createElement("span"); label.className = "bar-label"; label.textContent = `${day.date.getMonth() + 1}/${day.date.getDate()}`; const track = document.createElement("span"); track.className = "bar-track"; const value = document.createElement("span"); value.className = "bar-value"; value.style.width = `${day.tokens / maximum * 100}%`; track.append(value); const count = document.createElement("span"); count.className = "bar-count"; count.textContent = compactNumber(day.tokens); row.append(label, track, count); return row; }));
|
||||
}
|
||||
@@ -648,7 +812,7 @@
|
||||
managedKeys = demoState.keys.filter(key => document.querySelector("#include-archived").checked || key.status !== "archived");
|
||||
upstreamAccounts = demoState.upstreams;
|
||||
modelSuggestions = ["deepseek-v4-flash", "deepseek-source-a", "deepseek-source-b", "deepseek-source-c"];
|
||||
currentRecords = demoState.usage;
|
||||
usageDashboard = demoUsageDashboard();
|
||||
renderManagedKeys();
|
||||
keyStatusNode.textContent = `测试数据 · ${managedKeys.length} 个 Key,${upstreamAccounts.length} 个上游账号`;
|
||||
return;
|
||||
@@ -656,10 +820,10 @@
|
||||
if (!keyInput.value.trim()) return;
|
||||
keyStatusNode.textContent = "正在同步";
|
||||
try {
|
||||
const [upstreams, models, usage] = await Promise.all([managedFetch(UPSTREAMS_API), managedFetch(MODELS_API), managedFetch(API)]);
|
||||
const [upstreams, models, summary] = await Promise.all([managedFetch(UPSTREAMS_API), managedFetch(MODELS_API), managedFetch(SUMMARY_API)]);
|
||||
upstreamAccounts = upstreams.accounts || [];
|
||||
modelSuggestions = models.models || [];
|
||||
currentRecords = usage.records || [];
|
||||
usageDashboard = summary;
|
||||
const archived = document.querySelector("#include-archived").checked ? "?include_archived=1" : "";
|
||||
const keys = await managedFetch(KEYS_API + archived);
|
||||
managedKeys = keys.keys || [];
|
||||
@@ -671,16 +835,19 @@
|
||||
async function createManagedKey() {
|
||||
const name = document.querySelector("#editor-name").value.trim();
|
||||
const secret = document.querySelector("#editor-secret").value.trim();
|
||||
const route = document.querySelector("#editor-route").value;
|
||||
const allModels = document.querySelector("#editor-all-models").checked;
|
||||
const payload = { name, secret, route_mode: route, upstream_account_id: route === "strict" ? document.querySelector("#editor-upstream").value : "", all_models: allModels, models: document.querySelector("#editor-models").value.split(",").map(value => value.trim()).filter(Boolean), billing: billingPayload() };
|
||||
try {
|
||||
if (testMode) {
|
||||
if (!name) throw new Error("请输入名称");
|
||||
if (demoState.keys.some(key => key.name.toLowerCase() === name.toLowerCase())) throw new Error("名称已经存在");
|
||||
const created = { id: "demo-key-" + Date.now(), name, secret: secret || `demo-${Date.now()}-000000`, status: "active", route_mode: "auto", upstream_account_id: "", all_models: true, models: [] };
|
||||
const created = { id: "demo-key-" + Date.now(), ...payload, secret: secret || `demo-${Date.now()}-000000`, status: "active", billing: { ...payload.billing, spent_usd: "0", balance_usd: payload.billing.quota_usd, active_requests: 0, cycle_started_at: new Date().toISOString() } };
|
||||
demoState.keys.push(created); saveDemoState(); closeDrawer(); await loadKeyManagement();
|
||||
keyStatusNode.textContent = `测试数据已创建 ${created.name}:${created.secret}`;
|
||||
return;
|
||||
}
|
||||
const created = await managedFetch(KEYS_API, { method: "POST", body: JSON.stringify({ name, secret }) });
|
||||
const created = await managedFetch(KEYS_API, { method: "POST", body: JSON.stringify(payload) });
|
||||
keyStatusNode.textContent = `已创建 ${created.name}:${created.secret}`;
|
||||
closeDrawer(); await loadKeyManagement();
|
||||
} catch (error) { keyStatusNode.textContent = "创建失败: " + error.message; }
|
||||
@@ -691,7 +858,11 @@
|
||||
if (testMode) {
|
||||
const index = demoState.keys.findIndex(key => key.id === payload.id);
|
||||
if (index < 0) throw new Error("测试 Key 不存在");
|
||||
demoState.keys[index] = { ...demoState.keys[index], ...payload }; saveDemoState(); closeDrawer(); await loadKeyManagement(); return;
|
||||
const previous = demoState.keys[index]; const spent = Number(previous.billing?.spent_usd || 0); const previousQuota = Number(previous.billing?.quota_usd || 0); const nextQuota = Number(payload.billing?.quota_usd || 0);
|
||||
const billing = { ...previous.billing, ...payload.billing, spent_usd: String(spent), balance_usd: String(nextQuota - spent), active_requests: previous.billing?.active_requests || 0 };
|
||||
demoState.keys[index] = { ...previous, ...payload, billing };
|
||||
if (previousQuota !== nextQuota) demoState.ledger.push({ id: Date.now(), key_id: previous.id, kind: "quota_change", amount_usd: String(nextQuota - previousQuota), balance_after_usd: String(nextQuota - spent), occurred_at: new Date().toISOString() });
|
||||
saveDemoState(); closeDrawer(); await loadKeyManagement(); return;
|
||||
}
|
||||
await managedFetch(KEYS_API, { method: "PATCH", body: JSON.stringify(payload) }); closeDrawer(); await loadKeyManagement();
|
||||
}
|
||||
@@ -709,10 +880,41 @@
|
||||
route_mode: route,
|
||||
upstream_account_id: route === "strict" ? document.querySelector("#editor-upstream").value : "",
|
||||
all_models: allModels,
|
||||
models: document.querySelector("#editor-models").value.split(",").map(value => value.trim()).filter(Boolean)
|
||||
models: document.querySelector("#editor-models").value.split(",").map(value => value.trim()).filter(Boolean),
|
||||
billing: billingPayload()
|
||||
});
|
||||
}
|
||||
|
||||
async function loadBillingLedger(key) {
|
||||
const node = document.querySelector("#billing-ledger");
|
||||
node.replaceChildren(Object.assign(document.createElement("div"), { className: "empty", textContent: "正在读取" }));
|
||||
try {
|
||||
const entries = testMode ? demoState.ledger.filter(item => item.key_id === key.id).sort((a, b) => b.id - a.id).slice(0, 50) : (await managedFetch(BILLING_LEDGER_API + "?id=" + encodeURIComponent(key.id) + "&limit=50")).entries || [];
|
||||
node.replaceChildren(...(entries.length ? entries.map(entry => {
|
||||
const row = document.createElement("div"); row.className = "ledger-item";
|
||||
const label = document.createElement("strong"); label.textContent = ({ charge: "请求扣费", quota_change: "调整额度", cycle_reset: "额度重置" }[entry.kind] || entry.kind);
|
||||
const amount = document.createElement("strong"); const numeric = Number(entry.amount_usd || 0); amount.className = numeric < 0 ? "negative" : "positive"; amount.textContent = (numeric > 0 ? "+" : "") + money(numeric);
|
||||
const detail = document.createElement("small"); detail.textContent = `${new Date(entry.occurred_at).toLocaleString()} · 余额 ${money(entry.balance_after_usd)}${entry.model ? " · " + entry.model : ""}${entry.request_id ? " · " + entry.request_id : ""}`;
|
||||
row.append(label, amount, detail); return row;
|
||||
}) : [Object.assign(document.createElement("div"), { className: "empty", textContent: "暂无账目" })]));
|
||||
} catch (error) { node.replaceChildren(Object.assign(document.createElement("div"), { className: "empty", textContent: "读取失败:" + error.message })); }
|
||||
}
|
||||
|
||||
async function resetManagedBilling() {
|
||||
if (!editingKey || !confirm(`立即重置 ${editingKey.name} 的额度?当前剩余余额不会结转。`)) return;
|
||||
try {
|
||||
if (testMode) {
|
||||
const key = demoState.keys.find(item => item.id === editingKey.id); const quota = Number(key.billing.quota_usd || 0); const now = new Date();
|
||||
key.billing.spent_usd = "0"; key.billing.balance_usd = String(quota); key.billing.cycle_started_at = now.toISOString();
|
||||
if (key.billing.reset_period !== "none") { const next = new Date(now); if (key.billing.reset_period === "daily") next.setDate(next.getDate() + 1); else if (key.billing.reset_period === "weekly") next.setDate(next.getDate() + 7); else next.setMonth(next.getMonth() + 1); key.billing.next_reset_at = next.toISOString(); }
|
||||
demoState.ledger.push({ id: Date.now(), key_id: key.id, kind: "cycle_reset", amount_usd: String(quota), balance_after_usd: String(quota), occurred_at: now.toISOString() }); saveDemoState(); editingKey = key;
|
||||
} else {
|
||||
await managedFetch(BILLING_RESET_API, { method: "POST", body: JSON.stringify({ id: editingKey.id }) });
|
||||
}
|
||||
await loadKeyManagement(); const refreshed = managedKeys.find(item => item.id === editingKey.id); if (refreshed) openKeyEditor(refreshed);
|
||||
} catch (error) { document.querySelector("#billing-status").textContent = "重置失败:" + error.message; }
|
||||
}
|
||||
|
||||
async function archiveManagedKey(key) {
|
||||
if (!confirm(`永久归档 ${key.name}?该 Key 将不能恢复,但历史统计会保留。`)) return;
|
||||
try {
|
||||
@@ -757,7 +959,7 @@
|
||||
document.querySelectorAll(".nav-button").forEach(button => button.classList.toggle("active", button.dataset.view === view));
|
||||
document.querySelectorAll(".view").forEach(panel => panel.classList.toggle("active", panel.id === "view-" + view));
|
||||
try { sessionStorage.setItem("cpa-ext:active-view", view); } catch (_) {}
|
||||
if (view === "usage") load(true);
|
||||
if (view === "usage") { loadUsageFilterOptions(); load(true, 1); }
|
||||
if (view === "pricing") loadPrices();
|
||||
if (view === "keys") loadKeyManagement();
|
||||
}
|
||||
@@ -769,13 +971,14 @@
|
||||
document.querySelector("#demo-banner").classList.toggle("hidden", !testMode);
|
||||
keyInput.disabled = testMode;
|
||||
keyInput.placeholder = testMode ? "测试模式无需密钥" : "输入管理密钥";
|
||||
lastSignature = ""; currentPage = 1; closeDrawer();
|
||||
lastSignature = ""; currentPage = 1; currentPagination = { page: 1, page_size: PAGE_SIZE, total: 0, total_pages: 0, previous_cursor: "", next_cursor: "" }; closeDrawer();
|
||||
currentRecords = []; managedKeys = []; upstreamAccounts = []; currentPrices = [];
|
||||
usageDashboard = { today: { requests: 0, total_tokens: 0, cost_usd: 0 }, users: [], days: [] };
|
||||
render([]); renderManagedKeys(); renderPrices();
|
||||
try { sessionStorage.setItem("cpa-ext:data-mode", mode); } catch (_) {}
|
||||
const active = document.querySelector(".nav-button.active")?.dataset.view || "keys";
|
||||
if (active === "keys") loadKeyManagement();
|
||||
if (active === "usage") load(true);
|
||||
if (active === "usage") { loadUsageFilterOptions(); load(true, 1); }
|
||||
if (active === "pricing") loadPrices();
|
||||
}
|
||||
|
||||
@@ -895,10 +1098,12 @@
|
||||
} catch (error) { priceStatusNode.textContent = "删除失败: " + error.message; }
|
||||
}
|
||||
|
||||
async function load(manual = false) {
|
||||
async function load(manual = false, page = currentPage, cursor = "") {
|
||||
if (loading) return;
|
||||
if (testMode) {
|
||||
currentRecords = demoState.usage; render(currentRecords);
|
||||
const payload = demoUsagePage(page);
|
||||
currentPagination = payload.pagination;
|
||||
render(payload.records);
|
||||
statusNode.textContent = "";
|
||||
return;
|
||||
}
|
||||
@@ -910,15 +1115,17 @@
|
||||
loading = true;
|
||||
if (manual) statusNode.textContent = "正在读取";
|
||||
try {
|
||||
const response = await fetch(API, { headers: authHeaders() });
|
||||
const params = usageFilterQuery();
|
||||
params.set("page", String(page || 1));
|
||||
params.set("page_size", String(PAGE_SIZE));
|
||||
if (cursor && !cursor.startsWith("demo-")) params.set("cursor", cursor);
|
||||
const response = await fetch(API + "?" + params.toString(), { headers: authHeaders() });
|
||||
const payload = await response.json();
|
||||
if (!response.ok) throw new Error(payload?.error?.message || "HTTP " + response.status);
|
||||
const records = payload.records || [];
|
||||
const signature = JSON.stringify(records);
|
||||
if (signature !== lastSignature) {
|
||||
lastSignature = signature;
|
||||
render(records);
|
||||
}
|
||||
currentPagination = payload.pagination || { page: 1, page_size: PAGE_SIZE, total: 0, total_pages: 0 };
|
||||
const records = payload.records || [];
|
||||
lastSignature = JSON.stringify(payload);
|
||||
render(records);
|
||||
statusNode.textContent = "";
|
||||
} catch (error) {
|
||||
statusNode.textContent = "读取失败: " + error.message;
|
||||
@@ -929,7 +1136,10 @@
|
||||
|
||||
keyInput.value = storedPanelKey() || sessionStorage.getItem("cpa-ext:management-key") || "";
|
||||
keyInput.addEventListener("change", () => sessionStorage.setItem("cpa-ext:management-key", keyInput.value.trim()));
|
||||
document.querySelector("#refresh").addEventListener("click", () => load(true));
|
||||
document.querySelector("#refresh").addEventListener("click", () => load(true, currentPage));
|
||||
document.querySelector("#apply-usage-filters").addEventListener("click", () => { currentPage = 1; load(true, 1); });
|
||||
document.querySelector("#clear-usage-filters").addEventListener("click", () => { ["usage-from", "usage-to", "usage-model", "usage-request-id"].forEach(id => document.querySelector("#" + id).value = ""); ["usage-key", "usage-result", "usage-auth", "usage-endpoint"].forEach(id => document.querySelector("#" + id).value = ""); currentPage = 1; load(true, 1); });
|
||||
document.querySelector("#usage-request-id").addEventListener("keydown", event => { if (event.key === "Enter") { event.preventDefault(); currentPage = 1; load(true, 1); } });
|
||||
document.querySelector("#long-enabled").addEventListener("change", event => setLongFields(event.target.checked));
|
||||
document.querySelector("#save-price").addEventListener("click", savePrice);
|
||||
document.querySelector("#clear-price").addEventListener("click", clearPriceForm);
|
||||
@@ -944,15 +1154,17 @@
|
||||
document.querySelector("#archive-key").addEventListener("click", () => editingKey && archiveManagedKey(editingKey));
|
||||
document.querySelector("#editor-route").addEventListener("change", syncEditorRoute);
|
||||
document.querySelector("#editor-all-models").addEventListener("change", syncEditorRoute);
|
||||
document.querySelector("#editor-reset-period").addEventListener("change", syncEditorRoute);
|
||||
document.querySelector("#reset-billing").addEventListener("click", resetManagedBilling);
|
||||
document.querySelectorAll(".nav-button").forEach(button => button.addEventListener("click", () => setView(button.dataset.view)));
|
||||
document.querySelectorAll(".mode-button").forEach(button => button.addEventListener("click", () => setMode(button.dataset.mode)));
|
||||
document.querySelector("#reset-demo").addEventListener("click", () => { demoState = freshDemoState(); saveDemoState(); currentPage = 1; setMode("demo"); });
|
||||
document.addEventListener("keydown", event => { if (event.key === "Escape") closeDrawer(); });
|
||||
document.addEventListener("visibilitychange", () => { if (!testMode && !document.hidden && document.querySelector('[data-view="usage"]').classList.contains("active")) load(); });
|
||||
document.addEventListener("visibilitychange", () => { if (!testMode && currentPage === 1 && !document.hidden && document.querySelector('[data-view="usage"]').classList.contains("active")) load(false, 1); });
|
||||
renderColumnControls();
|
||||
setMode(sessionStorage.getItem("cpa-ext:data-mode") || "live");
|
||||
setView(sessionStorage.getItem("cpa-ext:active-view") || "keys");
|
||||
setInterval(() => { if (!testMode && !document.hidden && document.querySelector('[data-view="usage"]').classList.contains("active")) load(); }, 3000);
|
||||
setInterval(() => { if (!testMode && currentPage === 1 && !document.hidden && document.querySelector('[data-view="usage"]').classList.contains("active")) load(false, 1); }, 3000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user