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:
Sakurasan
2026-08-15 21:05:02 +08:00
co-authored by Claude Sonnet 5
parent b0c7439c01
commit d0e31b198f
45 changed files with 6222 additions and 0 deletions
+160
View File
@@ -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,
}
}