M0+M1: 基建 + 用户/密钥/核心代理
后端 (Go/Gin/GORM): - 配置(viper+env)、SQLite/Postgres 迁移、argon2id、AES-GCM 渠道密钥、JWT+refresh cookie - 用户注册/登录/刷新/登出、API Key CRUD(仅存哈希、明文一次展示) - 代理网关: /v1/chat/completions、/v1/responses、/v1/models 直通 OpenAI 渠道 非流式+流式(SSE 零缓冲转发), 用量捕获(chat 末块/responses completed 嵌套), OpenAI 错误格式(401/402/404/502), 余额检查 - 异步批量记账 + 余额流水 + 日聚合, admin 用户/余额/配置 API - 单测: crypto/jwt/apikey/流式 usage 提取 前端 (Vue3+TS+Vite+Tailwind v4): - taste-skill 设计 tokens: 深色仪表盘, 石墨+信号铜色, Outfit+JetBrains Mono - Landing/登录/注册, 控制台(仪表盘图表/密钥管理/用量明细) - 基础组件 Button/Input/Badge/Modal, ECharts 用量图 部署: docker-compose(nginx+api+postgres), 双 Dockerfile, nginx SSE 反代 联调: scripts/mockupstream 本地 mock 上游, 端到端验证通过
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
// Package proxy API 网关核心:代理 /v1/chat/completions、/v1/responses、/v1/models。
|
||||
// M1:对 OpenAI 渠道直通(passthrough),不转格式;M3 起加入协议转换。
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/openteam/server/internal/channel"
|
||||
"github.com/openteam/server/internal/pkg/apikey"
|
||||
"github.com/openteam/server/internal/pkg/crypto"
|
||||
"github.com/openteam/server/internal/store"
|
||||
"github.com/openteam/server/internal/usage"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
CtxUserID = "proxy_user_id"
|
||||
CtxKeyID = "proxy_key_id"
|
||||
CtxTrace = "proxy_trace_id"
|
||||
)
|
||||
|
||||
type Gateway struct {
|
||||
db *gorm.DB
|
||||
ch *channel.Service
|
||||
rec *usage.Recorder
|
||||
enc *crypto.Encryptor
|
||||
hc *http.Client
|
||||
}
|
||||
|
||||
func NewGateway(db *gorm.DB, enc *crypto.Encryptor, rec *usage.Recorder) *Gateway {
|
||||
return &Gateway{
|
||||
db: db,
|
||||
ch: channel.NewService(db, enc),
|
||||
rec: rec,
|
||||
enc: enc,
|
||||
hc: &http.Client{Timeout: 120 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Auth 代理鉴权中间件:Bearer sk-xxx → 哈希查表 → 校验状态/过期/模型白名单。
|
||||
func (g *Gateway) Auth(c *gin.Context) {
|
||||
auth := c.GetHeader("Authorization")
|
||||
key := strings.TrimPrefix(auth, "Bearer ")
|
||||
key = strings.TrimSpace(key)
|
||||
if !apikey.Valid(key) {
|
||||
openAIError(c, http.StatusUnauthorized, "invalid_api_key", "Invalid API key format. Expected: Bearer sk-...")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
hash := apikey.Hash(key)
|
||||
var k store.APIKey
|
||||
if err := g.db.Where("key_hash = ? AND status = ?", hash, store.KeyStatusActive).First(&k).Error; err != nil {
|
||||
openAIError(c, http.StatusUnauthorized, "invalid_api_key", "Invalid API key")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
var u store.User
|
||||
if err := g.db.First(&u, k.UserID).Error; err != nil || u.Status != store.UserStatusActive {
|
||||
openAIError(c, http.StatusForbidden, "user_disabled", "User account is disabled")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if k.ExpiresAt != nil && time.Now().After(*k.ExpiresAt) {
|
||||
openAIError(c, http.StatusUnauthorized, "key_expired", "API key has expired")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set(CtxUserID, u.ID)
|
||||
c.Set(CtxKeyID, k.ID)
|
||||
c.Set(CtxTrace, newTraceID())
|
||||
g.db.Model(&store.APIKey{}).Where("id = ?", k.ID).Update("last_used_at", time.Now())
|
||||
c.Next()
|
||||
}
|
||||
|
||||
// Handle 路由到对应协议处理器。
|
||||
func (g *Gateway) Handle(c *gin.Context) {
|
||||
switch {
|
||||
case c.Request.URL.Path == "/v1/chat/completions":
|
||||
g.chatCompletions(c)
|
||||
case c.Request.URL.Path == "/v1/responses":
|
||||
g.responses(c)
|
||||
case c.Request.URL.Path == "/v1/models" && c.Request.Method == http.MethodGet:
|
||||
g.models(c)
|
||||
default:
|
||||
openAIError(c, http.StatusNotFound, "not_found", "Unknown endpoint: "+c.Request.URL.Path)
|
||||
}
|
||||
}
|
||||
|
||||
// models GET /v1/models:返回启用的全局模型(OpenAI 风格)。
|
||||
func (g *Gateway) models(c *gin.Context) {
|
||||
var ms []store.Model
|
||||
if err := g.db.Where("enabled = ?", true).Order("sort ASC, id ASC").Find(&ms).Error; err != nil {
|
||||
openAIError(c, http.StatusInternalServerError, "internal_error", "failed to load models")
|
||||
return
|
||||
}
|
||||
data := make([]gin.H, 0, len(ms))
|
||||
for _, m := range ms {
|
||||
data = append(data, gin.H{
|
||||
"id": m.Name,
|
||||
"object": "model",
|
||||
"created": m.CreatedAt.Unix(),
|
||||
"owned_by": "openteam",
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"object": "list", "data": data})
|
||||
}
|
||||
|
||||
// resolveUser 取当前用户(含余额)。
|
||||
func (g *Gateway) resolveUser(c *gin.Context) (*store.User, bool) {
|
||||
uid, _ := c.Get(CtxUserID)
|
||||
var u store.User
|
||||
if err := g.db.First(&u, uid).Error; err != nil {
|
||||
openAIError(c, http.StatusUnauthorized, "invalid_api_key", "Invalid API key")
|
||||
return nil, false
|
||||
}
|
||||
return &u, true
|
||||
}
|
||||
|
||||
// checkBalance 余额不足返回 402(PLANNING §4.4.3)。
|
||||
func (g *Gateway) checkBalance(c *gin.Context, u *store.User) bool {
|
||||
if u.Balance <= 0 {
|
||||
openAIError(c, http.StatusPaymentRequired, "insufficient_balance", "Insufficient balance. Please recharge or contact admin.")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
var errNoChannel = errors.New("no available channel")
|
||||
Reference in New Issue
Block a user