feat(ops): 错误日志 key 归因与早退字段补全
让 /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>
This commit is contained in:
parent
57bae985a7
commit
ddf063352a
@ -71,6 +71,49 @@ const (
|
||||
opsErrorLogBatchSize = 32
|
||||
)
|
||||
|
||||
// looksLikeSystemKey 粗筛"形似本系统 key"的输入:长度 16-128 且仅含 [a-zA-Z0-9_-]。
|
||||
// 不用前缀匹配(APIKeyPrefix 可配置)。用于反查审计表前挡掉随机扫描的乱码输入。
|
||||
func looksLikeSystemKey(key string) bool {
|
||||
if len(key) < 16 || len(key) > 128 {
|
||||
return false
|
||||
}
|
||||
for _, c := range key {
|
||||
allowed := (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
|
||||
(c >= '0' && c <= '9') || c == '_' || c == '-'
|
||||
if !allowed {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// keyPrefix 返回脱敏前缀(前 n 个字符);不足 n 则原样返回。
|
||||
func keyPrefix(key string, n int) string {
|
||||
if len(key) <= n {
|
||||
return key
|
||||
}
|
||||
return key[:n]
|
||||
}
|
||||
|
||||
// extractAttemptedKey 按认证中间件同样的顺序从请求头提取提交的 key 明文。
|
||||
// 与 api_key_auth.go:43-59 一致:Authorization 仅取 Bearer scheme,非 Bearer 则忽略并继续 x-api-key → x-goog-api-key。
|
||||
func extractAttemptedKey(c *gin.Context) string {
|
||||
if h := c.GetHeader("Authorization"); h != "" {
|
||||
parts := strings.SplitN(h, " ", 2)
|
||||
if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") {
|
||||
return strings.TrimSpace(parts[1])
|
||||
}
|
||||
// 非 Bearer:与中间件一致,忽略 Authorization,继续尝试其它 header(不在此 return)。
|
||||
}
|
||||
if k := c.GetHeader("x-api-key"); k != "" {
|
||||
return strings.TrimSpace(k)
|
||||
}
|
||||
if k := c.GetHeader("x-goog-api-key"); k != "" {
|
||||
return strings.TrimSpace(k)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type opsErrorLogJob struct {
|
||||
ops *service.OpsService
|
||||
entry *service.OpsInsertErrorLogInput
|
||||
@ -546,7 +589,7 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
apiKey, _ := middleware2.GetAPIKeyFromContext(c)
|
||||
apiKey := getOpsAPIKey(c)
|
||||
clientRequestID, _ := c.Request.Context().Value(ctxkey.ClientRequestID).(string)
|
||||
|
||||
model, _ := c.Get(opsModelKey)
|
||||
@ -721,6 +764,7 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc {
|
||||
|
||||
if apiKey != nil {
|
||||
entry.APIKeyID = &apiKey.ID
|
||||
entry.APIKeyPrefix = keyPrefix(apiKey.Key, 8)
|
||||
if apiKey.User != nil {
|
||||
entry.UserID = &apiKey.User.ID
|
||||
}
|
||||
@ -765,7 +809,7 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
apiKey, _ := middleware2.GetAPIKeyFromContext(c)
|
||||
apiKey := getOpsAPIKey(c)
|
||||
|
||||
clientRequestID, _ := c.Request.Context().Value(ctxkey.ClientRequestID).(string)
|
||||
|
||||
@ -911,6 +955,8 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc {
|
||||
|
||||
if apiKey != nil {
|
||||
entry.APIKeyID = &apiKey.ID
|
||||
// 有效(未删除)key 报错时快照前缀,key 之后被删也保留;与 INVALID_API_KEY 的 attempted_key_prefix 互斥。
|
||||
entry.APIKeyPrefix = keyPrefix(apiKey.Key, 8)
|
||||
if apiKey.User != nil {
|
||||
entry.UserID = &apiKey.User.ID
|
||||
}
|
||||
@ -929,6 +975,22 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc {
|
||||
entry.ClientIP = &clientIP
|
||||
}
|
||||
|
||||
// 已删除 key 归因:仅 INVALID_API_KEY 才尝试。响应已写出,此处不阻塞客户端。
|
||||
if parsed.Code == opsCodeInvalidAPIKey {
|
||||
if attemptedKey := extractAttemptedKey(c); attemptedKey != "" {
|
||||
entry.AttemptedKeyPrefix = keyPrefix(attemptedKey, 8)
|
||||
if looksLikeSystemKey(attemptedKey) {
|
||||
if res, lookupErr := ops.LookupDeletedKeyAudit(c.Request.Context(), attemptedKey); lookupErr != nil {
|
||||
log.Printf("[OpsErrorLogger] LookupDeletedKeyAudit failed: %v", lookupErr)
|
||||
} else if res != nil {
|
||||
owner := res.UserID
|
||||
entry.DeletedKeyOwnerUserID = &owner
|
||||
entry.DeletedKeyName = res.KeyName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enqueueOpsErrorLog(ops, entry)
|
||||
}
|
||||
}
|
||||
@ -1035,6 +1097,20 @@ func parseOpsErrorResponse(body []byte) parsedOpsError {
|
||||
return parsedOpsError{Message: truncateString(string(body), 1024)}
|
||||
}
|
||||
|
||||
// getOpsAPIKey 返回用于 Ops 错误日志的 API Key:优先取已鉴权写入的正式 key;
|
||||
// 鉴权早退(分组停用/删除、Key 停用/过期/额度、用户停用、IP 限制等)时,
|
||||
// 正式 key 尚未写入,回退到 middleware 写入的 ops fallback key
|
||||
// (含 User/Group/Platform),从而让日志能展示 用户/分组/平台。
|
||||
func getOpsAPIKey(c *gin.Context) *service.APIKey {
|
||||
if apiKey, ok := middleware2.GetAPIKeyFromContext(c); ok && apiKey != nil {
|
||||
return apiKey
|
||||
}
|
||||
if apiKey, ok := middleware2.GetOpsFallbackAPIKey(c); ok && apiKey != nil {
|
||||
return apiKey
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveOpsPlatform(apiKey *service.APIKey, fallback string) string {
|
||||
if apiKey != nil && apiKey.Group != nil && apiKey.Group.Platform != "" {
|
||||
return apiKey.Group.Platform
|
||||
|
||||
118
backend/internal/handler/ops_error_logger_attribution_test.go
Normal file
118
backend/internal/handler/ops_error_logger_attribution_test.go
Normal file
@ -0,0 +1,118 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestLooksLikeSystemKey(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"sk-abcdef0123456789", true},
|
||||
{"ABCdef_-0123456789", true},
|
||||
{"short", false},
|
||||
{"with space xxxxxxxxxx", false},
|
||||
{"汉字key1234567890", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := looksLikeSystemKey(c.in); got != c.want {
|
||||
t.Errorf("looksLikeSystemKey(%q)=%v want %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
long := make([]byte, 129)
|
||||
for i := range long {
|
||||
long[i] = 'a'
|
||||
}
|
||||
if looksLikeSystemKey(string(long)) {
|
||||
t.Errorf("129-char key should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyPrefix(t *testing.T) {
|
||||
if got := keyPrefix("sk-3f2a9c7e", 8); got != "sk-3f2a9" {
|
||||
t.Errorf("keyPrefix=%q want %q", got, "sk-3f2a9")
|
||||
}
|
||||
if got := keyPrefix("abc", 8); got != "abc" {
|
||||
t.Errorf("short key should be returned as-is, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractAttemptedKey(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
headers map[string]string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "Bearer in Authorization",
|
||||
headers: map[string]string{"Authorization": "Bearer sk-testkey0123456789"},
|
||||
want: "sk-testkey0123456789",
|
||||
},
|
||||
{
|
||||
name: "Bearer case-insensitive",
|
||||
headers: map[string]string{"Authorization": "BEARER sk-testkey0123456789"},
|
||||
want: "sk-testkey0123456789",
|
||||
},
|
||||
{
|
||||
name: "x-api-key header",
|
||||
headers: map[string]string{"x-api-key": "sk-xapikey0123456789"},
|
||||
want: "sk-xapikey0123456789",
|
||||
},
|
||||
{
|
||||
name: "x-goog-api-key header",
|
||||
headers: map[string]string{"x-goog-api-key": "sk-goog0123456789"},
|
||||
want: "sk-goog0123456789",
|
||||
},
|
||||
{
|
||||
name: "Authorization takes priority over x-api-key",
|
||||
headers: map[string]string{"Authorization": "Bearer sk-auth0123456789", "x-api-key": "sk-xapi0123456789"},
|
||||
want: "sk-auth0123456789",
|
||||
},
|
||||
{
|
||||
name: "x-api-key takes priority over x-goog-api-key",
|
||||
headers: map[string]string{"x-api-key": "sk-xapi0123456789", "x-goog-api-key": "sk-goog0123456789"},
|
||||
want: "sk-xapi0123456789",
|
||||
},
|
||||
{
|
||||
name: "no key headers",
|
||||
headers: map[string]string{},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "Bearer with leading/trailing spaces trimmed",
|
||||
headers: map[string]string{"Authorization": "Bearer sk-trimmed0123456789 "},
|
||||
want: "sk-trimmed0123456789",
|
||||
},
|
||||
{
|
||||
// 非 Bearer Authorization 应被忽略,继续 fall-through 到 x-api-key(与认证中间件一致)
|
||||
name: "non-Bearer Authorization falls through to x-api-key",
|
||||
headers: map[string]string{"Authorization": "junk-not-bearer", "x-api-key": "sk-realkey1234567"},
|
||||
want: "sk-realkey1234567",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
for k, v := range tc.headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
c.Request = req
|
||||
|
||||
got := extractAttemptedKey(c)
|
||||
if got != tc.want {
|
||||
t.Errorf("extractAttemptedKey(%v) = %q, want %q", tc.headers, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -931,3 +931,45 @@ func TestSetOpsEndpointContext_NilContext(t *testing.T) {
|
||||
setOpsEndpointContext(nil, "model", int16(1))
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetOpsAPIKeyFallsBackToOpsFallbackKey(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
|
||||
// 主 key 缺席(鉴权早退场景):返回 nil。
|
||||
require.Nil(t, getOpsAPIKey(c))
|
||||
|
||||
// 写入 ops 专用 fallback key 后应能取到,且带齐 user/group。
|
||||
groupID := int64(55)
|
||||
apiKey := &service.APIKey{
|
||||
ID: 100,
|
||||
GroupID: &groupID,
|
||||
User: &service.User{ID: 7},
|
||||
Group: &service.Group{ID: groupID, Platform: service.PlatformAnthropic},
|
||||
}
|
||||
c.Set(string(middleware2.ContextKeyOpsFallbackAPIKey), apiKey)
|
||||
|
||||
got := getOpsAPIKey(c)
|
||||
require.NotNil(t, got)
|
||||
require.Equal(t, int64(100), got.ID)
|
||||
require.NotNil(t, got.User)
|
||||
require.Equal(t, int64(7), got.User.ID)
|
||||
require.NotNil(t, got.Group)
|
||||
require.Equal(t, service.PlatformAnthropic, got.Group.Platform)
|
||||
}
|
||||
|
||||
func TestGetOpsAPIKeyPrefersPrimaryContextKey(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
|
||||
primary := &service.APIKey{ID: 1}
|
||||
fallback := &service.APIKey{ID: 2}
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), primary)
|
||||
c.Set(string(middleware2.ContextKeyOpsFallbackAPIKey), fallback)
|
||||
|
||||
got := getOpsAPIKey(c)
|
||||
require.NotNil(t, got)
|
||||
require.Equal(t, int64(1), got.ID, "已鉴权请求应优先使用正式 api key")
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ package repository
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@ -304,6 +305,66 @@ func (r *apiKeyRepository) Delete(ctx context.Context, id int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteWithAudit 在同一事务内:
|
||||
// 1. 把(明文 key、所有者、key 名称)写入 deleted_api_key_audits;
|
||||
// 2. 软删除该 key(tombstone 覆盖 key 列以释放唯一约束)。
|
||||
//
|
||||
// 保证"被删除的 key 一定能反查到所有者"。事务模式与 group_repo.DeleteCascade 一致。
|
||||
func (r *apiKeyRepository) DeleteWithAudit(ctx context.Context, id int64) error {
|
||||
tombstoneKey := fmt.Sprintf("__deleted__%d__%d", id, time.Now().UnixNano())
|
||||
|
||||
tx, err := r.client.Tx(ctx)
|
||||
if err != nil && !errors.Is(err, dbent.ErrTxStarted) {
|
||||
return err
|
||||
}
|
||||
exec := r.client
|
||||
if err == nil {
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
exec = tx.Client()
|
||||
}
|
||||
// err == dbent.ErrTxStarted 时复用当前事务(exec = r.client)。
|
||||
|
||||
// 1. 审计:数据源即 api_keys 当前行;WHERE deleted_at IS NULL 保证只对未删除行写一次。
|
||||
if _, err := exec.ExecContext(ctx, `
|
||||
INSERT INTO deleted_api_key_audits (key, api_key_id, user_id, key_name, deleted_at)
|
||||
SELECT key, id, user_id, name, NOW()
|
||||
FROM api_keys
|
||||
WHERE id = $1 AND deleted_at IS NULL`, id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. 软删除(tombstone 覆盖 key)。
|
||||
res, err := exec.ExecContext(ctx, `
|
||||
UPDATE api_keys
|
||||
SET key = $1, deleted_at = NOW(), updated_at = NOW()
|
||||
WHERE id = $2 AND deleted_at IS NULL`, tombstoneKey, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
// 并发/重复删除:记录已存在(已软删)则幂等返回 nil(defer 回滚空事务),否则 NotFound。
|
||||
exists, existErr := r.client.APIKey.Query().
|
||||
Where(apikey.IDEQ(id)).
|
||||
Exist(mixins.SkipSoftDelete(ctx))
|
||||
if existErr != nil {
|
||||
return existErr
|
||||
}
|
||||
if exists {
|
||||
return nil
|
||||
}
|
||||
return service.ErrAPIKeyNotFound
|
||||
}
|
||||
|
||||
if tx != nil {
|
||||
return tx.Commit()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *apiKeyRepository) ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams, filters service.APIKeyListFilters) ([]service.APIKey, *pagination.PaginationResult, error) {
|
||||
q := r.activeQuery().Where(apikey.UserIDEQ(userID))
|
||||
|
||||
|
||||
@ -555,3 +555,46 @@ func TestIncrementQuotaUsed_Concurrent(t *testing.T) {
|
||||
require.Equal(t, float64(goroutines)*increment, got.QuotaUsed,
|
||||
"并发递增后总和应为 %v,实际为 %v", float64(goroutines)*increment, got.QuotaUsed)
|
||||
}
|
||||
|
||||
func (s *APIKeyRepoSuite) TestDeleteWithAudit_WritesAuditAndSoftDeletes() {
|
||||
user := s.mustCreateUser("delwithaudit@test.com")
|
||||
key := &service.APIKey{
|
||||
UserID: user.ID,
|
||||
Key: "sk-del-audit-1",
|
||||
Name: "Audit Me",
|
||||
Status: service.StatusActive,
|
||||
}
|
||||
s.Require().NoError(s.repo.Create(s.ctx, key))
|
||||
|
||||
s.Require().NoError(s.repo.DeleteWithAudit(s.ctx, key.ID))
|
||||
|
||||
_, err := s.repo.GetByID(s.ctx, key.ID)
|
||||
s.Require().Error(err)
|
||||
|
||||
rows, qErr := s.client.QueryContext(s.ctx,
|
||||
`SELECT key, key_name, user_id, api_key_id FROM deleted_api_key_audits WHERE api_key_id = $1`, key.ID)
|
||||
s.Require().NoError(qErr)
|
||||
defer rows.Close()
|
||||
s.Require().True(rows.Next(), "expected one audit row")
|
||||
var auditKey, auditName string
|
||||
var auditUserID, auditAPIKeyID int64
|
||||
s.Require().NoError(rows.Scan(&auditKey, &auditName, &auditUserID, &auditAPIKeyID))
|
||||
s.Require().Equal("sk-del-audit-1", auditKey)
|
||||
s.Require().Equal("Audit Me", auditName)
|
||||
s.Require().Equal(user.ID, auditUserID)
|
||||
s.Require().Equal(key.ID, auditAPIKeyID)
|
||||
}
|
||||
|
||||
func (s *APIKeyRepoSuite) TestDeleteWithAudit_RepeatIsIdempotent() {
|
||||
user := s.mustCreateUser("delwithaudit-idem@test.com")
|
||||
key := &service.APIKey{UserID: user.ID, Key: "sk-del-audit-2", Name: "K", Status: service.StatusActive}
|
||||
s.Require().NoError(s.repo.Create(s.ctx, key))
|
||||
|
||||
s.Require().NoError(s.repo.DeleteWithAudit(s.ctx, key.ID))
|
||||
s.Require().NoError(s.repo.DeleteWithAudit(s.ctx, key.ID))
|
||||
}
|
||||
|
||||
func (s *APIKeyRepoSuite) TestDeleteWithAudit_NotFound() {
|
||||
err := s.repo.DeleteWithAudit(s.ctx, 999999)
|
||||
s.Require().ErrorIs(err, service.ErrAPIKeyNotFound)
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@ -54,9 +55,13 @@ INSERT INTO ops_error_logs (
|
||||
upstream_latency_ms,
|
||||
response_latency_ms,
|
||||
time_to_first_token_ms,
|
||||
created_at
|
||||
created_at,
|
||||
attempted_key_prefix,
|
||||
deleted_key_owner_user_id,
|
||||
deleted_key_name,
|
||||
api_key_prefix
|
||||
) VALUES (
|
||||
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36,$37
|
||||
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36,$37,$38,$39,$40,$41
|
||||
)`
|
||||
|
||||
func NewOpsRepository(db *sql.DB) service.OpsRepository {
|
||||
@ -165,6 +170,10 @@ func opsInsertErrorLogArgs(input *service.OpsInsertErrorLogInput) []any {
|
||||
opsNullInt64(input.ResponseLatencyMs),
|
||||
opsNullInt64(input.TimeToFirstTokenMs),
|
||||
input.CreatedAt,
|
||||
opsNullString(input.AttemptedKeyPrefix),
|
||||
opsNullInt64(input.DeletedKeyOwnerUserID),
|
||||
opsNullString(input.DeletedKeyName),
|
||||
opsNullString(input.APIKeyPrefix),
|
||||
}
|
||||
}
|
||||
|
||||
@ -402,11 +411,17 @@ SELECT
|
||||
e.routing_latency_ms,
|
||||
e.upstream_latency_ms,
|
||||
e.response_latency_ms,
|
||||
e.time_to_first_token_ms
|
||||
e.time_to_first_token_ms,
|
||||
COALESCE(e.attempted_key_prefix, ''),
|
||||
e.deleted_key_owner_user_id,
|
||||
COALESCE(du.email, ''),
|
||||
COALESCE(e.deleted_key_name, ''),
|
||||
COALESCE(e.api_key_prefix, '')
|
||||
FROM ops_error_logs e
|
||||
LEFT JOIN users u ON e.user_id = u.id
|
||||
LEFT JOIN accounts a ON e.account_id = a.id
|
||||
LEFT JOIN groups g ON e.group_id = g.id
|
||||
LEFT JOIN users du ON e.deleted_key_owner_user_id = du.id
|
||||
WHERE e.id = $1
|
||||
LIMIT 1`
|
||||
|
||||
@ -426,6 +441,7 @@ LIMIT 1`
|
||||
var responseLatency sql.NullInt64
|
||||
var ttft sql.NullInt64
|
||||
var requestType sql.NullInt64
|
||||
var deletedKeyOwnerUserID sql.NullInt64
|
||||
|
||||
err := r.db.QueryRowContext(ctx, q, id).Scan(
|
||||
&out.ID,
|
||||
@ -471,6 +487,11 @@ LIMIT 1`
|
||||
&upstreamLatency,
|
||||
&responseLatency,
|
||||
&ttft,
|
||||
&out.AttemptedKeyPrefix,
|
||||
&deletedKeyOwnerUserID,
|
||||
&out.DeletedKeyOwnerEmail,
|
||||
&out.DeletedKeyName,
|
||||
&out.APIKeyPrefix,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -533,6 +554,10 @@ LIMIT 1`
|
||||
v := int16(requestType.Int64)
|
||||
out.RequestType = &v
|
||||
}
|
||||
if deletedKeyOwnerUserID.Valid {
|
||||
v := deletedKeyOwnerUserID.Int64
|
||||
out.DeletedKeyOwnerUserID = &v
|
||||
}
|
||||
|
||||
// Normalize upstream_errors to empty string when stored as JSON null.
|
||||
out.UpstreamErrors = strings.TrimSpace(out.UpstreamErrors)
|
||||
@ -543,6 +568,26 @@ LIMIT 1`
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// LookupDeletedKeyAudit 按明文 key 反查最近一条已删除 key 审计。
|
||||
// 同一 key 可能有多条历史(反复创建/删除),取 deleted_at 最近一条(id 作同毫秒 tiebreaker)。
|
||||
// 未命中返回 (nil, nil)。
|
||||
func (r *opsRepository) LookupDeletedKeyAudit(ctx context.Context, key string) (*service.DeletedKeyAuditResult, error) {
|
||||
var res service.DeletedKeyAuditResult
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT user_id, key_name
|
||||
FROM deleted_api_key_audits
|
||||
WHERE key = $1
|
||||
ORDER BY deleted_at DESC, id DESC
|
||||
LIMIT 1`, key).Scan(&res.UserID, &res.KeyName)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &res, nil
|
||||
}
|
||||
|
||||
func (r *opsRepository) UpdateErrorResolution(ctx context.Context, errorID int64, resolved bool, resolvedByUserID *int64, resolvedAt *time.Time) error {
|
||||
if r == nil || r.db == nil {
|
||||
return fmt.Errorf("nil ops repository")
|
||||
|
||||
@ -0,0 +1,94 @@
|
||||
//go:build integration
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestGetErrorLogByID_DeletedKeyOwner 验证:
|
||||
// 1. 带 deleted_key_owner_user_id 的记录能正确 JOIN users 返回 DeletedKeyOwnerEmail
|
||||
// 2. 新列全为 NULL 的普通记录 Scan 不报错,这些字段为空/nil
|
||||
func TestGetErrorLogByID_DeletedKeyOwner(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
_, _ = integrationDB.ExecContext(ctx, "TRUNCATE ops_error_logs RESTART IDENTITY CASCADE")
|
||||
|
||||
repo := NewOpsRepository(integrationDB).(*opsRepository)
|
||||
|
||||
// ── Case 1: 带 deleted_key_owner 信息的记录 ──────────────────────────────
|
||||
owner := mustCreateUser(t, integrationEntClient, &service.User{
|
||||
Email: "deleted-key-owner-" + time.Now().Format("150405.000000000") + "@example.com",
|
||||
})
|
||||
|
||||
var insertedID int64
|
||||
err := integrationDB.QueryRowContext(ctx, `
|
||||
INSERT INTO ops_error_logs (
|
||||
error_phase, error_type, severity, status_code, created_at,
|
||||
attempted_key_prefix, deleted_key_owner_user_id, deleted_key_name
|
||||
) VALUES (
|
||||
'auth', 'INVALID_API_KEY', 'error', 401, NOW(),
|
||||
'sk-test-abc', $1, 'my-deleted-key'
|
||||
) RETURNING id`,
|
||||
owner.ID,
|
||||
).Scan(&insertedID)
|
||||
require.NoError(t, err)
|
||||
require.Positive(t, insertedID)
|
||||
|
||||
detail, err := repo.GetErrorLogByID(ctx, insertedID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, detail)
|
||||
|
||||
require.Equal(t, "sk-test-abc", detail.AttemptedKeyPrefix)
|
||||
require.NotNil(t, detail.DeletedKeyOwnerUserID)
|
||||
require.Equal(t, owner.ID, *detail.DeletedKeyOwnerUserID)
|
||||
require.Equal(t, owner.Email, detail.DeletedKeyOwnerEmail)
|
||||
require.Equal(t, "my-deleted-key", detail.DeletedKeyName)
|
||||
|
||||
// ── Case 2: 新列全为 NULL 的普通错误记录 ──────────────────────────────────
|
||||
var plainID int64
|
||||
err = integrationDB.QueryRowContext(ctx, `
|
||||
INSERT INTO ops_error_logs (
|
||||
error_phase, error_type, severity, status_code, created_at
|
||||
) VALUES (
|
||||
'upstream', 'upstream_error', 'error', 500, NOW()
|
||||
) RETURNING id`,
|
||||
).Scan(&plainID)
|
||||
require.NoError(t, err)
|
||||
require.Positive(t, plainID)
|
||||
|
||||
plain, err := repo.GetErrorLogByID(ctx, plainID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, plain)
|
||||
|
||||
require.Empty(t, plain.AttemptedKeyPrefix, "no prefix for plain error")
|
||||
require.Nil(t, plain.DeletedKeyOwnerUserID, "no owner for plain error")
|
||||
require.Empty(t, plain.DeletedKeyOwnerEmail, "no owner email for plain error")
|
||||
require.Empty(t, plain.DeletedKeyName, "no key name for plain error")
|
||||
require.Empty(t, plain.APIKeyPrefix, "no api key prefix for plain error")
|
||||
|
||||
// ── Case 3: 有效(未删除)key 报错,经 InsertErrorLog 快照 api_key_prefix ──────
|
||||
// 走真实 InsertErrorLog 写入路径(覆盖新列 + $41 占位符),再 GetErrorLogByID 读回。
|
||||
validID, err := repo.InsertErrorLog(ctx, &service.OpsInsertErrorLogInput{
|
||||
ErrorPhase: "request",
|
||||
ErrorType: "api_error",
|
||||
Severity: "error",
|
||||
StatusCode: 402,
|
||||
CreatedAt: time.Now(),
|
||||
APIKeyPrefix: "sk-valid",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Positive(t, validID)
|
||||
|
||||
valid, err := repo.GetErrorLogByID(ctx, validID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, valid)
|
||||
|
||||
require.Equal(t, "sk-valid", valid.APIKeyPrefix)
|
||||
require.Empty(t, valid.AttemptedKeyPrefix, "attempted prefix and api key prefix are mutually exclusive")
|
||||
require.Nil(t, valid.DeletedKeyOwnerUserID, "valid key error has no deleted owner")
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
//go:build integration
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOpsRepositoryLookupDeletedKeyAudit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
_, _ = integrationDB.ExecContext(ctx, "TRUNCATE deleted_api_key_audits RESTART IDENTITY")
|
||||
repo := NewOpsRepository(integrationDB).(*opsRepository)
|
||||
|
||||
// 同一 key 两条审计,取最近一条(deleted_at DESC, id DESC)
|
||||
_, err := integrationDB.ExecContext(ctx, `
|
||||
INSERT INTO deleted_api_key_audits (key, api_key_id, user_id, key_name, deleted_at)
|
||||
VALUES ('sk-lookup-1', 10, 100, 'old', $1),
|
||||
('sk-lookup-1', 11, 200, 'new', $2)`,
|
||||
time.Now().Add(-time.Hour), time.Now())
|
||||
require.NoError(t, err)
|
||||
|
||||
res, err := repo.LookupDeletedKeyAudit(ctx, "sk-lookup-1")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, res)
|
||||
require.Equal(t, int64(200), res.UserID)
|
||||
require.Equal(t, "new", res.KeyName)
|
||||
|
||||
// 未命中返回 nil
|
||||
miss, err := repo.LookupDeletedKeyAudit(ctx, "sk-never-existed")
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, miss)
|
||||
}
|
||||
@ -2102,6 +2102,10 @@ func (r *stubApiKeyRepo) Delete(ctx context.Context, id int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *stubApiKeyRepo) DeleteWithAudit(ctx context.Context, id int64) error {
|
||||
return r.Delete(ctx, id)
|
||||
}
|
||||
|
||||
func (r *stubApiKeyRepo) ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams, _ service.APIKeyListFilters) ([]service.APIKey, *pagination.PaginationResult, error) {
|
||||
ids := make([]int64, 0, len(r.byID))
|
||||
for id := range r.byID {
|
||||
|
||||
@ -76,6 +76,10 @@ func apiKeyAuthWithSubscription(apiKeyService *service.APIKeyService, subscripti
|
||||
return
|
||||
}
|
||||
|
||||
// apiKey 已加载(含 User/Group)。即便后续因分组停用/Key 停用/用户停用/
|
||||
// IP 限制等早退中断,也让 Ops 错误日志能回退取到 user/group/platform。
|
||||
SetOpsFallbackAPIKey(c, apiKey)
|
||||
|
||||
// ── 3. 基础鉴权(始终执行) ─────────────────────────────────
|
||||
|
||||
// disabled / 未知状态 → 无条件拦截(expired 和 quota_exhausted 留给计费阶段)
|
||||
@ -237,6 +241,26 @@ func GetAPIKeyFromContext(c *gin.Context) (*service.APIKey, bool) {
|
||||
return apiKey, ok
|
||||
}
|
||||
|
||||
// SetOpsFallbackAPIKey 记录已加载的 API Key,供 Ops 错误日志在鉴权早退时回退使用。
|
||||
// 与 ContextKeyAPIKey 区分:写入它不代表请求已通过鉴权,因此不影响 handler、
|
||||
// 审计日志等对“已鉴权”的判断。
|
||||
func SetOpsFallbackAPIKey(c *gin.Context, apiKey *service.APIKey) {
|
||||
if c == nil || apiKey == nil {
|
||||
return
|
||||
}
|
||||
c.Set(string(ContextKeyOpsFallbackAPIKey), apiKey)
|
||||
}
|
||||
|
||||
// GetOpsFallbackAPIKey 读取 Ops 错误日志专用的回退 API Key。
|
||||
func GetOpsFallbackAPIKey(c *gin.Context) (*service.APIKey, bool) {
|
||||
value, exists := c.Get(string(ContextKeyOpsFallbackAPIKey))
|
||||
if !exists {
|
||||
return nil, false
|
||||
}
|
||||
apiKey, ok := value.(*service.APIKey)
|
||||
return apiKey, ok
|
||||
}
|
||||
|
||||
// GetSubscriptionFromContext 从上下文中获取订阅信息
|
||||
func GetSubscriptionFromContext(c *gin.Context) (*service.UserSubscription, bool) {
|
||||
value, exists := c.Get(string(ContextKeySubscription))
|
||||
|
||||
@ -42,6 +42,10 @@ func APIKeyAuthWithSubscriptionGoogle(apiKeyService *service.APIKeyService, subs
|
||||
return
|
||||
}
|
||||
|
||||
// 同 api_key_auth.go:早退中断前也写入 Ops 回退 key,便于错误日志展示
|
||||
// user/group/platform。
|
||||
SetOpsFallbackAPIKey(c, apiKey)
|
||||
|
||||
if !apiKey.IsActive() {
|
||||
abortWithGoogleError(c, 401, "API key is disabled")
|
||||
return
|
||||
|
||||
@ -56,6 +56,9 @@ func (f fakeAPIKeyRepo) Update(ctx context.Context, key *service.APIKey) error {
|
||||
func (f fakeAPIKeyRepo) Delete(ctx context.Context, id int64) error {
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
func (f fakeAPIKeyRepo) DeleteWithAudit(ctx context.Context, id int64) error {
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
func (f fakeAPIKeyRepo) ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams, _ service.APIKeyListFilters) ([]service.APIKey, *pagination.PaginationResult, error) {
|
||||
return nil, nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
@ -419,6 +419,138 @@ func TestAPIKeyAuthRejectsUnavailableGroup(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIKeyAuthSetsOpsFallbackKeyOnEarlyAbort(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
groupID := int64(101)
|
||||
user := &service.User{
|
||||
ID: 7,
|
||||
Role: service.RoleUser,
|
||||
Status: service.StatusActive,
|
||||
Balance: 10,
|
||||
Concurrency: 3,
|
||||
}
|
||||
apiKey := &service.APIKey{
|
||||
ID: 100,
|
||||
UserID: user.ID,
|
||||
GroupID: &groupID,
|
||||
Key: "test-key",
|
||||
Status: service.StatusActive,
|
||||
User: user,
|
||||
Group: &service.Group{
|
||||
ID: groupID,
|
||||
Name: "disabled",
|
||||
Status: service.StatusDisabled,
|
||||
Platform: service.PlatformAnthropic,
|
||||
Hydrated: true,
|
||||
},
|
||||
}
|
||||
apiKeyRepo := &stubApiKeyRepo{
|
||||
getByKey: func(ctx context.Context, key string) (*service.APIKey, error) {
|
||||
if key != apiKey.Key {
|
||||
return nil, service.ErrAPIKeyNotFound
|
||||
}
|
||||
clone := *apiKey
|
||||
return &clone, nil
|
||||
},
|
||||
}
|
||||
cfg := &config.Config{RunMode: config.RunModeStandard}
|
||||
apiKeyService := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, nil, cfg)
|
||||
|
||||
router := gin.New()
|
||||
var fallback *service.APIKey
|
||||
var fallbackOK bool
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Next()
|
||||
fallback, fallbackOK = GetOpsFallbackAPIKey(c)
|
||||
})
|
||||
router.Use(gin.HandlerFunc(NewAPIKeyAuthMiddleware(apiKeyService, nil, cfg)))
|
||||
router.GET("/t", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/t", nil)
|
||||
req.Header.Set("x-api-key", apiKey.Key)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
// 分组停用 → 早退中断,但 ops fallback key 仍应写入,含 user/group/platform。
|
||||
require.Equal(t, http.StatusForbidden, w.Code)
|
||||
require.Contains(t, w.Body.String(), "GROUP_DISABLED")
|
||||
require.True(t, fallbackOK, "鉴权早退时也应写入 ops fallback api key")
|
||||
require.NotNil(t, fallback)
|
||||
require.Equal(t, apiKey.ID, fallback.ID)
|
||||
require.NotNil(t, fallback.User)
|
||||
require.Equal(t, user.ID, fallback.User.ID)
|
||||
require.NotNil(t, fallback.GroupID)
|
||||
require.Equal(t, groupID, *fallback.GroupID)
|
||||
require.NotNil(t, fallback.Group)
|
||||
require.Equal(t, service.PlatformAnthropic, fallback.Group.Platform)
|
||||
}
|
||||
|
||||
func TestAPIKeyAuthGoogleSetsOpsFallbackKeyOnEarlyAbort(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
groupID := int64(202)
|
||||
user := &service.User{
|
||||
ID: 9,
|
||||
Role: service.RoleUser,
|
||||
Status: service.StatusActive,
|
||||
Balance: 10,
|
||||
Concurrency: 3,
|
||||
}
|
||||
apiKey := &service.APIKey{
|
||||
ID: 200,
|
||||
UserID: user.ID,
|
||||
GroupID: &groupID,
|
||||
Key: "g-key",
|
||||
Status: service.StatusActive,
|
||||
User: user,
|
||||
Group: &service.Group{
|
||||
ID: groupID,
|
||||
Name: "disabled",
|
||||
Status: service.StatusDisabled,
|
||||
Platform: service.PlatformGemini,
|
||||
Hydrated: true,
|
||||
},
|
||||
}
|
||||
apiKeyRepo := &stubApiKeyRepo{
|
||||
getByKey: func(ctx context.Context, key string) (*service.APIKey, error) {
|
||||
if key != apiKey.Key {
|
||||
return nil, service.ErrAPIKeyNotFound
|
||||
}
|
||||
clone := *apiKey
|
||||
return &clone, nil
|
||||
},
|
||||
}
|
||||
cfg := &config.Config{RunMode: config.RunModeStandard}
|
||||
apiKeyService := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, nil, cfg)
|
||||
|
||||
router := gin.New()
|
||||
var fallback *service.APIKey
|
||||
var fallbackOK bool
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Next()
|
||||
fallback, fallbackOK = GetOpsFallbackAPIKey(c)
|
||||
})
|
||||
router.Use(gin.HandlerFunc(APIKeyAuthWithSubscriptionGoogle(apiKeyService, nil, cfg)))
|
||||
router.GET("/t", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/t", nil)
|
||||
req.Header.Set("x-goog-api-key", apiKey.Key)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusForbidden, w.Code)
|
||||
require.True(t, fallbackOK, "Google 鉴权早退时也应写入 ops fallback api key")
|
||||
require.NotNil(t, fallback)
|
||||
require.Equal(t, apiKey.ID, fallback.ID)
|
||||
require.NotNil(t, fallback.User)
|
||||
require.Equal(t, user.ID, fallback.User.ID)
|
||||
}
|
||||
|
||||
func TestRequireGroupAssignmentMarksUngroupedKeyBusinessLimited(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
@ -761,6 +893,10 @@ func (r *stubApiKeyRepo) Delete(ctx context.Context, id int64) error {
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (r *stubApiKeyRepo) DeleteWithAudit(ctx context.Context, id int64) error {
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (r *stubApiKeyRepo) ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams, _ service.APIKeyListFilters) ([]service.APIKey, *pagination.PaginationResult, error) {
|
||||
return nil, nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
@ -24,6 +24,11 @@ const (
|
||||
ContextKeySubscription ContextKey = "subscription"
|
||||
// ContextKeyForcePlatform 强制平台(用于 /antigravity 路由)
|
||||
ContextKeyForcePlatform ContextKey = "force_platform"
|
||||
// ContextKeyOpsFallbackAPIKey 运维错误日志专用回退键。
|
||||
// 鉴权早退(分组停用/删除、Key 停用/过期/额度、用户停用、IP 限制等)时,
|
||||
// apiKey 已加载但尚未写入 ContextKeyAPIKey;该键让 Ops 错误日志仍能取到
|
||||
// user/group/platform。仅供 Ops 错误日志读取,不代表请求已通过鉴权。
|
||||
ContextKeyOpsFallbackAPIKey ContextKey = "ops_fallback_api_key"
|
||||
)
|
||||
|
||||
// ForcePlatform 返回设置强制平台的中间件
|
||||
|
||||
@ -146,6 +146,9 @@ func (s *apiKeyRepoStubForGroupUpdate) GetByKeyForAuth(context.Context, string)
|
||||
panic("unexpected")
|
||||
}
|
||||
func (s *apiKeyRepoStubForGroupUpdate) Delete(context.Context, int64) error { panic("unexpected") }
|
||||
func (s *apiKeyRepoStubForGroupUpdate) DeleteWithAudit(context.Context, int64) error {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (s *apiKeyRepoStubForGroupUpdate) ListByUserID(context.Context, int64, pagination.PaginationParams, APIKeyListFilters) ([]APIKey, *pagination.PaginationResult, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
|
||||
@ -55,6 +55,8 @@ type APIKeyRepository interface {
|
||||
GetByKeyForAuth(ctx context.Context, key string) (*APIKey, error)
|
||||
Update(ctx context.Context, key *APIKey) error
|
||||
Delete(ctx context.Context, id int64) error
|
||||
// DeleteWithAudit 在同一事务内先写 deleted_api_key_audits 审计、再软删除该 key。
|
||||
DeleteWithAudit(ctx context.Context, id int64) error
|
||||
|
||||
ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams, filters APIKeyListFilters) ([]APIKey, *pagination.PaginationResult, error)
|
||||
VerifyOwnership(ctx context.Context, userID int64, apiKeyIDs []int64) ([]int64, error)
|
||||
@ -648,15 +650,16 @@ func (s *APIKeyService) Delete(ctx context.Context, id int64, userID int64) erro
|
||||
return ErrInsufficientPerms
|
||||
}
|
||||
|
||||
// 清除Redis缓存(使用 userID 而非 apiKey.UserID)
|
||||
// 事务内:写审计 + 软删除(tombstone)。
|
||||
if err := s.apiKeyRepo.DeleteWithAudit(ctx, id); err != nil {
|
||||
return fmt.Errorf("delete api key: %w", err)
|
||||
}
|
||||
|
||||
// 删除成功后再清理缓存,避免"缓存已清但删除失败"的竞态。
|
||||
if s.cache != nil {
|
||||
_ = s.cache.DeleteCreateAttemptCount(ctx, userID)
|
||||
}
|
||||
s.InvalidateAuthCacheByKey(ctx, key)
|
||||
|
||||
if err := s.apiKeyRepo.Delete(ctx, id); err != nil {
|
||||
return fmt.Errorf("delete api key: %w", err)
|
||||
}
|
||||
s.lastUsedTouchL1.Delete(id)
|
||||
|
||||
return nil
|
||||
|
||||
@ -53,6 +53,10 @@ func (s *authRepoStub) Delete(ctx context.Context, id int64) error {
|
||||
panic("unexpected Delete call")
|
||||
}
|
||||
|
||||
func (s *authRepoStub) DeleteWithAudit(ctx context.Context, id int64) error {
|
||||
panic("unexpected DeleteWithAudit call")
|
||||
}
|
||||
|
||||
func (s *authRepoStub) ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams, filters APIKeyListFilters) ([]APIKey, *pagination.PaginationResult, error) {
|
||||
panic("unexpected ListByUserID call")
|
||||
}
|
||||
|
||||
@ -79,6 +79,12 @@ func (s *apiKeyRepoStub) Delete(ctx context.Context, id int64) error {
|
||||
return s.deleteErr
|
||||
}
|
||||
|
||||
// DeleteWithAudit 与 Delete 一样记录被删除的 ID,供 service 测试断言。
|
||||
func (s *apiKeyRepoStub) DeleteWithAudit(ctx context.Context, id int64) error {
|
||||
s.deletedIDs = append(s.deletedIDs, id)
|
||||
return s.deleteErr
|
||||
}
|
||||
|
||||
// 以下是接口要求实现但本测试不关心的方法
|
||||
|
||||
func (s *apiKeyRepoStub) ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams, filters APIKeyListFilters) ([]APIKey, *pagination.PaginationResult, error) {
|
||||
@ -274,8 +280,8 @@ func TestApiKeyService_Delete_NotFound(t *testing.T) {
|
||||
// 预期行为:
|
||||
// - GetKeyAndOwnerID 返回正确的所有者 ID
|
||||
// - 所有权验证通过
|
||||
// - 缓存被清除(在删除之前)
|
||||
// - Delete 被调用但返回错误
|
||||
// - DeleteWithAudit 被调用但返回错误
|
||||
// - 删除失败时缓存不被清除(缓存清理在删除成功后执行,消除竞态)
|
||||
// - 返回包含 "delete api key" 的错误信息
|
||||
func TestApiKeyService_Delete_DeleteFails(t *testing.T) {
|
||||
repo := &apiKeyRepoStub{
|
||||
@ -288,7 +294,7 @@ func TestApiKeyService_Delete_DeleteFails(t *testing.T) {
|
||||
err := svc.Delete(context.Background(), 3, 3) // API Key ID=3, 调用者 userID=3
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "delete api key")
|
||||
require.Equal(t, []int64{3}, repo.deletedIDs) // 验证删除操作被调用
|
||||
require.Equal(t, []int64{3}, cache.invalidated) // 验证缓存已被清除(即使删除失败)
|
||||
require.Equal(t, []string{svc.authCacheKey("k")}, cache.deleteAuthKeys)
|
||||
require.Equal(t, []int64{3}, repo.deletedIDs) // 验证 DeleteWithAudit 被调用
|
||||
require.Empty(t, cache.invalidated) // 验证删除失败时缓存未被清除(新顺序:先删后清)
|
||||
require.Empty(t, cache.deleteAuthKeys) // 验证删除失败时 auth 缓存未被清除
|
||||
}
|
||||
|
||||
@ -101,6 +101,9 @@ func (s *quotaBaseAPIKeyRepoStub) Update(context.Context, *APIKey) error {
|
||||
func (s *quotaBaseAPIKeyRepoStub) Delete(context.Context, int64) error {
|
||||
panic("unexpected Delete call")
|
||||
}
|
||||
func (s *quotaBaseAPIKeyRepoStub) DeleteWithAudit(context.Context, int64) error {
|
||||
panic("unexpected DeleteWithAudit call")
|
||||
}
|
||||
func (s *quotaBaseAPIKeyRepoStub) ListByUserID(context.Context, int64, pagination.PaginationParams, APIKeyListFilters) ([]APIKey, *pagination.PaginationResult, error) {
|
||||
panic("unexpected ListByUserID call")
|
||||
}
|
||||
|
||||
@ -87,6 +87,15 @@ type OpsErrorLogDetail struct {
|
||||
|
||||
// vNext metric semantics
|
||||
IsBusinessLimited bool `json:"is_business_limited"`
|
||||
|
||||
// Deleted key owner info (populated when INVALID_API_KEY and key was previously deleted)
|
||||
AttemptedKeyPrefix string `json:"attempted_key_prefix,omitempty"`
|
||||
DeletedKeyOwnerUserID *int64 `json:"deleted_key_owner_user_id,omitempty"`
|
||||
DeletedKeyOwnerEmail string `json:"deleted_key_owner_email,omitempty"`
|
||||
DeletedKeyName string `json:"deleted_key_name,omitempty"`
|
||||
|
||||
// Bound (non-deleted) key prefix, snapshotted at error time; mutually exclusive with AttemptedKeyPrefix.
|
||||
APIKeyPrefix string `json:"api_key_prefix,omitempty"`
|
||||
}
|
||||
|
||||
type OpsErrorLogFilter struct {
|
||||
|
||||
@ -10,6 +10,8 @@ type OpsRepository interface {
|
||||
BatchInsertErrorLogs(ctx context.Context, inputs []*OpsInsertErrorLogInput) (int64, error)
|
||||
ListErrorLogs(ctx context.Context, filter *OpsErrorLogFilter) (*OpsErrorLogList, error)
|
||||
GetErrorLogByID(ctx context.Context, id int64) (*OpsErrorLogDetail, error)
|
||||
// LookupDeletedKeyAudit 按明文 key 反查最近一条已删除 key 审计;未命中返回 (nil, nil)。
|
||||
LookupDeletedKeyAudit(ctx context.Context, key string) (*DeletedKeyAuditResult, error)
|
||||
ListRequestDetails(ctx context.Context, filter *OpsRequestDetailFilter) ([]*OpsRequestDetail, int64, error)
|
||||
BatchInsertSystemLogs(ctx context.Context, inputs []*OpsInsertSystemLogInput) (int64, error)
|
||||
ListSystemLogs(ctx context.Context, filter *OpsSystemLogFilter) (*OpsSystemLogList, error)
|
||||
@ -61,6 +63,12 @@ type OpsRepository interface {
|
||||
GetLatestDailyBucketDate(ctx context.Context) (time.Time, bool, error)
|
||||
}
|
||||
|
||||
// DeletedKeyAuditResult 是按明文 key 反查 deleted_api_key_audits 的结果。
|
||||
type DeletedKeyAuditResult struct {
|
||||
UserID int64
|
||||
KeyName string
|
||||
}
|
||||
|
||||
type OpsInsertErrorLogInput struct {
|
||||
RequestID string
|
||||
ClientRequestID string
|
||||
@ -118,6 +126,15 @@ type OpsInsertErrorLogInput struct {
|
||||
TimeToFirstTokenMs *int64
|
||||
|
||||
CreatedAt time.Time
|
||||
|
||||
// 已删除 key 归因(仅 INVALID_API_KEY 认证失败时可能非空)
|
||||
AttemptedKeyPrefix string // 提交 key 的脱敏前缀(前 8 位)
|
||||
DeletedKeyOwnerUserID *int64 // 反查命中的原所有者 user_id
|
||||
DeletedKeyName string // 反查命中的 key 名称
|
||||
|
||||
// 有效(未删除)key 报错时快照的 key 脱敏前缀(前 8 位);与 AttemptedKeyPrefix 互斥。
|
||||
// 落库快照而非读时 JOIN:key 之后被删(key 列被 tombstone 覆盖)仍保留当时前缀。
|
||||
APIKeyPrefix string
|
||||
}
|
||||
|
||||
type OpsInsertSystemMetricsInput struct {
|
||||
|
||||
@ -13,6 +13,7 @@ type opsRepoMock struct {
|
||||
ListSystemLogsFn func(ctx context.Context, filter *OpsSystemLogFilter) (*OpsSystemLogList, error)
|
||||
DeleteSystemLogsFn func(ctx context.Context, filter *OpsSystemLogCleanupFilter) (int64, error)
|
||||
InsertSystemLogCleanupAuditFn func(ctx context.Context, input *OpsSystemLogCleanupAudit) error
|
||||
LookupDeletedKeyAuditFn func(ctx context.Context, key string) (*DeletedKeyAuditResult, error)
|
||||
}
|
||||
|
||||
func (m *opsRepoMock) InsertErrorLog(ctx context.Context, input *OpsInsertErrorLogInput) (int64, error) {
|
||||
@ -189,4 +190,11 @@ func (m *opsRepoMock) GetLatestDailyBucketDate(ctx context.Context) (time.Time,
|
||||
return time.Time{}, false, nil
|
||||
}
|
||||
|
||||
func (m *opsRepoMock) LookupDeletedKeyAudit(ctx context.Context, key string) (*DeletedKeyAuditResult, error) {
|
||||
if m.LookupDeletedKeyAuditFn != nil {
|
||||
return m.LookupDeletedKeyAuditFn(ctx, key)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var _ OpsRepository = (*opsRepoMock)(nil)
|
||||
|
||||
@ -355,6 +355,14 @@ func (s *OpsService) GetErrorLogByID(ctx context.Context, id int64) (*OpsErrorLo
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
// LookupDeletedKeyAudit 按明文 key 反查已删除 key 的原所有者;未命中或未启用返回 (nil, nil)。
|
||||
func (s *OpsService) LookupDeletedKeyAudit(ctx context.Context, key string) (*DeletedKeyAuditResult, error) {
|
||||
if s.opsRepo == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return s.opsRepo.LookupDeletedKeyAudit(ctx, key)
|
||||
}
|
||||
|
||||
func (s *OpsService) UpdateErrorResolution(ctx context.Context, errorID int64, resolved bool, resolvedByUserID *int64) error {
|
||||
if err := s.RequireMonitoringEnabled(ctx); err != nil {
|
||||
return err
|
||||
|
||||
22
backend/migrations/145_deleted_api_key_audit.sql
Normal file
22
backend/migrations/145_deleted_api_key_audit.sql
Normal file
@ -0,0 +1,22 @@
|
||||
-- 已删除 API key 审计表:删除 key 时同步留存(明文 key、所有者、key 信息),
|
||||
-- 供认证失败(INVALID_API_KEY)反查"这个失效 key 曾属于谁"。
|
||||
-- 仅对本表上线后删除的 key 生效;此前已删的 key 原值已被 tombstone 覆盖,无法补录。
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
SET LOCAL statement_timeout = '10min';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS deleted_api_key_audits (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
key VARCHAR(128) NOT NULL, -- 原 key 明文(复用 api_keys 策略),非唯一
|
||||
api_key_id BIGINT NOT NULL, -- 原 api_keys.id
|
||||
user_id BIGINT NOT NULL, -- 原所有者(不加外键,与 ops 表设计哲学一致)
|
||||
key_name VARCHAR(100) NOT NULL DEFAULT '', -- 原 key 名称,便于展示
|
||||
deleted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS deletedapikeyaudit_key ON deleted_api_key_audits (key);
|
||||
CREATE INDEX IF NOT EXISTS deletedapikeyaudit_user_id ON deleted_api_key_audits (user_id);
|
||||
|
||||
ALTER TABLE ops_error_logs
|
||||
ADD COLUMN IF NOT EXISTS attempted_key_prefix VARCHAR(32),
|
||||
ADD COLUMN IF NOT EXISTS deleted_key_owner_user_id BIGINT,
|
||||
ADD COLUMN IF NOT EXISTS deleted_key_name VARCHAR(100);
|
||||
12
backend/migrations/147_ops_error_log_api_key_prefix.sql
Normal file
12
backend/migrations/147_ops_error_log_api_key_prefix.sql
Normal file
@ -0,0 +1,12 @@
|
||||
-- 有效(未删除)key 报错时,在 ops 落库层快照该 key 的脱敏前缀(前 8 位),
|
||||
-- 便于在 /admin/ops 错误详情识别是用户的哪一个 key 出的错。
|
||||
-- 与 attempted_key_prefix 互补且互斥:
|
||||
-- api_key_id 非空(有效 key 报错) => api_key_prefix
|
||||
-- api_key_id 为空(INVALID_API_KEY 无效) => attempted_key_prefix
|
||||
-- 落库快照(而非读时 JOIN api_keys):key 之后被删时 api_keys.key 会被 tombstone
|
||||
-- 覆盖,快照可保留报错当时的真实前缀。
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
SET LOCAL statement_timeout = '10min';
|
||||
|
||||
ALTER TABLE ops_error_logs
|
||||
ADD COLUMN IF NOT EXISTS api_key_prefix VARCHAR(32);
|
||||
@ -941,6 +941,15 @@ export interface OpsErrorDetail extends OpsErrorLog {
|
||||
time_to_first_token_ms?: number | null
|
||||
|
||||
is_business_limited: boolean
|
||||
|
||||
// Deleted key owner info (INVALID_API_KEY attribution)
|
||||
attempted_key_prefix?: string | null
|
||||
deleted_key_owner_user_id?: number | null
|
||||
deleted_key_owner_email?: string | null
|
||||
deleted_key_name?: string | null
|
||||
|
||||
// Bound (non-deleted) key prefix, snapshotted at error time
|
||||
api_key_prefix?: string | null
|
||||
}
|
||||
|
||||
export type OpsErrorLogsResponse = PaginatedResponse<OpsErrorLog>
|
||||
|
||||
@ -4883,7 +4883,11 @@ export default {
|
||||
suggestRequest: 'Client request error: ask customer to fix request parameters',
|
||||
suggestAuth: 'Auth failed: verify API key/credentials',
|
||||
suggestPlatform: 'Platform error: prioritize investigation and fix',
|
||||
suggestGeneric: 'See details for more context'
|
||||
suggestGeneric: 'See details for more context',
|
||||
apiKeyPrefix: 'Key Prefix',
|
||||
attemptedKeyPrefix: 'Attempted Key Prefix',
|
||||
deletedKeyOwner: 'Deleted Key Owner',
|
||||
keyDeletedBadge: 'Key Deleted'
|
||||
},
|
||||
requestDetails: {
|
||||
title: 'Request Details',
|
||||
|
||||
@ -5042,7 +5042,11 @@ export default {
|
||||
suggestRequest: '⚠️ 客户端请求错误,建议:联系客户修正请求参数 / 手动标记已解决',
|
||||
suggestAuth: '⚠️ 认证失败,建议:检查 API Key 是否有效 / 联系客户更新凭证',
|
||||
suggestPlatform: '🚨 平台错误,建议立即排查修复',
|
||||
suggestGeneric: '查看详情了解更多信息'
|
||||
suggestGeneric: '查看详情了解更多信息',
|
||||
apiKeyPrefix: 'Key 前缀',
|
||||
attemptedKeyPrefix: '尝试的 Key 前缀',
|
||||
deletedKeyOwner: '已删除 Key 所有者',
|
||||
keyDeletedBadge: 'Key 已删除'
|
||||
},
|
||||
requestDetails: {
|
||||
title: '请求明细',
|
||||
|
||||
@ -106,6 +106,31 @@
|
||||
{{ detail.message || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="detail.api_key_prefix" class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
|
||||
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.errorDetail.apiKeyPrefix') }}</div>
|
||||
<div class="mt-1 font-mono text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ detail.api_key_prefix }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="detail.attempted_key_prefix" class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
|
||||
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.errorDetail.attemptedKeyPrefix') }}</div>
|
||||
<div class="mt-1 font-mono text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ detail.attempted_key_prefix }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="detail.deleted_key_owner_email" class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
|
||||
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.errorDetail.deletedKeyOwner') }}</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ detail.deleted_key_owner_email }}
|
||||
<span v-if="detail.deleted_key_name" class="ml-1 text-xs text-gray-500 dark:text-gray-400">({{ detail.deleted_key_name }})</span>
|
||||
<span class="ml-2 inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold ring-1 ring-inset bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-900/30 dark:text-red-400 dark:ring-red-500/30">
|
||||
{{ t('admin.ops.errorDetail.keyDeletedBadge') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Response content (client request -> error_body; upstream -> upstream_error_detail/message) -->
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user