feat(server): API relay gateway backend M0-M4
Gin + GORM + pure-Go SQLite. Users/auth (JWT), API key management with quotas, proxy gateway with weighted channel failover and health checks, usage/billing ledger, cross-protocol conversion (Anthropic Messages / OpenAI Chat Completions / OpenAI Responses), and channel/model admin API. Channels declare native API formats and auto-convert the rest. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
b0c7439c01
commit
d0e31b198f
@@ -0,0 +1,160 @@
|
||||
package apikey
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"openteam/server/internal/pkg/crypto"
|
||||
"openteam/server/internal/pkg/rand"
|
||||
"openteam/server/internal/store"
|
||||
)
|
||||
|
||||
var ErrKeyNotFound = errors.New("api key not found")
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB, log *zap.Logger) *Service {
|
||||
return &Service{db: db, log: log}
|
||||
}
|
||||
|
||||
type CreateInput struct {
|
||||
Name string `json:"name" binding:"required,max=128"`
|
||||
QuotaTokensPerDay *int64 `json:"quotaTokensPerDay"`
|
||||
QuotaRequestsPerDay *int `json:"quotaRequestsPerDay"`
|
||||
AllowedModels []string `json:"allowedModels"`
|
||||
ExpiresInDays *int `json:"expiresInDays"`
|
||||
}
|
||||
|
||||
type UpdateInput struct {
|
||||
Name *string `json:"name"`
|
||||
QuotaTokensPerDay *int64 `json:"quotaTokensPerDay"`
|
||||
QuotaRequestsPerDay *int `json:"quotaRequestsPerDay"`
|
||||
AllowedModels []string `json:"allowedModels"`
|
||||
ExpiresAt *time.Time `json:"expiresAt"`
|
||||
Status *string `json:"status"`
|
||||
}
|
||||
|
||||
// Create generates a key and returns it once along with the stored record.
|
||||
func (s *Service) Create(userID int64, in CreateInput) (*store.ApiKey, string, error) {
|
||||
plain, err := rand.Base62(48)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
full := "sk-" + plain
|
||||
rec := &store.ApiKey{
|
||||
UserID: userID,
|
||||
Name: in.Name,
|
||||
KeyHash: crypto.HashSHA256(full),
|
||||
KeyPrefix: "sk-" + plain[:8],
|
||||
QuotaTokensPerDay: in.QuotaTokensPerDay,
|
||||
QuotaRequestsPerDay: in.QuotaRequestsPerDay,
|
||||
AllowedModels: in.AllowedModels,
|
||||
Status: "active",
|
||||
}
|
||||
if in.ExpiresInDays != nil && *in.ExpiresInDays > 0 {
|
||||
t := time.Now().AddDate(0, 0, *in.ExpiresInDays)
|
||||
rec.ExpiresAt = &t
|
||||
}
|
||||
if err := s.db.Create(rec).Error; err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return rec, full, nil
|
||||
}
|
||||
|
||||
// List returns the user's keys (never the hash).
|
||||
func (s *Service) List(userID int64) ([]store.ApiKey, error) {
|
||||
var keys []store.ApiKey
|
||||
err := s.db.Where("user_id = ?", userID).Order("id DESC").Find(&keys).Error
|
||||
return keys, err
|
||||
}
|
||||
|
||||
// Update patches a key owned by the user.
|
||||
func (s *Service) Update(userID, keyID int64, in UpdateInput) (*store.ApiKey, error) {
|
||||
var k store.ApiKey
|
||||
if err := s.db.First(&k, "id = ? AND user_id = ?", keyID, userID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrKeyNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
updates := map[string]any{}
|
||||
if in.Name != nil {
|
||||
updates["name"] = *in.Name
|
||||
}
|
||||
if in.QuotaTokensPerDay != nil {
|
||||
updates["quota_tokens_per_day"] = *in.QuotaTokensPerDay
|
||||
}
|
||||
if in.QuotaRequestsPerDay != nil {
|
||||
updates["quota_requests_per_day"] = *in.QuotaRequestsPerDay
|
||||
}
|
||||
if in.AllowedModels != nil {
|
||||
// GORM map updates skip the json serializer, so encode explicitly.
|
||||
modelsJSON, err := json.Marshal(in.AllowedModels)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
updates["allowed_models"] = string(modelsJSON)
|
||||
}
|
||||
if in.ExpiresAt != nil {
|
||||
updates["expires_at"] = in.ExpiresAt
|
||||
}
|
||||
if in.Status != nil {
|
||||
updates["status"] = *in.Status
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
if err := s.db.Model(&k).Updates(updates).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return s.Get(userID, keyID)
|
||||
}
|
||||
|
||||
// Delete revokes a key (soft revoke by status).
|
||||
func (s *Service) Delete(userID, keyID int64) error {
|
||||
res := s.db.Model(&store.ApiKey{}).
|
||||
Where("id = ? AND user_id = ?", keyID, userID).
|
||||
Update("status", "revoked")
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return ErrKeyNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get loads one key owned by the user.
|
||||
func (s *Service) Get(userID, keyID int64) (*store.ApiKey, error) {
|
||||
var k store.ApiKey
|
||||
if err := s.db.First(&k, "id = ? AND user_id = ?", keyID, userID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrKeyNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &k, nil
|
||||
}
|
||||
|
||||
// Public returns a DTO without the hash.
|
||||
func Public(k *store.ApiKey) map[string]any {
|
||||
return map[string]any{
|
||||
"id": k.ID,
|
||||
"userId": k.UserID,
|
||||
"name": k.Name,
|
||||
"keyPrefix": k.KeyPrefix,
|
||||
"quotaTokensPerDay": k.QuotaTokensPerDay,
|
||||
"quotaRequestsPerDay": k.QuotaRequestsPerDay,
|
||||
"allowedModels": k.AllowedModels,
|
||||
"expiresAt": k.ExpiresAt,
|
||||
"status": k.Status,
|
||||
"lastUsedAt": k.LastUsedAt,
|
||||
"createdAt": k.CreatedAt,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user