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
-11
View File
@@ -1,11 +0,0 @@
package error
import "github.com/gin-gonic/gin"
func ErrorData(message string) gin.H {
return gin.H{
"error": gin.H{
"message": message,
},
}
}
-72
View File
@@ -1,72 +0,0 @@
/*
文档 https://www.microsoft.com/en-us/bing/apis/bing-web-search-api
价格 https://www.microsoft.com/en-us/bing/apis/pricing
curl -H "Ocp-Apim-Subscription-Key: <yourkeygoeshere>" https://api.bing.microsoft.com/v7.0/search?q=今天上海天气怎么样
curl -H "Ocp-Apim-Subscription-Key: 6fc7c97ebed54f75a5e383ee2272c917" https://api.bing.microsoft.com/v7.0/search?q=今天上海天气怎么样
*/
package search
import (
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"github.com/tidwall/gjson"
)
const (
bingEndpoint = "https://api.bing.microsoft.com/v7.0/search"
)
var subscriptionKey string
func init() {
if os.Getenv("bing") != "" {
subscriptionKey = os.Getenv("bing")
} else {
log.Println("bing key not found")
}
}
func BingSearch(searchParams SearchParams) (any, error) {
params := url.Values{}
params.Set("q", searchParams.Query)
params.Set("count", "5")
if searchParams.Num > 0 {
params.Set("count", fmt.Sprintf("%d", searchParams.Num))
}
reqURL, _ := url.Parse(bingEndpoint)
reqURL.RawQuery = params.Encode()
req, _ := http.NewRequest("GET", reqURL.String(), nil)
req.Header.Set("Ocp-Apim-Subscription-Key", subscriptionKey)
client := &http.Client{}
resp, err := client.Do(req)
defer resp.Body.Close()
if err != nil {
fmt.Println("Error sending request:", err)
return nil, err
}
body, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Println("Error reading response:", err)
return nil, err
}
result := gjson.ParseBytes(body).Get("webPages.value")
return result.Raw, nil
}
type SearchParams struct {
Query string `form:"q"`
Num int `form:"num,default=5"`
}
-30
View File
@@ -1,30 +0,0 @@
/*
文档 https://www.microsoft.com/en-us/bing/apis/bing-web-search-api
价格 https://www.microsoft.com/en-us/bing/apis/pricing
curl -H "Ocp-Apim-Subscription-Key: <yourkeygoeshere>" https://api.bing.microsoft.com/v7.0/search?q=今天上海天气怎么样
curl -H "Ocp-Apim-Subscription-Key: 6fc7c97ebed54f75a5e383ee2272c917" https://api.bing.microsoft.com/v7.0/search?q=今天上海天气怎么样
*/
package search
import (
"testing"
)
func TestBingSearch(t *testing.T) {
var searchParams = SearchParams{
Query: "上海明天天气怎么样",
Num: 3,
}
t.Run("BingSearch", func(t *testing.T) {
got, err := BingSearch(searchParams)
if err != nil {
t.Errorf("BingSearch() error = %v", err)
return
}
t.Log(got)
})
}
-114
View File
@@ -1,114 +0,0 @@
package store
import (
"fmt"
"log"
"opencatd-open/internal/model"
"opencatd-open/pkg/config"
"os"
"strings"
// "gocloud.dev/mysql"
// "gocloud.dev/postgres"
"github.com/glebarez/sqlite"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
// "gorm.io/driver/sqlite"
"gorm.io/gorm"
)
var DB *gorm.DB
// var DBType consts.DBType
var IsPostgres bool
func GetDB() *gorm.DB {
return DB
}
// InitDB 初始化数据库连接
func InitDB(cfg *config.Config) (*gorm.DB, error) {
var db *gorm.DB
var err error
// 从环境变量获取DSN
dsn := cfg.DSN
if dsn == "" {
log.Println("No DSN provided, using SQLite as default")
db, err = initSQLite()
}
// 解析DSN来确定数据库类型
if strings.HasPrefix(dsn, "postgres://") {
IsPostgres = true
cfg.DB_Type = "postgres"
db, err = initPostgres(dsn)
} else if strings.HasPrefix(dsn, "mysql://") {
cfg.DB_Type = "mysql"
db, err = initMySQL(dsn)
} else {
if dsn != "" {
return nil, fmt.Errorf("unsupported database type in DSN: %s", dsn)
}
}
if err != nil {
return nil, err
}
DB = db
if IsPostgres {
err = db.AutoMigrate(&model.User{}, &model.Token{}, &model.ApiKey_PG{}, &model.Usage{}, &model.DailyUsage{}, &model.Passkey{})
if err != nil {
return nil, err
}
} else {
err = db.AutoMigrate(&model.User{}, &model.Token{}, &model.ApiKey{}, &model.Usage{}, &model.DailyUsage{}, &model.Passkey{})
if err != nil {
return nil, err
}
}
return db, nil
}
// initSQLite 初始化 SQLite 数据库
func initSQLite() (*gorm.DB, error) {
if _, err := os.Stat("db"); os.IsNotExist(err) {
errDir := os.MkdirAll("db", 0755)
if errDir != nil {
log.Fatalln("Error creating directory:", err)
}
}
db, err := gorm.Open(sqlite.Open("./db/openteam.db"), &gorm.Config{})
if err != nil {
return nil, fmt.Errorf("failed to connect to SQLite: %v", err)
}
// db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
return db, nil
}
// initPostgres 初始化 PostgreSQL 数据库
func initPostgres(dsn string) (*gorm.DB, error) {
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
return nil, fmt.Errorf("failed to connect to PostgreSQL: %v", err)
}
return db, nil
}
// initMySQL 初始化 MySQL 数据库
func initMySQL(dsn string) (*gorm.DB, error) {
// 移除 "mysql://" 前缀,因为 MySQL 驱动不需要这个前缀
dsn = strings.TrimPrefix(dsn, "mysql://")
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
return nil, fmt.Errorf("failed to connect to MySQL: %v", err)
}
return db, nil
}
-67
View File
@@ -1,67 +0,0 @@
package store
import (
"errors"
"log"
"time"
"github.com/bluele/gcache"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/google/uuid"
)
// WebAuthnSessionStore 使用 gcache 存储 WebAuthn 会话数据
type WebAuthnSessionStore struct {
cache gcache.Cache
}
// NewWebAuthnSessionStore 创建一个新的会话存储实例
func NewWebAuthnSessionStore() *WebAuthnSessionStore {
// 创建一个 LRU 缓存,最多存储 10000 个会话,每个会话有效期 5 分钟
gc := gcache.New(10000).
LRU().
Expiration(5 * time.Minute).
Build()
return &WebAuthnSessionStore{cache: gc}
}
// GenerateSessionID 生成唯一的会话ID
func GenerateSessionID() string {
return uuid.NewString()
}
// SaveWebauthnSession 保存 WebAuthn 会话数据
func (s *WebAuthnSessionStore) SaveWebauthnSession(sessionID string, data *webauthn.SessionData) error {
return s.cache.Set(sessionID, data)
}
// GetWebauthnSession 获取 WebAuthn 会话数据
func (s *WebAuthnSessionStore) GetWebauthnSession(sessionID string) (*webauthn.SessionData, error) {
val, err := s.cache.Get(sessionID)
if err != nil {
if errors.Is(err, gcache.KeyNotFoundError) {
return nil, errors.New("会话未找到或已过期")
}
return nil, err // 其他 gcache 错误
}
sessionData, ok := val.(*webauthn.SessionData)
if !ok {
// 如果类型断言失败,说明缓存中存储了错误类型的数据
log.Printf("警告:会话存储中发现非预期的类型,Key: %s", sessionID)
// 尝试删除无效数据
_ = s.cache.Remove(sessionID)
return nil, errors.New("无效的会话数据类型")
}
return sessionData, nil
}
// DeleteWebauthnSession 删除 WebAuthn 会话数据
func (s *WebAuthnSessionStore) DeleteWebauthnSession(sessionID string) {
s.cache.Remove(sessionID)
}
func (s *WebAuthnSessionStore) GetALL() map[any]any {
return s.cache.GetALL(false)
}
-182
View File
@@ -1,182 +0,0 @@
package team
import (
"net/http"
"opencatd-open/llm/azureopenai"
"opencatd-open/store"
"strings"
"github.com/Sakurasan/to"
"github.com/gin-gonic/gin"
)
type Key struct {
ID int `json:"id,omitempty"`
Key string `json:"key,omitempty"`
Name string `json:"name,omitempty"`
ApiType string `json:"api_type,omitempty"`
Endpoint string `json:"endpoint,omitempty"`
UpdatedAt string `json:"updatedAt,omitempty"`
CreatedAt string `json:"createdAt,omitempty"`
}
func HandleKeys(c *gin.Context) {
keys, err := store.GetAllKeys()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"error": err.Error(),
})
}
c.JSON(http.StatusOK, keys)
}
func HandleAddKey(c *gin.Context) {
var body Key
if err := c.BindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{
"message": err.Error(),
}})
return
}
body.Name = strings.ToLower(strings.TrimSpace(body.Name))
body.Key = strings.TrimSpace(body.Key)
if strings.HasPrefix(body.Name, "azure.") {
keynames := strings.Split(body.Name, ".")
if len(keynames) < 2 {
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{
"message": "Invalid Key Name",
}})
return
}
k := &store.Key{
ApiType: "azure",
Name: body.Name,
Key: body.Key,
ResourceNmae: keynames[1],
EndPoint: body.Endpoint,
}
if err := store.CreateKey(k); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": gin.H{
"message": err.Error(),
}})
return
}
} else if strings.HasPrefix(body.Name, "claude.") {
keynames := strings.Split(body.Name, ".")
if len(keynames) < 2 {
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{
"message": "Invalid Key Name",
}})
return
}
if body.Endpoint == "" {
body.Endpoint = "https://api.anthropic.com"
}
k := &store.Key{
// ApiType: "anthropic",
ApiType: "claude",
Name: body.Name,
Key: body.Key,
ResourceNmae: keynames[1],
EndPoint: body.Endpoint,
}
if err := store.CreateKey(k); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": gin.H{
"message": err.Error(),
}})
return
}
} else if strings.HasPrefix(body.Name, "google.") {
keynames := strings.Split(body.Name, ".")
if len(keynames) < 2 {
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{
"message": "Invalid Key Name",
}})
return
}
k := &store.Key{
// ApiType: "anthropic",
ApiType: "google",
Name: body.Name,
Key: body.Key,
ResourceNmae: keynames[1],
EndPoint: body.Endpoint,
}
if err := store.CreateKey(k); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": gin.H{
"message": err.Error(),
}})
return
}
} else if strings.HasPrefix(body.Name, "github.") {
keynames := strings.Split(body.Name, ".")
if len(keynames) < 2 {
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{
"message": "Invalid Key Name",
}})
return
}
k := &store.Key{
ApiType: "github",
Name: body.Name,
Key: body.Key,
ResourceNmae: keynames[1],
EndPoint: body.Endpoint,
}
if err := store.CreateKey(k); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": gin.H{
"message": err.Error(),
}})
return
}
} else {
if body.ApiType == "" {
if err := store.AddKey("openai", body.Key, body.Name); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": gin.H{
"message": err.Error(),
}})
return
}
} else {
k := &store.Key{
ApiType: body.ApiType,
Name: body.Name,
Key: body.Key,
ResourceNmae: azureopenai.GetResourceName(body.Endpoint),
EndPoint: body.Endpoint,
}
if err := store.CreateKey(k); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": gin.H{
"message": err.Error(),
}})
return
}
}
}
k, err := store.GetKeyrByName(body.Name)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": gin.H{
"message": err.Error(),
}})
return
}
c.JSON(http.StatusOK, k)
}
func HandleDelKey(c *gin.Context) {
id := to.Int(c.Param("id"))
if id < 1 {
c.JSON(http.StatusOK, gin.H{"error": "invalid key id"})
return
}
if err := store.DeleteKey(uint(id)); err != nil {
c.JSON(http.StatusOK, gin.H{"error": "invalid key id"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "ok"})
}
-104
View File
@@ -1,104 +0,0 @@
package team
import (
"errors"
"net/http"
"opencatd-open/store"
"time"
"github.com/Sakurasan/to"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
)
func Handleinit(c *gin.Context) {
user, err := store.GetUserByID(1)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
u := store.User{Name: "root", Token: uuid.NewString()}
u.ID = 1
if err := store.CreateUser(&u); err != nil {
c.JSON(http.StatusForbidden, gin.H{
"error": err.Error(),
})
return
} else {
rootToken = u.Token
resJSON := User{
false,
int(u.ID),
u.UpdatedAt.Format(time.RFC3339),
u.Name,
u.Token,
u.CreatedAt.Format(time.RFC3339),
}
c.JSON(http.StatusOK, resJSON)
return
}
}
c.JSON(http.StatusOK, gin.H{
"error": err.Error(),
})
return
}
if user.ID == 1 {
c.JSON(http.StatusForbidden, gin.H{
"error": "super user already exists, use cli to reset password",
})
}
}
func HandleMe(c *gin.Context) {
token := c.GetHeader("Authorization")
u, err := store.GetUserByToken(token[7:])
if err != nil {
c.JSON(http.StatusOK, gin.H{
"error": err.Error(),
})
}
resJSON := User{
false,
int(u.ID),
u.UpdatedAt.Format(time.RFC3339),
u.Name,
u.Token,
u.CreatedAt.Format(time.RFC3339),
}
c.JSON(http.StatusOK, resJSON)
}
func HandleMeUsage(c *gin.Context) {
token := c.GetHeader("Authorization")
fromStr := c.Query("from")
toStr := c.Query("to")
getMonthStartAndEnd := func() (start, end string) {
loc, _ := time.LoadLocation("Local")
now := time.Now().In(loc)
year, month, _ := now.Date()
startOfMonth := time.Date(year, month, 1, 0, 0, 0, 0, loc)
endOfMonth := startOfMonth.AddDate(0, 1, 0)
start = startOfMonth.Format("2006-01-02")
end = endOfMonth.Format("2006-01-02")
return
}
if fromStr == "" || toStr == "" {
fromStr, toStr = getMonthStartAndEnd()
}
user, err := store.GetUserByToken(token)
if err != nil {
c.AbortWithError(http.StatusForbidden, err)
return
}
usage, err := store.QueryUserUsage(to.String(user.ID), fromStr, toStr)
if err != nil {
c.AbortWithError(http.StatusForbidden, err)
return
}
c.JSON(200, usage)
}
-69
View File
@@ -1,69 +0,0 @@
package team
import (
"log"
"net/http"
"opencatd-open/store"
"strings"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
)
var (
rootToken string
)
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
if rootToken == "" {
u, err := store.GetUserByID(uint(1))
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
c.Abort()
return
}
rootToken = u.Token
}
token := c.GetHeader("Authorization")
if token == "" || token[:7] != "Bearer " {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
c.Abort()
return
}
if store.IsExistAuthCache(token[7:]) {
if strings.HasPrefix(c.Request.URL.Path, "/1/me") {
c.Next()
return
}
}
if token[7:] != rootToken {
u, err := store.GetUserByID(uint(1))
if err != nil {
log.Println(err)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
c.Abort()
return
}
if token[:7] != u.Token {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
c.Abort()
return
}
rootToken = u.Token
store.LoadAuthCache()
}
// 可以在这里对 token 进行验证并检查权限
c.Next()
}
}
func CORS() gin.HandlerFunc {
config := cors.DefaultConfig()
config.AllowAllOrigins = true
config.AllowCredentials = true
config.AllowMethods = []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}
config.AllowHeaders = []string{"*"}
return cors.New(config)
}
-38
View File
@@ -1,38 +0,0 @@
package team
import (
"net/http"
"opencatd-open/store"
"time"
"github.com/gin-gonic/gin"
)
func HandleUsage(c *gin.Context) {
fromStr := c.Query("from")
toStr := c.Query("to")
getMonthStartAndEnd := func() (start, end string) {
loc, _ := time.LoadLocation("Local")
now := time.Now().In(loc)
year, month, _ := now.Date()
startOfMonth := time.Date(year, month, 1, 0, 0, 0, 0, loc)
endOfMonth := startOfMonth.AddDate(0, 1, 0)
start = startOfMonth.Format("2006-01-02")
end = endOfMonth.Format("2006-01-02")
return
}
if fromStr == "" || toStr == "" {
fromStr, toStr = getMonthStartAndEnd()
}
usage, err := store.QueryUsage(fromStr, toStr)
if err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
c.JSON(200, usage)
}
-89
View File
@@ -1,89 +0,0 @@
package team
import (
"net/http"
"opencatd-open/store"
"github.com/Sakurasan/to"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type User struct {
IsDelete bool `json:"IsDelete,omitempty"`
ID int `json:"id,omitempty"`
UpdatedAt string `json:"updatedAt,omitempty"`
Name string `json:"name,omitempty"`
Token string `json:"token,omitempty"`
CreatedAt string `json:"createdAt,omitempty"`
}
func HandleUsers(c *gin.Context) {
users, err := store.GetAllUsers()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"error": err.Error(),
})
}
c.JSON(http.StatusOK, users)
}
func HandleAddUser(c *gin.Context) {
var body User
if err := c.BindJSON(&body); err != nil {
c.JSON(http.StatusOK, gin.H{"error": err.Error()})
return
}
if len(body.Name) == 0 {
c.JSON(http.StatusOK, gin.H{"error": "invalid user name"})
return
}
if err := store.AddUser(body.Name, uuid.NewString()); err != nil {
c.JSON(http.StatusOK, gin.H{"error": err.Error()})
return
}
u, err := store.GetUserByName(body.Name)
if err != nil {
c.JSON(http.StatusOK, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, u)
}
func HandleDelUser(c *gin.Context) {
id := to.Int(c.Param("id"))
if id <= 1 {
c.JSON(http.StatusOK, gin.H{"error": "invalid user id"})
return
}
if err := store.DeleteUser(uint(id)); err != nil {
c.JSON(http.StatusOK, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "ok"})
}
func HandleResetUserToken(c *gin.Context) {
id := to.Int(c.Param("id"))
newtoken := c.Query("token")
if newtoken == "" {
newtoken = uuid.NewString()
}
if err := store.UpdateUser(uint(id), newtoken); err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
u, err := store.GetUserByID(uint(id))
if err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
if u.ID == 1 {
rootToken = u.Token
}
c.JSON(http.StatusOK, u)
}