refactor: complete backend rewrite for multi-protocol proxy

Major rewrite of the Go backend to support:
- Three API format imports: openai, anthropic, compatible
- Three protocol conversions: Chat Completions, Responses, Messages
- Hub-and-spoke architecture with Chat as intermediate format

Deleted:
- opencat.go (old entry)
- store/, team/, pkg/team/, pkg/store/ (old data layer)
- internal/model/, internal/consts/ (old types)
- internal/service/team/, internal/controller/team/ (old handlers)
- llm/ (removed LLM client library, pure proxy mode)
- dist/, assets/ (old build artifacts)

Added:
- internal/store/ — 9 GORM models + multi-DB support
- internal/pkg/ — crypto (AES-GCM), apikey, jwt, ratelimit, resp, tokenizer
- internal/channel/ — channel selection, weighted LB, health checks
- internal/proxy/convert/ — 6 protocol conversion functions + SSE streaming
- internal/proxy/ — gateway with request dispatch and upstream selection
- internal/usage/ — async usage recorder with batch writes
- internal/api/ — management API (auth, users, keys, channels, models)
- Makefile for build/test/deploy

Fixed API to match frontend expectations:
- Login response wraps token in { data: { token } }
- GET /api/profile route added
- Profile response wraps user in { code, data }
- Role returned as number (10=admin, 1=user)
This commit is contained in:
Sakurasan
2026-08-30 11:49:31 +08:00
parent aa0d87f132
commit ef3025dd80
127 changed files with 4623 additions and 10500 deletions
+30
View File
@@ -0,0 +1,30 @@
package apikey
import (
"crypto/rand"
"encoding/hex"
"opencatd-open/internal/pkg/crypto"
"strings"
)
const Prefix = "sk-ot-"
// Generate 生成新的 API Key,返回明文和哈希
func Generate() (plaintext, hash string) {
b := make([]byte, 24)
_, _ = rand.Read(b)
raw := hex.EncodeToString(b)
plaintext = Prefix + raw
hash = crypto.Sha256Hex(plaintext)
return
}
// Valid 校验 API Key 格式
func Valid(key string) bool {
return strings.HasPrefix(key, Prefix)
}
// Hash 计算 API Key 的 SHA-256 哈希
func Hash(key string) string {
return crypto.Sha256Hex(key)
}
+105
View File
@@ -0,0 +1,105 @@
package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"io"
"os"
)
func defaultKey() []byte {
key := os.Getenv("ENCRYPT_KEY")
if key == "" {
key = "opencatd-default-key-change-me"
}
h := sha256.Sum256([]byte(key))
return h[:] // 32 bytes
}
// Encrypt encrypts plaintext using AES-GCM with the default key
func Encrypt(plaintext string) (string, error) {
enc, err := NewEncryptor(defaultKey())
if err != nil {
return "", err
}
return enc.Encrypt(plaintext)
}
// Decrypt decrypts ciphertext using AES-GCM with the default key
func Decrypt(encoded string) (string, error) {
enc, err := NewEncryptor(defaultKey())
if err != nil {
return "", err
}
return enc.Decrypt(encoded)
}
// Sha256Hex is a convenience wrapper for SHA-256 hex hashing
func Sha256Hex(data string) string {
h := sha256.Sum256([]byte(data))
return hex.EncodeToString(h[:])
}
// Encryptor AES-GCM 加密器
type Encryptor struct {
key []byte
}
// NewEncryptor 创建加密器(key 为 16/24/32 字节)
func NewEncryptor(key []byte) (*Encryptor, error) {
switch len(key) {
case 16, 24, 32:
default:
return nil, errors.New("crypto: invalid key length, must be 16, 24, or 32 bytes")
}
return &Encryptor{key: key}, nil
}
// Encrypt AES-GCM 加密,返回 base64 编码的密文
func (e *Encryptor) Encrypt(plaintext string) (string, error) {
block, err := aes.NewCipher(e.key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
// Decrypt AES-GCM 解密
func (e *Encryptor) Decrypt(encoded string) (string, error) {
data, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", err
}
block, err := aes.NewCipher(e.key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonceSize := gcm.NonceSize()
if len(data) < nonceSize {
return "", errors.New("ciphertext too short")
}
nonce, ciphertext := data[:nonceSize], data[nonceSize:]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", err
}
return string(plaintext), nil
}
+55
View File
@@ -0,0 +1,55 @@
package crypto
import (
"testing"
)
func TestEncryptDecrypt(t *testing.T) {
plaintext := "sk-test-api-key-12345"
encrypted, err := Encrypt(plaintext)
if err != nil {
t.Fatalf("Encrypt() error = %v", err)
}
if encrypted == plaintext {
t.Error("Encrypt() returned plaintext")
}
decrypted, err := Decrypt(encrypted)
if err != nil {
t.Fatalf("Decrypt() error = %v", err)
}
if decrypted != plaintext {
t.Errorf("Decrypt() = %q, want %q", decrypted, plaintext)
}
}
func TestSha256Hex(t *testing.T) {
input := "test"
result := Sha256Hex(input)
if len(result) != 64 {
t.Errorf("Sha256Hex() returned %d chars, want 64", len(result))
}
// Same input should produce same hash
result2 := Sha256Hex(input)
if result != result2 {
t.Error("Sha256Hex() not deterministic")
}
// Different input should produce different hash
result3 := Sha256Hex("different")
if result == result3 {
t.Error("Sha256Hex() same hash for different inputs")
}
}
func TestEncryptorInvalidKey(t *testing.T) {
_, err := NewEncryptor([]byte("short"))
if err == nil {
t.Error("NewEncryptor() should error with invalid key length")
}
}
+61
View File
@@ -0,0 +1,61 @@
package jwt
import (
"errors"
"time"
gojwt "github.com/golang-jwt/jwt/v5"
)
type Claims struct {
UserID uint64 `json:"user_id"`
Name string `json:"name"`
Role string `json:"role"`
gojwt.RegisteredClaims
}
// GenerateTokenPair 生成 access + refresh token
func GenerateTokenPair(userID uint64, name, role, secret string, accessExpire, refreshExpire time.Duration) (accessToken, refreshToken string, err error) {
accessToken, err = generateToken(userID, name, role, "access", secret, accessExpire)
if err != nil {
return "", "", err
}
refreshToken, err = generateToken(userID, name, role, "refresh", secret, refreshExpire)
if err != nil {
return "", "", err
}
return
}
func generateToken(userID uint64, name, role, tokenType, secret string, expire time.Duration) (string, error) {
now := time.Now()
claims := Claims{
UserID: userID,
Name: name,
Role: role,
RegisteredClaims: gojwt.RegisteredClaims{
ExpiresAt: gojwt.NewNumericDate(now.Add(expire)),
IssuedAt: gojwt.NewNumericDate(now),
NotBefore: gojwt.NewNumericDate(now),
},
}
token := gojwt.NewWithClaims(gojwt.SigningMethodHS256, claims)
return token.SignedString([]byte(secret))
}
// ValidateToken 校验 JWT
func ValidateToken(tokenString, secret string) (*Claims, error) {
token, err := gojwt.ParseWithClaims(tokenString, &Claims{}, func(token *gojwt.Token) (interface{}, error) {
if _, ok := token.Method.(*gojwt.SigningMethodHMAC); !ok {
return nil, errors.New("unexpected signing method")
}
return []byte(secret), nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil
}
return nil, gojwt.ErrInvalidKey
}
+122
View File
@@ -0,0 +1,122 @@
package ratelimit
import (
"sync"
"time"
)
// Limiter 内存限流器
type Limiter struct {
mu sync.Mutex
// 每用户每秒请求数
userRPS map[uint64]*tokenBucket
// 密钥每日请求计数
keyDailyReq map[uint64]*dailyCounter
// 密钥每日 token 计数
keyDailyTokens map[uint64]*dailyCounter
}
type tokenBucket struct {
tokens float64
maxTokens float64
refillRate float64
lastRefill time.Time
}
type dailyCounter struct {
date string
count int64
}
func New() *Limiter {
return &Limiter{
userRPS: make(map[uint64]*tokenBucket),
keyDailyReq: make(map[uint64]*dailyCounter),
keyDailyTokens: make(map[uint64]*dailyCounter),
}
}
// AllowRequest 检查用户级每秒请求限制
func (l *Limiter) AllowRequest(userID uint64, rps int) bool {
if rps <= 0 {
return true
}
l.mu.Lock()
defer l.mu.Unlock()
bucket, ok := l.userRPS[userID]
if !ok {
bucket = &tokenBucket{
tokens: float64(rps),
maxTokens: float64(rps),
refillRate: float64(rps),
lastRefill: time.Now(),
}
l.userRPS[userID] = bucket
}
now := time.Now()
elapsed := now.Sub(bucket.lastRefill).Seconds()
bucket.tokens += elapsed * bucket.refillRate
if bucket.tokens > bucket.maxTokens {
bucket.tokens = bucket.maxTokens
}
bucket.lastRefill = now
if bucket.tokens < 1 {
return false
}
bucket.tokens--
return true
}
// AllowRequestDaily 检查密钥每日请求配额
func (l *Limiter) AllowRequestDaily(keyID uint64, quota int) bool {
if quota <= 0 {
return true
}
l.mu.Lock()
defer l.mu.Unlock()
today := time.Now().UTC().Format("2006-01-02")
counter, ok := l.keyDailyReq[keyID]
if !ok || counter.date != today {
l.keyDailyReq[keyID] = &dailyCounter{date: today, count: 1}
return true
}
if counter.count >= int64(quota) {
return false
}
counter.count++
return true
}
// TokensUsed 返回密钥今日 token 用量
func (l *Limiter) TokensUsed(keyID uint64) int64 {
l.mu.Lock()
defer l.mu.Unlock()
today := time.Now().UTC().Format("2006-01-02")
counter, ok := l.keyDailyTokens[keyID]
if !ok || counter.date != today {
return 0
}
return counter.count
}
// AddTokens 累加密钥今日 token 用量
func (l *Limiter) AddTokens(keyID uint64, tokens int64) {
l.mu.Lock()
defer l.mu.Unlock()
today := time.Now().UTC().Format("2006-01-02")
counter, ok := l.keyDailyTokens[keyID]
if !ok || counter.date != today {
l.keyDailyTokens[keyID] = &dailyCounter{date: today, count: tokens}
return
}
counter.count += tokens
}
+47
View File
@@ -0,0 +1,47 @@
package resp
import (
"net/http"
"github.com/gin-gonic/gin"
)
// Error 按 OpenAI 格式返回错误
func Error(c *gin.Context, status int, message string) {
c.AbortWithStatusJSON(status, gin.H{
"error": gin.H{
"message": message,
"type": "api_error",
"param": nil,
"code": nil,
},
})
}
// ErrorWithType 按 OpenAI 格式返回带类型的错误
func ErrorWithType(c *gin.Context, status int, errType, code, message string) {
c.AbortWithStatusJSON(status, gin.H{
"error": gin.H{
"message": message,
"type": errType,
"param": nil,
"code": code,
},
})
}
// ErrorAsAnthropic 按 Anthropic 格式返回错误
func ErrorAsAnthropic(c *gin.Context, status int, errType, message string) {
c.AbortWithStatusJSON(status, gin.H{
"type": "error",
"error": gin.H{
"type": errType,
"message": message,
},
})
}
// OK 返回成功 JSON
func OK(c *gin.Context, data interface{}) {
c.JSON(http.StatusOK, data)
}
+86
View File
@@ -0,0 +1,86 @@
package tokenizer
import (
"fmt"
"strings"
"github.com/pkoukk/tiktoken-go"
)
// Count 计算字符串的 token 数量
func Count(text, model string) int {
tkm, err := tiktoken.EncodingForModel(model)
if err != nil {
tkm, _ = tiktoken.GetEncoding("cl100k_base")
}
return len(tkm.Encode(text, nil, nil))
}
// Cost 计算模型调用成本(USD,按每百万 token 定价)
func Cost(model string, inputTokens, outputTokens int) float64 {
var inputPrice, outputPrice float64
switch {
case strings.Contains(model, "gpt-4o-mini"):
inputPrice = 0.15
outputPrice = 0.60
case strings.Contains(model, "gpt-4o"):
inputPrice = 2.50
outputPrice = 10.00
case strings.Contains(model, "gpt-4-turbo"):
inputPrice = 10.00
outputPrice = 30.00
case strings.Contains(model, "gpt-4"):
inputPrice = 30.00
outputPrice = 60.00
case strings.Contains(model, "gpt-3.5-turbo"):
inputPrice = 0.50
outputPrice = 1.50
case strings.Contains(model, "claude-3-5-sonnet"):
inputPrice = 3.00
outputPrice = 15.00
case strings.Contains(model, "claude-3-opus"):
inputPrice = 15.00
outputPrice = 75.00
case strings.Contains(model, "claude-3-haiku"):
inputPrice = 0.25
outputPrice = 1.25
case strings.Contains(model, "claude"):
inputPrice = 8.00
outputPrice = 24.00
case strings.Contains(model, "gemini-1.5-pro"):
inputPrice = 3.50
outputPrice = 10.50
case strings.Contains(model, "gemini-1.5-flash"):
inputPrice = 0.35
outputPrice = 0.53
case strings.Contains(model, "gemini"):
inputPrice = 0.50
outputPrice = 1.50
default:
inputPrice = 0.15
outputPrice = 0.60
}
cost := float64(inputTokens)/1e6*inputPrice + float64(outputTokens)/1e6*outputPrice
if cost < 0.000001 {
cost = 0.000001
}
return cost
}
// CostWithModel 从数据库模型记录获取定价
func CostWithModel(inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens int64, inputPrice, outputPrice, cacheReadPrice float64) float64 {
cost := float64(inputTokens)/1e6*inputPrice +
float64(outputTokens)/1e6*outputPrice +
float64(cacheReadTokens)/1e6*cacheReadPrice +
float64(cacheCreationTokens)/1e6*inputPrice*1.25
if cost < 0.000001 {
cost = 0.000001
}
return cost
}
func init() {
_ = fmt.Sprintf // ensure fmt is used
}