Files
openteam/server/internal/api/keys.go
T
Sakurasan e865f69a41 fix: 修掉模型调用链路上的四处失效逻辑
- 超时:NewGateway 里 gw_timeout*time.Second 把已是 Duration 的值二次换算,
  int64 溢出后无论 OT_PROXY_TIMEOUT 填多少都等于没有超时,渠道 timeout_ms
  也从没被读过。现在非流式用 context deadline 约束整段时长,流式只约束首包
  (Transport.ResponseHeaderTimeout),正文不限时长,长回答不会被砍。
- 断连:上游请求改用 NewRequestWithContext 绑定客户端 context,客户端断开即
  取消上游并记 canceled,不再空转生成继续付费;PATCH 已写 200 头后不再往
  响应里追加错误体。
- 白名单:APIKey.AllowedModels 以前只存不用,现在 Auth 注入 context 并参与
  判定(用户黑名单 > 密钥白名单 > 用户白名单 > 全局)。
- 选路:模型已有渠道绑定时只用绑定渠道,都不可用直接 503 no_channel,不再
  把请求发给不认识它的渠道;新增运行期健康(连续 3 次网络错误/超时/429/5xx
  进 30s 冷却,成功清零),与 60s 一次的 /models 探测互补。
- 密钥 API:PATCH 回显更新后的完整对象;限额填 0 统一视为不限制(原来
  token 配额存 0 会让密钥第一次请求就被 429 打死,而请求配额存 0 又等于
  不限制,两个字段语义相反);前端编辑框总是提交白名单,清空能真的取消。

测试:新增 proxy 链路集成测试(超时生效、流式不被截断、断连取消并记账、
密钥白名单 403、连续 429 后候选剔除)与 channel 用例(绑定模型不退全局、
冷却阈值与到期恢复、成功清零)。
2026-10-16 19:15:51 +08:00

215 lines
6.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package api
import (
"encoding/json"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/openteam/server/internal/pkg/apikey"
"github.com/openteam/server/internal/pkg/resp"
"github.com/openteam/server/internal/store"
)
type createKeyReq struct {
Name *string `json:"name" binding:"required,min=1,max=64"`
QuotaTokensPerDay *int64 `json:"quota_tokens_per_day"`
QuotaRequestsPerDay *int `json:"quota_requests_per_day"`
AllowedModels []string `json:"allowed_models"`
ExpiresAt *string `json:"expires_at"` // RFC3339
}
// CreateKey POST /api/v1/keys — 创建密钥,明文仅此一次返回。
func (h *Handler) CreateKey(c *gin.Context) {
u, ok := userFromContext(c)
if !ok {
resp.Fail(c, http.StatusUnauthorized, "authentication required")
return
}
var req createKeyReq
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid input: "+err.Error())
return
}
plain, hash, prefix, err := apikey.Generate()
if err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to generate key")
return
}
k := store.APIKey{
UserID: u.ID,
Name: *req.Name,
KeyHash: hash,
KeyPrefix: prefix,
QuotaTokensPerDay: req.QuotaTokensPerDay,
QuotaRequestsPerDay: req.QuotaRequestsPerDay,
AllowedModels: req.AllowedModels,
Status: store.KeyStatusActive,
}
// 限额填 0 视为不限制(同 PatchKey),存成 0 会让密钥一发都发不出去
if k.QuotaTokensPerDay != nil && *k.QuotaTokensPerDay <= 0 {
k.QuotaTokensPerDay = nil
}
if k.QuotaRequestsPerDay != nil && *k.QuotaRequestsPerDay <= 0 {
k.QuotaRequestsPerDay = nil
}
if req.ExpiresAt != nil {
t, err := time.Parse(time.RFC3339, *req.ExpiresAt)
if err != nil {
resp.Fail(c, http.StatusBadRequest, "expires_at must be RFC3339")
return
}
k.ExpiresAt = &t
}
if err := h.a.DB.Create(&k).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to create key")
return
}
resp.Created(c, gin.H{
"id": k.ID,
"name": k.Name,
"key": plain, // 仅此一次
"key_prefix": k.KeyPrefix,
"created_at": k.CreatedAt,
})
}
// keyJSON 密钥的对外表示(列表与 PATCH 回显共用)。
func keyJSON(k store.APIKey) gin.H {
return gin.H{
"id": k.ID,
"name": k.Name,
"key_prefix": k.KeyPrefix,
"quota_tokens_per_day": k.QuotaTokensPerDay,
"quota_requests_per_day": k.QuotaRequestsPerDay,
"allowed_models": k.AllowedModels,
"expires_at": k.ExpiresAt,
"status": k.Status,
"last_used_at": k.LastUsedAt,
"created_at": k.CreatedAt,
}
}
// ListKeys GET /api/v1/keys
func (h *Handler) ListKeys(c *gin.Context) {
u, ok := userFromContext(c)
if !ok {
resp.Fail(c, http.StatusUnauthorized, "authentication required")
return
}
var keys []store.APIKey
if err := h.a.DB.Where("user_id = ?", u.ID).Order("id DESC").Find(&keys).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to load keys")
return
}
out := make([]gin.H, 0, len(keys))
for _, k := range keys {
out = append(out, keyJSON(k))
}
resp.OK(c, gin.H{"items": out})
}
// PatchKey PATCH /api/v1/keys/:id — 改名、限额、白名单、启停。
func (h *Handler) PatchKey(c *gin.Context) {
u, ok := userFromContext(c)
if !ok {
resp.Fail(c, http.StatusUnauthorized, "authentication required")
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid key id")
return
}
var req struct {
Name *string `json:"name"`
QuotaTokensPerDay *int64 `json:"quota_tokens_per_day"`
QuotaRequestsPerDay *int `json:"quota_requests_per_day"`
AllowedModels *[]string `json:"allowed_models"`
Status *string `json:"status"`
}
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid input")
return
}
var k store.APIKey
if err := h.a.DB.Where("id = ? AND user_id = ?", id, u.ID).First(&k).Error; err != nil {
resp.Fail(c, http.StatusNotFound, "key not found")
return
}
updates := map[string]any{}
if req.Name != nil {
updates["name"] = *req.Name
}
// 限额:>0 生效,0 或负数视为"不限制"(写 NULL)。
// 不能存 0:token 配额判定是 `已用 >= 上限`,0 会让密钥第一次请求就被 429 打死。
if req.QuotaTokensPerDay != nil {
if *req.QuotaTokensPerDay > 0 {
updates["quota_tokens_per_day"] = *req.QuotaTokensPerDay
} else {
updates["quota_tokens_per_day"] = nil
}
}
if req.QuotaRequestsPerDay != nil {
if *req.QuotaRequestsPerDay > 0 {
updates["quota_requests_per_day"] = *req.QuotaRequestsPerDay
} else {
updates["quota_requests_per_day"] = nil
}
}
if req.Status != nil {
if *req.Status != store.KeyStatusActive && *req.Status != store.KeyStatusRevoked {
resp.Fail(c, http.StatusBadRequest, "status must be active or revoked")
return
}
updates["status"] = *req.Status
}
if len(updates) > 0 {
if err := h.a.DB.Model(&k).Updates(updates).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to update key")
return
}
}
// allowed_models 是 jsonb:手动序列化为 JSON 字符串写入(跨 SQLite/Postgres)
// 显式传 [] 表示"清空白名单"(前端编辑框预填现值,字段内容即最终意图)
if req.AllowedModels != nil {
raw, _ := json.Marshal(*req.AllowedModels)
if err := h.a.DB.Model(&k).Update("allowed_models", string(raw)).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to update key")
return
}
}
// 重新读出,回显更新后的完整密钥(含被清零的限额)
var updated store.APIKey
if err := h.a.DB.First(&updated, k.ID).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to reload key")
return
}
resp.OK(c, keyJSON(updated))
}
// DeleteKey DELETE /api/v1/keys/:id — 删除(硬删,立即失效不可恢复)。
func (h *Handler) DeleteKey(c *gin.Context) {
u, ok := userFromContext(c)
if !ok {
resp.Fail(c, http.StatusUnauthorized, "authentication required")
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid key id")
return
}
res := h.a.DB.Where("id = ? AND user_id = ?", id, u.ID).Delete(&store.APIKey{})
if res.Error != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to delete key")
return
}
if res.RowsAffected == 0 {
resp.Fail(c, http.StatusNotFound, "key not found")
return
}
resp.OK(c, gin.H{"ok": true})
}