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:
DaydreamCoding 2026-06-01 16:10:35 +08:00 committed by QTom
parent 57bae985a7
commit ddf063352a
29 changed files with 845 additions and 17 deletions

View File

@ -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

View 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)
}
})
}
}

View File

@ -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")
}

View File

@ -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))

View File

@ -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)
}

View File

@ -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")

View File

@ -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")
}

View File

@ -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)
}

View File

@ -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 {

View File

@ -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))

View File

@ -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

View File

@ -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")
}

View File

@ -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")
}

View File

@ -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 返回设置强制平台的中间件

View File

@ -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")
}

View File

@ -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

View File

@ -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")
}

View File

@ -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 缓存未被清除
}

View File

@ -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")
}

View File

@ -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 {

View File

@ -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 {

View File

@ -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)

View File

@ -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

View 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);

View 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);

View File

@ -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>

View File

@ -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',

View File

@ -5042,7 +5042,11 @@ export default {
suggestRequest: '⚠️ 客户端请求错误,建议:联系客户修正请求参数 / 手动标记已解决',
suggestAuth: '⚠️ 认证失败,建议:检查 API Key 是否有效 / 联系客户更新凭证',
suggestPlatform: '🚨 平台错误,建议立即排查修复',
suggestGeneric: '查看详情了解更多信息'
suggestGeneric: '查看详情了解更多信息',
apiKeyPrefix: 'Key 前缀',
attemptedKeyPrefix: '尝试的 Key 前缀',
deletedKeyOwner: '已删除 Key 所有者',
keyDeletedBadge: 'Key 已删除'
},
requestDetails: {
title: '请求明细',

View File

@ -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) -->