Files
openteam/server/internal/api/keys.go
T
SakurasanandClaude eb57a09c5d API 密钥: 支持编辑与高级选项, 修复 jsonb 白名单更新
- 前端密钥页新增"编辑": 改名/每日配额/模型白名单/状态切换
- 新建密钥展开"高级选项": 每日 Token/请求上限、模型白名单
- 修复 PATCH 更新 allowed_models(jsonb)不走序列化导致失败,
  改为 JSON 字符串写入, 跨 SQLite/Postgres 可靠

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-15 20:55:26 +08:00

188 lines
5.5 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,
}
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,
})
}
// 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, 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,
})
}
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
}
if req.QuotaTokensPerDay != nil {
updates["quota_tokens_per_day"] = *req.QuotaTokensPerDay
}
if req.QuotaRequestsPerDay != nil {
updates["quota_requests_per_day"] = *req.QuotaRequestsPerDay
}
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
}
}
resp.OK(c, gin.H{"ok": true})
}
// 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.Model(&store.APIKey{}).
Where("id = ? AND user_id = ?", id, u.ID).
Update("status", store.KeyStatusRevoked)
if res.Error != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to revoke key")
return
}
if res.RowsAffected == 0 {
resp.Fail(c, http.StatusNotFound, "key not found")
return
}
resp.OK(c, gin.H{"ok": true})
}