Files
cpa-plugin/internal/plugin/key_management.go
T

487 lines
18 KiB
Go

package plugin
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"unicode"
managedaccess "billing/internal/access"
"billing/internal/collection"
"billing/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"`
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"`
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"`
LifetimeSpentUSD string `json:"lifetime_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 {
ID string `json:"id"`
}
type managedKeyStatsResponse struct {
Stats managedaccess.KeyStats `json:"stats"`
Recent []usageListItem `json:"recent"`
}
func (a *App) listManagedKeys(includeArchived bool) ManagementResponse {
store, ok := a.currentStore()
if !ok {
return managementError(http.StatusServiceUnavailable, "database_unavailable", "Key 数据库尚未初始化")
}
keys, err := store.ListManagedKeys(context.Background(), includeArchived)
if err != nil {
return managementError(http.StatusInternalServerError, "database_error", err.Error())
}
items := make([]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 {
var req createManagedKeyRequest
if err := json.Unmarshal(body, &req); err != nil {
return managementError(http.StatusBadRequest, "invalid_request", "无法解析 Key 请求")
}
store, ok := a.currentStore()
if !ok {
return managementError(http.StatusServiceUnavailable, "database_unavailable", "Key 数据库尚未初始化")
}
name := strings.TrimSpace(req.Name)
if name == "" || len(name) > 64 {
return managementError(http.StatusBadRequest, "invalid_name", "Key 名称必须为 1-64 个字符")
}
secret := strings.TrimSpace(req.Secret)
if secret == "" {
var generateErr error
secret, generateErr = generatedToken("cpa_")
if generateErr != nil {
return managementError(http.StatusInternalServerError, "random_unavailable", "无法生成 Key")
}
}
if !validManagedSecret(secret) {
return managementError(http.StatusBadRequest, "invalid_key", "Key 必须为 6-256 个不含空白或控制字符的字符")
}
keyID, err := generatedToken("key_")
if err != nil {
return managementError(http.StatusInternalServerError, "random_unavailable", "无法生成 Key ID")
}
key := managedaccess.ManagedKey{
ID: keyID, Name: name, Secret: secret,
Status: managedaccess.StatusActive, RouteMode: managedaccess.RouteAuto, AllModels: true,
}
if template, err := store.ManagedKeyByID(context.Background(), "key_default"); err == nil {
key.RouteMode = template.RouteMode
key.UpstreamAccountID = template.UpstreamAccountID
key.AllModels = template.AllModels
key.Models = append([]string(nil), template.Models...)
}
if req.RouteMode != "" {
key.RouteMode = req.RouteMode
}
if req.UpstreamAccountID != "" {
key.UpstreamAccountID = strings.TrimSpace(req.UpstreamAccountID)
}
if req.AllModels != nil {
key.AllModels = *req.AllModels
}
if req.Models != nil {
key.Models = normalizeModels(*req.Models)
}
if key.RouteMode == managedaccess.RouteAuto {
key.UpstreamAccountID = ""
}
if key.AllModels {
key.Models = nil
}
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)
state, _ := store.BillingState(context.Background(), key.ID, time.Now().UTC())
return jsonManagementResponse(http.StatusCreated, managedKeyResponse(created, state))
}
func (a *App) updateManagedKey(body []byte) ManagementResponse {
var req updateManagedKeyRequest
if err := json.Unmarshal(body, &req); err != nil || strings.TrimSpace(req.ID) == "" {
return managementError(http.StatusBadRequest, "invalid_request", "缺少有效的 Key ID")
}
store, ok := a.currentStore()
if !ok {
return managementError(http.StatusServiceUnavailable, "database_unavailable", "Key 数据库尚未初始化")
}
key, err := store.ManagedKeyByID(context.Background(), req.ID)
if err != nil {
return managementError(http.StatusNotFound, "key_not_found", "Key 不存在")
}
if req.Name != nil {
key.Name = strings.TrimSpace(*req.Name)
}
if req.Status != nil {
key.Status = strings.TrimSpace(*req.Status)
}
if req.RouteMode != nil {
key.RouteMode = strings.TrimSpace(*req.RouteMode)
}
if req.UpstreamAccountID != nil {
key.UpstreamAccountID = strings.TrimSpace(*req.UpstreamAccountID)
}
if req.AllModels != nil {
key.AllModels = *req.AllModels
}
if req.Models != nil {
key.Models = normalizeModels(*req.Models)
}
if key.RouteMode == managedaccess.RouteAuto {
key.UpstreamAccountID = ""
}
if key.AllModels {
key.Models = nil
}
if key.Name == "" || len(key.Name) > 64 {
return managementError(http.StatusBadRequest, "invalid_name", "Key 名称必须为 1-64 个字符")
}
if key.Status != managedaccess.StatusActive && key.Status != managedaccess.StatusDisabled {
return managementError(http.StatusBadRequest, "invalid_status", "状态只能是 active 或 disabled")
}
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) {
status = http.StatusNotFound
}
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)
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: billingStateResponse(state)}
}
func (a *App) resetManagedKeyBilling(body []byte) ManagementResponse {
var request struct {
ID string `json:"id"`
All bool `json:"all"`
}
if err := json.Unmarshal(body, &request); err != nil || !request.All && strings.TrimSpace(request.ID) == "" {
return managementError(http.StatusBadRequest, "invalid_request", "缺少有效的 Key ID")
}
store, ok := a.currentStore()
if !ok {
return managementError(http.StatusServiceUnavailable, "database_unavailable", "额度数据库尚未初始化")
}
if request.All {
count, err := store.ResetAllBilling(context.Background(), time.Now().UTC())
if err != nil {
return managementError(http.StatusBadRequest, "reset_failed", err.Error())
}
return jsonManagementResponse(http.StatusOK, map[string]int{"reset_count": count})
}
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),
LifetimeSpentUSD: formatMicros(state.LifetimeSpentMicros),
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 {
var req archiveManagedKeyRequest
if err := json.Unmarshal(body, &req); err != nil || strings.TrimSpace(req.ID) == "" {
return managementError(http.StatusBadRequest, "invalid_request", "缺少有效的 Key ID")
}
store, ok := a.currentStore()
if !ok {
return managementError(http.StatusServiceUnavailable, "database_unavailable", "Key 数据库尚未初始化")
}
if err := store.ArchiveManagedKey(context.Background(), req.ID); err != nil {
return managementError(http.StatusNotFound, "key_not_found", "Key 不存在或已经归档")
}
return jsonManagementResponse(http.StatusOK, map[string]bool{"archived": true})
}
func (a *App) managedKeyStats(id string) ManagementResponse {
store, ok := a.currentStore()
if !ok {
return managementError(http.StatusServiceUnavailable, "database_unavailable", "Key 数据库尚未初始化")
}
if _, err := store.ManagedKeyByID(context.Background(), id); err != nil {
return managementError(http.StatusNotFound, "key_not_found", "Key 不存在")
}
location, _ := time.LoadLocation("Asia/Shanghai")
now := time.Now().In(location)
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, location)
stats, err := store.KeyStats(context.Background(), id, today)
if err != nil {
return managementError(http.StatusInternalServerError, "database_error", err.Error())
}
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, len(records.Records))
for _, record := range records.Records {
recent = append(recent, usageItem(record))
}
return jsonManagementResponse(http.StatusOK, managedKeyStatsResponse{Stats: stats, Recent: recent})
}
func (a *App) upstreamAccounts() ManagementResponse {
syncErr := a.syncUpstreamAccounts(context.Background())
store, ok := a.currentStore()
if !ok {
return managementError(http.StatusServiceUnavailable, "database_unavailable", "上游账号数据库尚未初始化")
}
if observedErr := a.flushObservedUpstreams(context.Background(), store); observedErr != nil && syncErr == nil {
syncErr = observedErr
}
accounts, err := store.ListUpstreamAccounts(context.Background())
if err != nil {
return managementError(http.StatusInternalServerError, "database_error", err.Error())
}
response := map[string]any{"accounts": accounts}
if syncErr != nil {
response["warning"] = syncErr.Error()
}
return jsonManagementResponse(http.StatusOK, response)
}
func (a *App) modelSuggestions() ManagementResponse {
store, ok := a.currentStore()
if !ok {
return managementError(http.StatusServiceUnavailable, "database_unavailable", "数据库尚未初始化")
}
models, err := store.ModelSuggestions(context.Background())
if err != nil {
return managementError(http.StatusInternalServerError, "database_error", err.Error())
}
return jsonManagementResponse(http.StatusOK, map[string]any{"models": models})
}
func (a *App) currentStore() (*repository.SQLiteUsageRepository, bool) {
a.mu.RLock()
defer a.mu.RUnlock()
return a.store, !a.closed && a.store != nil
}
func validateManagedKeyRule(store *repository.SQLiteUsageRepository, key managedaccess.ManagedKey) error {
if key.RouteMode != managedaccess.RouteAuto && key.RouteMode != managedaccess.RouteStrict {
return errors.New("路由模式只能是 auto 或 strict")
}
if key.RouteMode == managedaccess.RouteAuto {
key.UpstreamAccountID = ""
} else {
if strings.TrimSpace(key.UpstreamAccountID) == "" {
return errors.New("严格路由必须选择上游账号")
}
if _, err := store.UpstreamAccountByID(context.Background(), key.UpstreamAccountID); err != nil {
return errors.New("选择的上游账号不存在")
}
}
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
}
func normalizeModels(models []string) []string {
seen := make(map[string]struct{})
result := make([]string, 0, len(models))
for _, raw := range models {
model := strings.ToLower(strings.TrimSpace(raw))
if model == "" {
continue
}
if _, exists := seen[model]; exists {
continue
}
seen[model] = struct{}{}
result = append(result, model)
}
return result
}
func generatedToken(prefix string) (string, error) {
raw := make([]byte, 24)
if _, err := rand.Read(raw); err != nil {
return "", err
}
return prefix + base64.RawURLEncoding.EncodeToString(raw), nil
}
func validManagedSecret(secret string) bool {
if len(secret) < 6 || len(secret) > 256 {
return false
}
for _, value := range secret {
if unicode.IsSpace(value) || unicode.IsControl(value) {
return false
}
}
return true
}
func managementError(status int, code, message string) ManagementResponse {
return jsonManagementResponse(status, map[string]any{"error": map[string]string{"code": code, "message": message}})
}