让 /admin/ops 错误详情正确归因 API key 并补全早退场景字段,合并三项改动: - 鉴权早退补全用户/分组/平台字段:引入 ops fallback key(ContextKeyOpsFallbackAPIKey), apiKey 一加载成功即写入,覆盖分组停用/删除、Key 停用/过期/额度、用户停用、IP 限制等早退 路径;ops 错误日志改用 getOpsAPIKey(正式 key 优先、回退键兜底),不改「已鉴权」语义。 - 已删除 key 归因(迁移 145):删除 key 时同一事务写 deleted_api_key_audits 映射,认证失败 时用明文反查命中原所有者,错误详情展示「已删除 Key 所有者」「尝试的 Key 前缀」。 - 有效 key 报错快照前缀(迁移 147):对绑定有效 key 的错误,落库时快照明文前 8 位到 api_key_prefix(与 attempted_key_prefix 互斥),key 之后被删仍保留报错当时真实前缀。 均仅对上线后新产生的错误/删除生效。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
179 lines
5.4 KiB
Go
179 lines
5.4 KiB
Go
package middleware
|
||
|
||
import (
|
||
"errors"
|
||
"strings"
|
||
|
||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||
"github.com/Wei-Shaw/sub2api/internal/pkg/googleapi"
|
||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
// APIKeyAuthGoogle is a Google-style error wrapper for API key auth.
|
||
func APIKeyAuthGoogle(apiKeyService *service.APIKeyService, cfg *config.Config) gin.HandlerFunc {
|
||
return APIKeyAuthWithSubscriptionGoogle(apiKeyService, nil, cfg)
|
||
}
|
||
|
||
// APIKeyAuthWithSubscriptionGoogle behaves like ApiKeyAuthWithSubscription but returns Google-style errors:
|
||
// {"error":{"code":401,"message":"...","status":"UNAUTHENTICATED"}}
|
||
//
|
||
// It is intended for Gemini native endpoints (/v1beta) to match Gemini SDK expectations.
|
||
func APIKeyAuthWithSubscriptionGoogle(apiKeyService *service.APIKeyService, subscriptionService *service.SubscriptionService, cfg *config.Config) gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
if v := strings.TrimSpace(c.Query("api_key")); v != "" {
|
||
abortWithGoogleError(c, 400, "Query parameter api_key is deprecated. Use Authorization header or key instead.")
|
||
return
|
||
}
|
||
apiKeyString := extractAPIKeyForGoogle(c)
|
||
if apiKeyString == "" {
|
||
abortWithGoogleError(c, 401, "API key is required")
|
||
return
|
||
}
|
||
|
||
apiKey, err := apiKeyService.GetByKey(c.Request.Context(), apiKeyString)
|
||
if err != nil {
|
||
if errors.Is(err, service.ErrAPIKeyNotFound) {
|
||
abortWithGoogleError(c, 401, "Invalid API key")
|
||
return
|
||
}
|
||
abortWithGoogleError(c, 500, "Failed to validate API key")
|
||
return
|
||
}
|
||
|
||
// 同 api_key_auth.go:早退中断前也写入 Ops 回退 key,便于错误日志展示
|
||
// user/group/platform。
|
||
SetOpsFallbackAPIKey(c, apiKey)
|
||
|
||
if !apiKey.IsActive() {
|
||
abortWithGoogleError(c, 401, "API key is disabled")
|
||
return
|
||
}
|
||
if apiKey.User == nil {
|
||
abortWithGoogleError(c, 401, "User associated with API key not found")
|
||
return
|
||
}
|
||
if !apiKey.User.IsActive() {
|
||
abortWithGoogleError(c, 401, "User account is not active")
|
||
return
|
||
}
|
||
if _, message, ok := validateAPIKeyGroupAvailable(apiKey); !ok {
|
||
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonAPIKeyGroupUnavailable)
|
||
abortWithGoogleError(c, 403, message)
|
||
return
|
||
}
|
||
|
||
// 简易模式:跳过余额和订阅检查
|
||
if cfg.RunMode == config.RunModeSimple {
|
||
c.Set(string(ContextKeyAPIKey), apiKey)
|
||
c.Set(string(ContextKeyUser), AuthSubject{
|
||
UserID: apiKey.User.ID,
|
||
Concurrency: apiKey.User.Concurrency,
|
||
})
|
||
c.Set(string(ContextKeyUserRole), apiKey.User.Role)
|
||
setGroupContext(c, apiKey.Group)
|
||
_ = apiKeyService.TouchLastUsed(c.Request.Context(), apiKey.ID)
|
||
c.Next()
|
||
return
|
||
}
|
||
|
||
isSubscriptionType := apiKey.Group != nil && apiKey.Group.IsSubscriptionType()
|
||
if isSubscriptionType && subscriptionService != nil {
|
||
subscription, err := subscriptionService.GetActiveSubscription(
|
||
c.Request.Context(),
|
||
apiKey.User.ID,
|
||
apiKey.Group.ID,
|
||
)
|
||
if err != nil {
|
||
abortWithGoogleError(c, 403, "No active subscription found for this group")
|
||
return
|
||
}
|
||
|
||
needsMaintenance, err := subscriptionService.ValidateAndCheckLimits(subscription, apiKey.Group)
|
||
if err != nil {
|
||
status := 403
|
||
if errors.Is(err, service.ErrDailyLimitExceeded) ||
|
||
errors.Is(err, service.ErrWeeklyLimitExceeded) ||
|
||
errors.Is(err, service.ErrMonthlyLimitExceeded) {
|
||
status = 429
|
||
}
|
||
abortWithGoogleError(c, status, err.Error())
|
||
return
|
||
}
|
||
|
||
c.Set(string(ContextKeySubscription), subscription)
|
||
|
||
if needsMaintenance {
|
||
maintenanceCopy := *subscription
|
||
subscriptionService.DoWindowMaintenance(&maintenanceCopy)
|
||
}
|
||
} else {
|
||
if apiKey.User.Balance <= 0 {
|
||
abortWithGoogleError(c, 403, "Insufficient account balance")
|
||
return
|
||
}
|
||
}
|
||
|
||
c.Set(string(ContextKeyAPIKey), apiKey)
|
||
c.Set(string(ContextKeyUser), AuthSubject{
|
||
UserID: apiKey.User.ID,
|
||
Concurrency: apiKey.User.Concurrency,
|
||
})
|
||
c.Set(string(ContextKeyUserRole), apiKey.User.Role)
|
||
setGroupContext(c, apiKey.Group)
|
||
_ = apiKeyService.TouchLastUsed(c.Request.Context(), apiKey.ID)
|
||
c.Next()
|
||
}
|
||
}
|
||
|
||
// extractAPIKeyForGoogle extracts API key for Google/Gemini endpoints.
|
||
// Priority: x-goog-api-key > Authorization: Bearer > x-api-key > query key
|
||
// This allows OpenClaw and other clients using Bearer auth to work with Gemini endpoints.
|
||
func extractAPIKeyForGoogle(c *gin.Context) string {
|
||
// 1) preferred: Gemini native header
|
||
if k := strings.TrimSpace(c.GetHeader("x-goog-api-key")); k != "" {
|
||
return k
|
||
}
|
||
|
||
// 2) fallback: Authorization: Bearer <key>
|
||
auth := strings.TrimSpace(c.GetHeader("Authorization"))
|
||
if auth != "" {
|
||
parts := strings.SplitN(auth, " ", 2)
|
||
if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") {
|
||
if k := strings.TrimSpace(parts[1]); k != "" {
|
||
return k
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3) x-api-key header (backward compatibility)
|
||
if k := strings.TrimSpace(c.GetHeader("x-api-key")); k != "" {
|
||
return k
|
||
}
|
||
|
||
// 4) query parameter key (for specific paths)
|
||
if allowGoogleQueryKey(c.Request.URL.Path) {
|
||
if v := strings.TrimSpace(c.Query("key")); v != "" {
|
||
return v
|
||
}
|
||
}
|
||
|
||
return ""
|
||
}
|
||
|
||
func allowGoogleQueryKey(path string) bool {
|
||
return strings.HasPrefix(path, "/v1beta") || strings.HasPrefix(path, "/antigravity/v1beta")
|
||
}
|
||
|
||
func abortWithGoogleError(c *gin.Context, status int, message string) {
|
||
c.JSON(status, gin.H{
|
||
"error": gin.H{
|
||
"code": status,
|
||
"message": message,
|
||
"status": googleapi.HTTPStatusToGoogleStatus(status),
|
||
},
|
||
})
|
||
c.Abort()
|
||
}
|