Merge pull request #3042 from DaydreamCoding/feat/usage-error-requests

feat(usage/ops): 失败请求记录与展示(用户端 + 管理端)+ 错误日志 Key 归因
This commit is contained in:
Wesley Liddick 2026-06-05 15:42:25 +08:00 committed by GitHub
commit d895d765b6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
63 changed files with 2845 additions and 127 deletions

View File

@ -91,36 +91,15 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
usageLogRepository := repository.NewUsageLogRepository(client, db)
usageService := service.NewUsageService(usageLogRepository, userRepository, client, apiKeyAuthCacheInvalidator)
usageHandler := handler.NewUsageHandler(usageService, apiKeyService)
redeemHandler := handler.NewRedeemHandler(redeemService)
subscriptionHandler := handler.NewSubscriptionHandler(subscriptionService)
announcementRepository := repository.NewAnnouncementRepository(client)
announcementReadRepository := repository.NewAnnouncementReadRepository(client)
announcementService := service.NewAnnouncementService(announcementRepository, announcementReadRepository, userRepository, userSubscriptionRepository)
announcementHandler := handler.NewAnnouncementHandler(announcementService)
channelMonitorRepository := repository.NewChannelMonitorRepository(client, db)
channelMonitorService := service.ProvideChannelMonitorService(channelMonitorRepository, secretEncryptor)
channelMonitorUserHandler := handler.NewChannelMonitorUserHandler(channelMonitorService, settingService)
dashboardAggregationRepository := repository.NewDashboardAggregationRepository(db)
dashboardStatsCache := repository.NewDashboardCache(redisClient, configConfig)
dashboardService := service.NewDashboardService(usageLogRepository, dashboardAggregationRepository, dashboardStatsCache, configConfig)
timingWheelService, err := service.ProvideTimingWheelService()
if err != nil {
return nil, err
}
dashboardAggregationService := service.ProvideDashboardAggregationService(dashboardAggregationRepository, timingWheelService, configConfig)
dashboardHandler := admin.NewDashboardHandler(dashboardService, dashboardAggregationService)
opsRepository := repository.NewOpsRepository(db)
schedulerCache := repository.ProvideSchedulerCache(redisClient, configConfig)
accountRepository := repository.NewAccountRepository(client, db, schedulerCache)
proxyExitInfoProber := repository.NewProxyExitInfoProber(configConfig)
proxyLatencyCache := repository.NewProxyLatencyCache(redisClient)
privacyClientFactory := providePrivacyClientFactory()
concurrencyCache := repository.ProvideConcurrencyCache(redisClient, configConfig)
concurrencyService := service.ProvideConcurrencyService(concurrencyCache, accountRepository, configConfig)
usageBillingRepository := repository.NewUsageBillingRepository(client, db)
gatewayCache := repository.NewGatewayCache(redisClient)
schedulerOutboxRepository := repository.NewSchedulerOutboxRepository(db)
schedulerSnapshotService := service.ProvideSchedulerSnapshotService(schedulerCache, schedulerOutboxRepository, accountRepository, groupRepository, configConfig)
concurrencyCache := repository.ProvideConcurrencyCache(redisClient, configConfig)
concurrencyService := service.ProvideConcurrencyService(concurrencyCache, accountRepository, configConfig)
pricingRemoteClient := repository.ProvidePricingRemoteClient(configConfig)
pricingService, err := service.ProvidePricingService(configConfig, pricingRemoteClient)
if err != nil {
@ -134,44 +113,72 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
geminiTokenCache := repository.NewGeminiTokenCache(redisClient)
compositeTokenCacheInvalidator := service.NewCompositeTokenCacheInvalidator(geminiTokenCache)
rateLimitService := service.ProvideRateLimitService(accountRepository, usageLogRepository, configConfig, geminiQuotaService, tempUnschedCache, timeoutCounterCache, openAI403CounterCache, settingService, compositeTokenCacheInvalidator)
identityCache := repository.NewIdentityCache(redisClient)
identityService := service.NewIdentityService(identityCache)
httpUpstream := repository.NewHTTPUpstream(configConfig)
timingWheelService, err := service.ProvideTimingWheelService()
if err != nil {
return nil, err
}
deferredService := service.ProvideDeferredService(accountRepository, timingWheelService)
openAIOAuthClient := repository.NewOpenAIOAuthClient()
openAIOAuthService := service.ProvideOpenAIOAuthService(proxyRepository, openAIOAuthClient, privacyClientFactory)
claudeOAuthClient := repository.NewClaudeOAuthClient()
oAuthService := service.NewOAuthService(proxyRepository, claudeOAuthClient)
oAuthRefreshAPI := service.ProvideOAuthRefreshAPI(accountRepository, geminiTokenCache)
openAITokenProvider := service.ProvideOpenAITokenProvider(accountRepository, geminiTokenCache, openAIOAuthService, oAuthRefreshAPI)
claudeTokenProvider := service.ProvideClaudeTokenProvider(accountRepository, geminiTokenCache, oAuthService, oAuthRefreshAPI)
sessionLimitCache := repository.ProvideSessionLimitCache(redisClient, configConfig)
rpmCache := repository.NewRPMCache(redisClient)
digestSessionStore := service.NewDigestSessionStore()
tlsFingerprintProfileRepository := repository.NewTLSFingerprintProfileRepository(client)
tlsFingerprintProfileCache := repository.NewTLSFingerprintProfileCache(redisClient)
tlsFingerprintProfileService := service.NewTLSFingerprintProfileService(tlsFingerprintProfileRepository, tlsFingerprintProfileCache)
channelRepository := repository.NewChannelRepository(db)
channelService := service.NewChannelService(channelRepository, groupRepository, apiKeyAuthCacheInvalidator, pricingService)
modelPricingResolver := service.NewModelPricingResolver(channelService, billingService)
notificationEmailService := service.NewNotificationEmailService(settingRepository, emailService)
balanceNotifyService := service.ProvideBalanceNotifyService(emailService, settingRepository, accountRepository, notificationEmailService)
gatewayService := service.NewGatewayService(accountRepository, groupRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService, claudeTokenProvider, sessionLimitCache, rpmCache, digestSessionStore, settingService, tlsFingerprintProfileService, channelService, modelPricingResolver, balanceNotifyService, serviceUserPlatformQuotaRepository)
openAIOAuthClient := repository.NewOpenAIOAuthClient()
privacyClientFactory := providePrivacyClientFactory()
openAIOAuthService := service.ProvideOpenAIOAuthService(proxyRepository, openAIOAuthClient, privacyClientFactory)
openAITokenProvider := service.ProvideOpenAITokenProvider(accountRepository, geminiTokenCache, openAIOAuthService, oAuthRefreshAPI)
openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService, openAITokenProvider, modelPricingResolver, channelService, balanceNotifyService, settingService, serviceUserPlatformQuotaRepository)
adminService := service.NewAdminService(userRepository, groupRepository, accountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, userRPMCache, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository, privacyClientFactory, openAIGatewayService)
adminUserHandler := admin.NewUserHandler(adminService, concurrencyService, serviceUserPlatformQuotaRepository, billingCache)
sessionLimitCache := repository.ProvideSessionLimitCache(redisClient, configConfig)
rpmCache := repository.NewRPMCache(redisClient)
groupCapacityService := service.NewGroupCapacityService(accountRepository, groupRepository, concurrencyService, sessionLimitCache, rpmCache)
groupHandler := admin.NewGroupHandler(adminService, dashboardService, groupCapacityService)
claudeOAuthClient := repository.NewClaudeOAuthClient()
oAuthService := service.NewOAuthService(proxyRepository, claudeOAuthClient)
geminiOAuthClient := repository.NewGeminiOAuthClient(configConfig)
geminiCliCodeAssistClient := repository.NewGeminiCliCodeAssistClient()
driveClient := repository.NewGeminiDriveClient()
geminiOAuthService := service.NewGeminiOAuthService(proxyRepository, geminiOAuthClient, geminiCliCodeAssistClient, driveClient, configConfig)
antigravityOAuthService := service.NewAntigravityOAuthService(proxyRepository)
claudeUsageFetcher := repository.NewClaudeUsageFetcher(httpUpstream)
antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository)
usageCache := service.NewUsageCache()
identityCache := repository.NewIdentityCache(redisClient)
tlsFingerprintProfileRepository := repository.NewTLSFingerprintProfileRepository(client)
tlsFingerprintProfileCache := repository.NewTLSFingerprintProfileCache(redisClient)
tlsFingerprintProfileService := service.NewTLSFingerprintProfileService(tlsFingerprintProfileRepository, tlsFingerprintProfileCache)
accountUsageService := service.NewAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, usageCache, identityCache, tlsFingerprintProfileService)
geminiTokenProvider := service.ProvideGeminiTokenProvider(accountRepository, geminiTokenCache, geminiOAuthService, oAuthRefreshAPI)
claudeTokenProvider := service.ProvideClaudeTokenProvider(accountRepository, geminiTokenCache, oAuthService, oAuthRefreshAPI)
antigravityOAuthService := service.NewAntigravityOAuthService(proxyRepository)
antigravityTokenProvider := service.ProvideAntigravityTokenProvider(accountRepository, geminiTokenCache, antigravityOAuthService, oAuthRefreshAPI, tempUnschedCache)
internal500CounterCache := repository.NewInternal500CounterCache(redisClient)
antigravityGatewayService := service.NewAntigravityGatewayService(accountRepository, gatewayCache, schedulerSnapshotService, antigravityTokenProvider, rateLimitService, httpUpstream, settingService, internal500CounterCache)
geminiMessagesCompatService := service.NewGeminiMessagesCompatService(accountRepository, groupRepository, gatewayCache, schedulerSnapshotService, geminiTokenProvider, rateLimitService, httpUpstream, antigravityGatewayService, configConfig)
opsSystemLogSink := service.ProvideOpsSystemLogSink(opsRepository)
opsService := service.ProvideOpsService(opsRepository, settingRepository, configConfig, accountRepository, userRepository, concurrencyService, gatewayService, openAIGatewayService, geminiMessagesCompatService, antigravityGatewayService, opsSystemLogSink, settingService)
usageHandler := handler.NewUsageHandler(usageService, apiKeyService, opsService, settingService)
redeemHandler := handler.NewRedeemHandler(redeemService)
subscriptionHandler := handler.NewSubscriptionHandler(subscriptionService)
announcementRepository := repository.NewAnnouncementRepository(client)
announcementReadRepository := repository.NewAnnouncementReadRepository(client)
announcementService := service.NewAnnouncementService(announcementRepository, announcementReadRepository, userRepository, userSubscriptionRepository)
announcementHandler := handler.NewAnnouncementHandler(announcementService)
channelMonitorRepository := repository.NewChannelMonitorRepository(client, db)
channelMonitorService := service.ProvideChannelMonitorService(channelMonitorRepository, secretEncryptor)
channelMonitorUserHandler := handler.NewChannelMonitorUserHandler(channelMonitorService, settingService)
dashboardAggregationRepository := repository.NewDashboardAggregationRepository(db)
dashboardStatsCache := repository.NewDashboardCache(redisClient, configConfig)
dashboardService := service.NewDashboardService(usageLogRepository, dashboardAggregationRepository, dashboardStatsCache, configConfig)
dashboardAggregationService := service.ProvideDashboardAggregationService(dashboardAggregationRepository, timingWheelService, configConfig)
dashboardHandler := admin.NewDashboardHandler(dashboardService, dashboardAggregationService)
proxyExitInfoProber := repository.NewProxyExitInfoProber(configConfig)
proxyLatencyCache := repository.NewProxyLatencyCache(redisClient)
adminService := service.NewAdminService(userRepository, groupRepository, accountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, userRPMCache, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository, privacyClientFactory, openAIGatewayService)
adminUserHandler := admin.NewUserHandler(adminService, concurrencyService, serviceUserPlatformQuotaRepository, billingCache)
groupCapacityService := service.NewGroupCapacityService(accountRepository, groupRepository, concurrencyService, sessionLimitCache, rpmCache)
groupHandler := admin.NewGroupHandler(adminService, dashboardService, groupCapacityService)
claudeUsageFetcher := repository.NewClaudeUsageFetcher(httpUpstream)
antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository)
usageCache := service.NewUsageCache()
accountUsageService := service.NewAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, usageCache, identityCache, tlsFingerprintProfileService)
accountTestService := service.NewAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService)
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig)
accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator)
@ -189,13 +196,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
proxyHandler := admin.NewProxyHandler(adminService)
adminRedeemHandler := admin.NewRedeemHandler(adminService, redeemService)
promoHandler := admin.NewPromoHandler(promoService)
opsRepository := repository.NewOpsRepository(db)
identityService := service.NewIdentityService(identityCache)
digestSessionStore := service.NewDigestSessionStore()
gatewayService := service.NewGatewayService(accountRepository, groupRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService, claudeTokenProvider, sessionLimitCache, rpmCache, digestSessionStore, settingService, tlsFingerprintProfileService, channelService, modelPricingResolver, balanceNotifyService, serviceUserPlatformQuotaRepository)
geminiMessagesCompatService := service.NewGeminiMessagesCompatService(accountRepository, groupRepository, gatewayCache, schedulerSnapshotService, geminiTokenProvider, rateLimitService, httpUpstream, antigravityGatewayService, configConfig)
opsSystemLogSink := service.ProvideOpsSystemLogSink(opsRepository)
opsService := service.ProvideOpsService(opsRepository, settingRepository, configConfig, accountRepository, userRepository, concurrencyService, gatewayService, openAIGatewayService, geminiMessagesCompatService, antigravityGatewayService, opsSystemLogSink, settingService)
encryptionKey, err := payment.ProvideEncryptionKey(configConfig)
if err != nil {
return nil, err

View File

@ -110,6 +110,9 @@ func (h *OpsHandler) GetErrorLogs(c *gin.Context) {
filter.Source = strings.TrimSpace(c.Query("error_source"))
filter.Query = strings.TrimSpace(c.Query("q"))
filter.UserQuery = strings.TrimSpace(c.Query("user_query"))
// Model 过滤admin 走精确匹配ModelFuzzy 默认 false保持管理端语义
// buildOpsErrorLogsWhere 以 COALESCE(requested_model, model) 比对。
filter.Model = strings.TrimSpace(c.Query("model"))
// Force request errors: client-visible status >= 400.
// buildOpsErrorLogsWhere already applies this for non-upstream phase.
@ -137,6 +140,22 @@ func (h *OpsHandler) GetErrorLogs(c *gin.Context) {
filter.AccountID = &id
}
if v := strings.TrimSpace(c.Query("user_id")); v != "" {
id, err := strconv.ParseInt(v, 10, 64)
if err != nil || id <= 0 {
response.BadRequest(c, "Invalid user_id")
return
}
filter.UserID = &id
}
if v := strings.TrimSpace(c.Query("api_key_id")); v != "" {
id, err := strconv.ParseInt(v, 10, 64)
if err != nil || id <= 0 {
response.BadRequest(c, "Invalid api_key_id")
return
}
filter.APIKeyID = &id
}
if v := strings.TrimSpace(c.Query("resolved")); v != "" {
switch strings.ToLower(v) {
case "1", "true", "yes":
@ -211,6 +230,9 @@ func (h *OpsHandler) ListRequestErrors(c *gin.Context) {
filter.Source = strings.TrimSpace(c.Query("error_source"))
filter.Query = strings.TrimSpace(c.Query("q"))
filter.UserQuery = strings.TrimSpace(c.Query("user_query"))
// Model 过滤admin 走精确匹配ModelFuzzy 默认 false保持管理端语义
// buildOpsErrorLogsWhere 以 COALESCE(requested_model, model) 比对。
filter.Model = strings.TrimSpace(c.Query("model"))
// Force request errors: client-visible status >= 400.
// buildOpsErrorLogsWhere already applies this for non-upstream phase.

View File

@ -297,6 +297,8 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
AffiliateEnabled: settings.AffiliateEnabled,
AllowUserViewErrorRequests: settings.AllowUserViewErrorRequests,
}
// OpenAI fast policy (stored under a dedicated setting key)
@ -658,6 +660,8 @@ type UpdateSettingsRequest struct {
AuthSourceGitHubPlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"auth_source_default_github_platform_quotas"`
AuthSourceGooglePlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"auth_source_default_google_platform_quotas"`
AuthSourceDingTalkPlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"auth_source_default_dingtalk_platform_quotas"`
AllowUserViewErrorRequests *bool `json:"allow_user_view_error_requests"`
}
// UpdateSettings 更新系统设置
@ -1591,6 +1595,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
MaxClaudeCodeVersion: req.MaxClaudeCodeVersion,
AllowUngroupedKeyScheduling: req.AllowUngroupedKeyScheduling,
BackendModeEnabled: req.BackendModeEnabled,
AllowUserViewErrorRequests: func() bool {
if req.AllowUserViewErrorRequests != nil {
return *req.AllowUserViewErrorRequests
}
return previousSettings.AllowUserViewErrorRequests
}(),
OpsMonitoringEnabled: func() bool {
if req.OpsMonitoringEnabled != nil {
return *req.OpsMonitoringEnabled
@ -2080,7 +2090,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
AffiliateEnabled: updatedSettings.AffiliateEnabled,
RiskControlEnabled: updatedSettings.RiskControlEnabled,
RiskControlEnabled: updatedSettings.RiskControlEnabled,
AllowUserViewErrorRequests: updatedSettings.AllowUserViewErrorRequests,
}
if fastPolicy, err := h.settingService.GetOpenAIFastPolicySettings(c.Request.Context()); err != nil {
slog.Error("openai_fast_policy_settings_get_failed", "error", err)

View File

@ -252,6 +252,9 @@ type SystemSettings struct {
// 系统全局默认平台配额key = platformnil/缺省 = 不限制)
DefaultPlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"default_platform_quotas,omitempty"`
// 允许终端用户在用量页查看自己的失败请求
AllowUserViewErrorRequests bool `json:"allow_user_view_error_requests"`
}
type DefaultSubscriptionSetting struct {
@ -316,6 +319,8 @@ type PublicSettings struct {
AffiliateEnabled bool `json:"affiliate_enabled"`
RiskControlEnabled bool `json:"risk_control_enabled"`
AllowUserViewErrorRequests bool `json:"allow_user_view_error_requests"`
}
type LoginAgreementDocument struct {

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

@ -98,6 +98,8 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
AffiliateEnabled: settings.AffiliateEnabled,
RiskControlEnabled: settings.RiskControlEnabled,
AllowUserViewErrorRequests: settings.AllowUserViewErrorRequests,
})
}

View File

@ -1,6 +1,7 @@
package handler
import (
"net/http"
"strconv"
"strings"
"time"
@ -18,15 +19,24 @@ import (
// UsageHandler handles usage-related requests
type UsageHandler struct {
usageService *service.UsageService
apiKeyService *service.APIKeyService
usageService *service.UsageService
apiKeyService *service.APIKeyService
opsService *service.OpsService
settingService *service.SettingService
}
// NewUsageHandler creates a new UsageHandler
func NewUsageHandler(usageService *service.UsageService, apiKeyService *service.APIKeyService) *UsageHandler {
func NewUsageHandler(
usageService *service.UsageService,
apiKeyService *service.APIKeyService,
opsService *service.OpsService,
settingService *service.SettingService,
) *UsageHandler {
return &UsageHandler{
usageService: usageService,
apiKeyService: apiKeyService,
usageService: usageService,
apiKeyService: apiKeyService,
opsService: opsService,
settingService: settingService,
}
}
@ -149,6 +159,117 @@ func (h *UsageHandler) List(c *gin.Context) {
response.Paginated(c, out, result.Total, page, pageSize)
}
// ListErrors handles listing the current user's failed requests (redacted).
// GET /api/v1/usage/errors
func (h *UsageHandler) ListErrors(c *gin.Context) {
subject, ok := middleware2.GetAuthSubjectFromContext(c)
if !ok {
response.Unauthorized(c, "User not authenticated")
return
}
// Visibility switch (fail-closed). Defense-in-depth: frontend also hides the tab.
if h.settingService == nil || !h.settingService.IsUserErrorViewAllowed(c.Request.Context()) {
response.Forbidden(c, "Error requests view is disabled")
return
}
if h.opsService == nil {
response.Error(c, http.StatusServiceUnavailable, "Ops service not available")
return
}
page, pageSize := response.ParsePagination(c)
if pageSize > 100 {
pageSize = 100
}
filter := &service.OpsErrorLogFilter{Page: page, PageSize: pageSize}
// Date range (half-open [start, end)), reuse usage-list semantics.
userTZ := c.Query("timezone")
if startDateStr := c.Query("start_date"); startDateStr != "" {
t, err := timezone.ParseInUserLocation("2006-01-02", startDateStr, userTZ)
if err != nil {
response.BadRequest(c, "Invalid start_date format, use YYYY-MM-DD")
return
}
filter.StartTime = &t
}
if endDateStr := c.Query("end_date"); endDateStr != "" {
t, err := timezone.ParseInUserLocation("2006-01-02", endDateStr, userTZ)
if err != nil {
response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD")
return
}
t = t.AddDate(0, 0, 1)
filter.EndTime = &t
}
filter.Model = strings.TrimSpace(c.Query("model"))
if k := strings.TrimSpace(c.Query("api_key_id")); k != "" {
n, err := strconv.ParseInt(k, 10, 64)
if err != nil || n < 0 {
response.BadRequest(c, "Invalid api_key_id")
return
}
if n > 0 {
filter.APIKeyID = &n
}
}
if sc := strings.TrimSpace(c.Query("status_code")); sc != "" {
n, err := strconv.Atoi(sc)
if err != nil || n < 0 {
response.BadRequest(c, "Invalid status_code")
return
}
filter.StatusCodes = []int{n}
}
if cat := strings.TrimSpace(c.Query("category")); cat != "" {
phases, types := service.CategoryToFilter(cat)
filter.ErrorPhasesAny = phases
filter.ErrorTypesAny = types
}
result, err := h.opsService.ListUserErrorRequests(c.Request.Context(), subject.UserID, filter)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Paginated(c, result.Items, int64(result.Total), result.Page, result.PageSize)
}
// GetErrorDetail handles fetching one of the current user's failed-request details (redacted).
// GET /api/v1/usage/errors/:id
func (h *UsageHandler) GetErrorDetail(c *gin.Context) {
subject, ok := middleware2.GetAuthSubjectFromContext(c)
if !ok {
response.Unauthorized(c, "User not authenticated")
return
}
if h.settingService == nil || !h.settingService.IsUserErrorViewAllowed(c.Request.Context()) {
response.Forbidden(c, "Error requests view is disabled")
return
}
if h.opsService == nil {
response.Error(c, http.StatusServiceUnavailable, "Ops service not available")
return
}
id, err := strconv.ParseInt(strings.TrimSpace(c.Param("id")), 10, 64)
if err != nil || id <= 0 {
response.BadRequest(c, "Invalid id")
return
}
detail, err := h.opsService.GetUserErrorRequestDetail(c.Request.Context(), subject.UserID, id)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, detail)
}
// GetByID handles getting a single usage record
// GET /api/v1/usage/:id
func (h *UsageHandler) GetByID(c *gin.Context) {

View File

@ -64,7 +64,7 @@ func newDailyUsageTestRouter(usageRepo *dailyUsageRepoStub, apiKeyRepo *dailyUsa
gin.SetMode(gin.TestMode)
usageSvc := service.NewUsageService(usageRepo, nil, nil, nil)
apiKeySvc := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, nil, nil)
handler := NewUsageHandler(usageSvc, apiKeySvc)
handler := NewUsageHandler(usageSvc, apiKeySvc, nil, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: userID})

View File

@ -34,7 +34,7 @@ func (s *userUsageRepoCapture) ListWithFilters(ctx context.Context, params pagin
func newUserUsageRequestTypeTestRouter(repo *userUsageRepoCapture) *gin.Engine {
gin.SetMode(gin.TestMode)
usageSvc := service.NewUsageService(repo, nil, nil, nil)
handler := NewUsageHandler(usageSvc, nil)
handler := NewUsageHandler(usageSvc, nil, nil, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 42})

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

@ -0,0 +1,95 @@
package repository
import (
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
)
func TestBuildOpsErrorLogsWhere_UserScopedFilters(t *testing.T) {
uid := int64(42)
kid := int64(7)
filter := &service.OpsErrorLogFilter{
UserID: &uid,
APIKeyID: &kid,
Model: "claude-sonnet-4-5",
ExcludeCountTokens: true,
ErrorPhasesAny: []string{"auth"},
ErrorTypesAny: []string{"rate_limit_error"},
View: "all",
}
where, args := buildOpsErrorLogsWhere(filter)
for _, want := range []string{
"e.user_id = $",
"e.api_key_id = $",
"COALESCE(e.requested_model, e.model, '') = $",
"COALESCE(e.is_count_tokens, false) = false",
"e.error_phase = ANY($",
"e.error_type = ANY($",
} {
if !strings.Contains(where, want) {
t.Fatalf("where missing %q\nfull: %s", want, where)
}
}
if len(args) != 5 {
t.Fatalf("expected 5 args, got %d", len(args))
}
}
func TestBuildOpsErrorLogsWhere_ModelFuzzy(t *testing.T) {
// 默认ModelFuzzy=false保持精确匹配
exact := &service.OpsErrorLogFilter{Model: "claude"}
whereExact, _ := buildOpsErrorLogsWhere(exact)
if !strings.Contains(whereExact, "COALESCE(e.requested_model, e.model, '') = $") {
t.Fatalf("default should be exact match, got: %s", whereExact)
}
// ModelFuzzy=true → ILIKE
fuzzy := &service.OpsErrorLogFilter{Model: "claude", ModelFuzzy: true}
whereFuzzy, args := buildOpsErrorLogsWhere(fuzzy)
if !strings.Contains(whereFuzzy, "COALESCE(e.requested_model, e.model, '') ILIKE $") {
t.Fatalf("ModelFuzzy should use ILIKE, got: %s", whereFuzzy)
}
if len(args) != 1 || args[0] != "%claude%" {
t.Fatalf("expected arg \"%%claude%%\", got %v", args)
}
// 通配符转义:输入含 % 应被转义为字面量
esc := &service.OpsErrorLogFilter{Model: "50%off", ModelFuzzy: true}
_, escArgs := buildOpsErrorLogsWhere(esc)
if len(escArgs) != 1 || escArgs[0] != `%50\%off%` {
t.Fatalf("expected escaped arg, got %v", escArgs)
}
esc2 := &service.OpsErrorLogFilter{Model: "gpt_4o", ModelFuzzy: true}
_, escArgs2 := buildOpsErrorLogsWhere(esc2)
if len(escArgs2) != 1 || escArgs2[0] != `%gpt\_4o%` {
t.Fatalf("underscore should be escaped, got %v", escArgs2)
}
}
func TestBuildOpsErrorLogsWhere_MatchDeletedKeyOwner(t *testing.T) {
uid := int64(42)
// 开关开启 → 归属放宽为 OR(user_id 或 deleted_key_owner_user_id),且共用同一占位符
on := &service.OpsErrorLogFilter{UserID: &uid, MatchDeletedKeyOwner: true}
whereOn, argsOn := buildOpsErrorLogsWhere(on)
if !strings.Contains(whereOn, "(e.user_id = $1 OR e.deleted_key_owner_user_id = $1)") {
t.Fatalf("MatchDeletedKeyOwner=true should widen to OR, got: %s", whereOn)
}
if len(argsOn) != 1 || argsOn[0] != uid {
t.Fatalf("expected single reused arg %d, got %v", uid, argsOn)
}
// 开关关闭(默认)→ 仅精确 user_id,绝不出现 deleted_key_owner_user_id(admin 回归)
off := &service.OpsErrorLogFilter{UserID: &uid}
whereOff, _ := buildOpsErrorLogsWhere(off)
if !strings.Contains(whereOff, "e.user_id = $1") {
t.Fatalf("default should match user_id exactly, got: %s", whereOff)
}
if strings.Contains(whereOff, "deleted_key_owner_user_id") {
t.Fatalf("default must NOT include deleted_key_owner_user_id, got: %s", whereOff)
}
}

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),
}
}
@ -231,12 +240,16 @@ SELECT
COALESCE(e.upstream_endpoint, ''),
COALESCE(e.requested_model, ''),
COALESCE(e.upstream_model, ''),
e.request_type
e.request_type,
COALESCE(ak.name, ''),
ak.deleted_at,
COALESCE(e.deleted_key_name, '')
FROM ops_error_logs e
LEFT JOIN accounts a ON e.account_id = a.id
LEFT JOIN groups g ON e.group_id = g.id
LEFT JOIN users u ON e.user_id = u.id
LEFT JOIN users u2 ON e.resolved_by_user_id = u2.id
LEFT JOIN api_keys ak ON ak.id = e.api_key_id
` + where + `
ORDER BY e.created_at DESC
LIMIT $` + itoa(len(args)+1) + ` OFFSET $` + itoa(len(args)+2)
@ -263,6 +276,9 @@ LIMIT $` + itoa(len(args)+1) + ` OFFSET $` + itoa(len(args)+2)
var resolvedBy sql.NullInt64
var resolvedByName string
var requestType sql.NullInt64
var apiKeyName string
var apiKeyDeletedAt sql.NullTime
var deletedKeyName string
if err := rows.Scan(
&item.ID,
&item.CreatedAt,
@ -296,6 +312,9 @@ LIMIT $` + itoa(len(args)+1) + ` OFFSET $` + itoa(len(args)+2)
&item.RequestedModel,
&item.UpstreamModel,
&requestType,
&apiKeyName,
&apiKeyDeletedAt,
&deletedKeyName,
); err != nil {
return nil, err
}
@ -336,6 +355,15 @@ LIMIT $` + itoa(len(args)+1) + ` OFFSET $` + itoa(len(args)+2)
v := int16(requestType.Int64)
item.RequestType = &v
}
// Key 名称:优先关联到的 ak.name已软删的 key name 仍保留);
// 关联不到api_key_id 为空 / 历史硬删)时回退错误记录里快照的 deleted_key_name。
if apiKeyName != "" {
item.APIKeyName = apiKeyName
} else {
item.APIKeyName = deletedKeyName
}
// 已删除ak.deleted_at 非空(软删),或仅命中 deleted_key_name 兜底。
item.APIKeyDeleted = apiKeyDeletedAt.Valid || (apiKeyName == "" && deletedKeyName != "")
out = append(out, &item)
}
if err := rows.Err(); err != nil {
@ -402,11 +430,20 @@ 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, ''),
COALESCE(ak.name, ''),
ak.deleted_at
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
LEFT JOIN api_keys ak ON ak.id = e.api_key_id
WHERE e.id = $1
LIMIT 1`
@ -426,6 +463,9 @@ LIMIT 1`
var responseLatency sql.NullInt64
var ttft sql.NullInt64
var requestType sql.NullInt64
var deletedKeyOwnerUserID sql.NullInt64
var detailAPIKeyName string
var detailAPIKeyDeletedAt sql.NullTime
err := r.db.QueryRowContext(ctx, q, id).Scan(
&out.ID,
@ -471,6 +511,13 @@ LIMIT 1`
&upstreamLatency,
&responseLatency,
&ttft,
&out.AttemptedKeyPrefix,
&deletedKeyOwnerUserID,
&out.DeletedKeyOwnerEmail,
&out.DeletedKeyName,
&out.APIKeyPrefix,
&detailAPIKeyName,
&detailAPIKeyDeletedAt,
)
if err != nil {
return nil, err
@ -533,6 +580,18 @@ LIMIT 1`
v := int16(requestType.Int64)
out.RequestType = &v
}
if deletedKeyOwnerUserID.Valid {
v := deletedKeyOwnerUserID.Int64
out.DeletedKeyOwnerUserID = &v
}
// Key 名称:优先关联到的 ak.name关联不到时回退快照的 deleted_key_name。
if detailAPIKeyName != "" {
out.APIKeyName = detailAPIKeyName
} else {
out.APIKeyName = out.DeletedKeyName
}
// 已删除ak.deleted_at 非空(软删),或仅命中 deleted_key_name 兜底。
out.APIKeyDeleted = detailAPIKeyDeletedAt.Valid || (detailAPIKeyName == "" && out.DeletedKeyName != "")
// Normalize upstream_errors to empty string when stored as JSON null.
out.UpstreamErrors = strings.TrimSpace(out.UpstreamErrors)
@ -543,6 +602,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")
@ -815,6 +894,14 @@ INSERT INTO ops_system_log_cleanup_audits (
return err
}
var likePatternReplacer = strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
// escapeLikePattern 转义 LIKE/ILIKE 通配符(\ % _避免用户输入被当作通配符。
// Postgres 默认以反斜杠为转义符,无需额外 ESCAPE 子句。
func escapeLikePattern(s string) string {
return likePatternReplacer.Replace(s)
}
func buildOpsErrorLogsWhere(filter *service.OpsErrorLogFilter) (string, []any) {
clauses := make([]string, 0, 12)
args := make([]any, 0, 12)
@ -927,6 +1014,41 @@ func buildOpsErrorLogsWhere(filter *service.OpsErrorLogFilter) (string, []any) {
clauses = append(clauses, "EXISTS (SELECT 1 FROM users u WHERE u.id = e.user_id AND u.email ILIKE $"+n+")")
}
if filter.UserID != nil && *filter.UserID > 0 {
args = append(args, *filter.UserID)
n := itoa(len(args))
if filter.MatchDeletedKeyOwner {
// 用户侧:把「删 key 后认证失败」(user_id=NULL,靠 deleted_key_owner 归因)的记录也纳入。
clauses = append(clauses, "(e.user_id = $"+n+" OR e.deleted_key_owner_user_id = $"+n+")")
} else {
clauses = append(clauses, "e.user_id = $"+n)
}
}
if filter.APIKeyID != nil && *filter.APIKeyID > 0 {
args = append(args, *filter.APIKeyID)
clauses = append(clauses, "e.api_key_id = $"+itoa(len(args)))
}
if m := strings.TrimSpace(filter.Model); m != "" {
if filter.ModelFuzzy {
args = append(args, "%"+escapeLikePattern(m)+"%")
clauses = append(clauses, "COALESCE(e.requested_model, e.model, '') ILIKE $"+itoa(len(args)))
} else {
args = append(args, m)
clauses = append(clauses, "COALESCE(e.requested_model, e.model, '') = $"+itoa(len(args)))
}
}
if filter.ExcludeCountTokens {
clauses = append(clauses, "COALESCE(e.is_count_tokens, false) = false")
}
if len(filter.ErrorPhasesAny) > 0 {
args = append(args, pq.Array(filter.ErrorPhasesAny))
clauses = append(clauses, "e.error_phase = ANY($"+itoa(len(args))+")")
}
if len(filter.ErrorTypesAny) > 0 {
args = append(args, pq.Array(filter.ErrorTypesAny))
clauses = append(clauses, "e.error_type = ANY($"+itoa(len(args))+")")
}
return "WHERE " + strings.Join(clauses, " AND "), args
}

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

@ -896,7 +896,8 @@ func TestAPIContracts(t *testing.T) {
"wechat_connect_mobile_app_secret_configured": false,
"wechat_connect_redirect_url": "",
"wechat_connect_frontend_redirect_url": "/auth/wechat/callback",
"wechat_connect_scopes": "snsapi_login"
"wechat_connect_scopes": "snsapi_login",
"allow_user_view_error_requests": false
}
}`,
},
@ -1167,7 +1168,8 @@ func TestAPIContracts(t *testing.T) {
"auth_source_default_dingtalk_subscriptions": [],
"auth_source_default_dingtalk_grant_on_signup": false,
"auth_source_default_dingtalk_grant_on_first_bind": false,
"force_email_on_third_party_signup": false
"force_email_on_third_party_signup": false,
"allow_user_view_error_requests": false
}
}`,
},
@ -1279,7 +1281,7 @@ func newContractDeps(t *testing.T) *contractDeps {
adminService := service.NewAdminService(userRepo, groupRepo, &accountRepo, proxyRepo, apiKeyRepo, redeemRepo, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
authHandler := handler.NewAuthHandler(cfg, nil, userService, settingService, nil, redeemService, nil, nil)
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
usageHandler := handler.NewUsageHandler(usageService, apiKeyService)
usageHandler := handler.NewUsageHandler(usageService, apiKeyService, nil, nil)
adminSettingHandler := adminhandler.NewSettingHandler(settingService, nil, nil, nil, nil, nil, nil)
adminAccountHandler := adminhandler.NewAccountHandler(adminService, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
@ -2102,6 +2104,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

@ -82,6 +82,8 @@ func RegisterUserRoutes(
usage := authenticated.Group("/usage")
{
usage.GET("", h.Usage.List)
usage.GET("/errors", h.Usage.ListErrors)
usage.GET("/errors/:id", h.Usage.GetErrorDetail)
usage.GET("/:id", h.Usage.GetByID)
usage.GET("/stats", h.Usage.Stats)
// User dashboard endpoints

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

@ -463,3 +463,7 @@ func SettingKeyAuthSourcePlatformQuotas(source string) string {
// AdminAPIKeyPrefix is the prefix for admin API keys (distinct from user "sk-" keys).
const AdminAPIKeyPrefix = "admin-"
// SettingKeyAllowUserViewErrorRequests controls whether end users can view
// their own failed requests on the usage page. Default false (opt-in).
const SettingKeyAllowUserViewErrorRequests = "allow_user_view_error_requests"

View File

@ -64,6 +64,10 @@ type OpsErrorLog struct {
RequestedModel string `json:"requested_model"`
UpstreamModel string `json:"upstream_model"`
RequestType *int16 `json:"request_type"`
// 关联 api_key 名称LEFT JOIN api_keys 取得;软删只覆盖 key 列name 保留,故已删 key 仍有原名)。
APIKeyName string `json:"api_key_name,omitempty"`
APIKeyDeleted bool `json:"api_key_deleted,omitempty"`
}
type OpsErrorLogDetail struct {
@ -87,6 +91,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 {
@ -99,7 +112,7 @@ type OpsErrorLogFilter struct {
StatusCodes []int
StatusCodesOther bool
Phase string
Phase string // Special: Phase=="upstream" bypasses status>=400 clause; do not set together with ErrorPhasesAny.
Owner string
Source string
Resolved *bool
@ -110,6 +123,32 @@ type OpsErrorLogFilter struct {
RequestID string
ClientRequestID string
// User-scoped filters (used by the user-facing error requests endpoint and
// by admin drill-down from the usage page).
UserID *int64
APIKeyID *int64
// MatchDeletedKeyOwner: 用户侧专用。UserID 设置且为 true 时,归属从 user_id=UserID
// 放宽为 (user_id=UserID OR deleted_key_owner_user_id=UserID),使原所有者能看到
// 自己「已删除 key 认证失败」的记录。admin 路径不设此开关 → 行为不变。
MatchDeletedKeyOwner bool
// Model matches against requested_model first, then model.
Model string
// ModelFuzzy 为 true 时 Model 走 ILIKE 模糊匹配仅用户端启用false默认保持精确 =,管理端语义不变。
ModelFuzzy bool
// ExcludeCountTokens drops count_tokens probe errors (is_count_tokens=true).
ExcludeCountTokens bool
// ErrorPhasesAny / ErrorTypesAny add plain ANY() filters WITHOUT touching the
// special-cased single `Phase` field (only Phase=="upstream" bypasses the status>=400 clause).
// NOTE: these ANY filters do NOT bypass status>=400; records with error_phase='upstream'
// but status_code<400 (recovered upstream errors) remain excluded.
// Used to map user-facing coarse categories to backend conditions.
ErrorPhasesAny []string
ErrorTypesAny []string
// View controls error categorization for list endpoints.
// - errors: show actionable errors (exclude business-limited / 429 / 529)
// - excluded: only show excluded errors

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

@ -338,6 +338,50 @@ func (s *OpsService) GetErrorLogs(ctx context.Context, filter *OpsErrorLogFilter
return result, nil
}
// ListUserErrorRequests 返回某个用户自己的错误请求(精简脱敏)。
// 强制仅当前用户、View=all含业务限流/余额类)、排除 count_tokens 噪声。
func (s *OpsService) ListUserErrorRequests(ctx context.Context, userID int64, filter *OpsErrorLogFilter) (*UserErrorRequestList, error) {
if filter == nil {
filter = &OpsErrorLogFilter{}
}
f := *filter // 拷贝快照,避免原地篡改调用方的 filterslice 字段只读,浅拷贝足够)
filter = &f
uid := userID
filter.UserID = &uid
// 用户侧放宽归属:纳入「删 key 后认证失败」(user_id=NULL,靠 deleted_key_owner 归因)的记录。
filter.MatchDeletedKeyOwner = true
// APIKeyID 透传:保留 handler 传入的值。安全由 buildOpsErrorLogsWhere 的
// "user_id = 自己 AND api_key_id = X" 双重约束保证——传入他人 key 只会得到空集,无泄露。
filter.View = "all"
filter.ExcludeCountTokens = true
filter.ModelFuzzy = true // 用户端模型过滤走 ILIKE 模糊;管理端不设此字段,保持精确
// 防御:用户端不接受这些 admin-only / 特殊维度
filter.UserQuery = ""
filter.Owner = ""
filter.Source = ""
// 清空 Phase 是防御:Phase 是单值特殊字段,仅当其 == "upstream" 时 buildOpsErrorLogsWhere 才跳过 status>=400 子句。
// 用户端一律改走 category→ErrorPhasesAny/ErrorTypesAny(纯 ANY 过滤,不影响 status>=400 子句),
// 因此 recovered upstream(error_phase='upstream' 但 status<400,最终成功返回)记录对用户不可见——符合预期。
filter.Phase = ""
list, err := s.opsRepo.ListErrorLogs(ctx, filter)
if err != nil {
return nil, err
}
items := make([]*UserErrorRequest, 0, len(list.Errors))
for _, e := range list.Errors {
if r := ToUserErrorRequest(e); r != nil {
items = append(items, r)
}
}
return &UserErrorRequestList{
Items: items,
Total: list.Total,
Page: list.Page,
PageSize: list.PageSize,
}, nil
}
func (s *OpsService) GetErrorLogByID(ctx context.Context, id int64) (*OpsErrorLogDetail, error) {
if err := s.RequireMonitoringEnabled(ctx); err != nil {
return nil, err
@ -355,6 +399,39 @@ func (s *OpsService) GetErrorLogByID(ctx context.Context, id int64) (*OpsErrorLo
return detail, nil
}
// GetUserErrorRequestDetail 返回某用户自己某条错误请求的脱敏详情(含 error_body)。
// 安全:强制按用户归属校验;非本人记录一律返回 NotFound(不泄露存在性)。
func (s *OpsService) GetUserErrorRequestDetail(ctx context.Context, userID, id int64) (*UserErrorRequestDetail, error) {
if s.opsRepo == nil {
return nil, infraerrors.NotFound("OPS_ERROR_NOT_FOUND", "ops error log not found")
}
if id <= 0 {
return nil, infraerrors.BadRequest("OPS_ERROR_INVALID_ID", "invalid error id")
}
detail, err := s.opsRepo.GetErrorLogByID(ctx, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, infraerrors.NotFound("OPS_ERROR_NOT_FOUND", "ops error log not found")
}
return nil, infraerrors.InternalServer("OPS_ERROR_LOAD_FAILED", "Failed to load ops error log").WithCause(err)
}
// 归属:直接归属(user_id)或经「已删除 key 归因」(deleted_key_owner_user_id)二者之一即可。
ownedDirectly := detail.UserID != nil && *detail.UserID == userID
ownedViaDeletedKey := detail.DeletedKeyOwnerUserID != nil && *detail.DeletedKeyOwnerUserID == userID
if !ownedDirectly && !ownedViaDeletedKey {
return nil, infraerrors.NotFound("OPS_ERROR_NOT_FOUND", "ops error log not found")
}
return ToUserErrorRequestDetail(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,222 @@
package service
import (
"context"
"database/sql"
"testing"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
)
type stubOpsRepoForUserErr struct {
OpsRepository // 嵌入接口,未实现的方法 panic仅覆盖 ListErrorLogs
gotFilter *OpsErrorLogFilter
// GetErrorLogByID 控制字段
detailToReturn *OpsErrorLogDetail
detailErrToReturn error
}
func (s *stubOpsRepoForUserErr) ListErrorLogs(ctx context.Context, f *OpsErrorLogFilter) (*OpsErrorLogList, error) {
s.gotFilter = f
return &OpsErrorLogList{
Errors: []*OpsErrorLog{{
Phase: "request", Type: "rate_limit_error",
Model: "m", RequestedModel: "rm", StatusCode: 429,
Message: "secret", UserEmail: "a@b.c",
}},
Total: 1, Page: 1, PageSize: 20,
}, nil
}
func (s *stubOpsRepoForUserErr) GetErrorLogByID(ctx context.Context, id int64) (*OpsErrorLogDetail, error) {
if s.detailErrToReturn != nil {
return nil, s.detailErrToReturn
}
return s.detailToReturn, nil
}
func TestListUserErrorRequests_ForcesScopeAndRedacts(t *testing.T) {
stub := &stubOpsRepoForUserErr{}
svc := &OpsService{opsRepo: stub}
uid := int64(42)
kid := int64(7)
in := &OpsErrorLogFilter{UserID: nil, View: "errors", Phase: "upstream", APIKeyID: &kid}
out, err := svc.ListUserErrorRequests(context.Background(), uid, in)
if err != nil {
t.Fatal(err)
}
// 强制按用户
if stub.gotFilter.UserID == nil || *stub.gotFilter.UserID != uid {
t.Fatalf("UserID not forced: %+v", stub.gotFilter.UserID)
}
// 强制 View=all含业务限流/余额)
if stub.gotFilter.View != "all" {
t.Fatalf("View not forced to all: %q", stub.gotFilter.View)
}
// 强制排除 count_tokens
if !stub.gotFilter.ExcludeCountTokens {
t.Fatal("ExcludeCountTokens not forced")
}
// 强制清空 Phase防止 "upstream" 绕过 status>=400 子句 + 与 ErrorPhasesAny 双重约束)
if stub.gotFilter.Phase != "" {
t.Fatalf("Phase not cleared: %q", stub.gotFilter.Phase)
}
// APIKeyID 透传保留(用户可按自己 key 过滤;越权由 user_id AND api_key_id 双重防护)
if stub.gotFilter.APIKeyID == nil || *stub.gotFilter.APIKeyID != kid {
t.Fatalf("APIKeyID should be preserved, got %v", stub.gotFilter.APIKeyID)
}
// 调用方传入的 filter 不应被原地篡改(验证 shallow copy 隔离生效)
if in.View != "errors" || in.UserID != nil || in.Phase != "upstream" {
t.Fatalf("caller filter was mutated: View=%q UserID=%v Phase=%q", in.View, in.UserID, in.Phase)
}
// 脱敏:返回条目含 message 字段
if len(out.Items) != 1 || out.Items[0].Category != "rate_limit" || out.Items[0].Model != "rm" {
t.Fatalf("bad item: %+v", out.Items)
}
}
func TestGetUserErrorRequestDetail_OwnershipEnforced(t *testing.T) {
ownerUID := int64(999)
callerUID := int64(1)
upstreamStatus := 503
detail := &OpsErrorLogDetail{
OpsErrorLog: OpsErrorLog{
ID: 42,
Phase: "upstream",
Type: "api_error",
Model: "gpt-4",
RequestedModel: "gpt-4-turbo",
InboundEndpoint: "/v1/chat/completions",
StatusCode: 502,
Platform: "openai",
Message: "upstream failed",
UserID: &ownerUID,
},
ErrorBody: `{"error":"upstream"}`,
UpstreamStatusCode: &upstreamStatus,
}
stub := &stubOpsRepoForUserErr{detailToReturn: detail}
svc := &OpsService{opsRepo: stub}
// 越权调用callerUID=1,但记录属于 ownerUID=999→ 应返回 NotFound,detail 为 nil
got, err := svc.GetUserErrorRequestDetail(context.Background(), callerUID, 42)
if err == nil {
t.Fatal("expected error for unauthorized access, got nil")
}
if got != nil {
t.Fatalf("expected nil detail for unauthorized access, got %+v", got)
}
// 验证错误为 NotFound(不暴露存在性)
if !infraerrors.IsNotFound(err) {
t.Fatalf("expected NotFound error, got: %v", err)
}
// 合法调用callerUID=999 = ownerUID→ 应返回 non-nil detail
got2, err2 := svc.GetUserErrorRequestDetail(context.Background(), ownerUID, 42)
if err2 != nil {
t.Fatalf("expected no error for legitimate access, got %v", err2)
}
if got2 == nil {
t.Fatal("expected non-nil detail for legitimate access")
}
if got2.ID != 42 {
t.Errorf("want ID=42, got %d", got2.ID)
}
if got2.ErrorBody != `{"error":"upstream"}` {
t.Errorf("want ErrorBody=%q, got %q", `{"error":"upstream"}`, got2.ErrorBody)
}
if got2.UpstreamStatusCode == nil || *got2.UpstreamStatusCode != 503 {
t.Errorf("want UpstreamStatusCode=503, got %v", got2.UpstreamStatusCode)
}
if got2.Message != "upstream failed" {
t.Errorf("want Message=%q, got %q", "upstream failed", got2.Message)
}
}
func TestGetUserErrorRequestDetail_NotFound(t *testing.T) {
stub := &stubOpsRepoForUserErr{detailErrToReturn: sql.ErrNoRows}
svc := &OpsService{opsRepo: stub}
got, err := svc.GetUserErrorRequestDetail(context.Background(), 1, 999)
if err == nil {
t.Fatal("expected error for not found, got nil")
}
if got != nil {
t.Fatalf("expected nil detail, got %+v", got)
}
}
func TestGetUserErrorRequestDetail_InvalidID(t *testing.T) {
stub := &stubOpsRepoForUserErr{}
svc := &OpsService{opsRepo: stub}
_, err := svc.GetUserErrorRequestDetail(context.Background(), 1, 0)
if err == nil {
t.Fatal("expected error for id=0")
}
_, err = svc.GetUserErrorRequestDetail(context.Background(), 1, -5)
if err == nil {
t.Fatal("expected error for id=-5")
}
}
func TestListUserErrorRequests_EnablesMatchDeletedKeyOwner(t *testing.T) {
stub := &stubOpsRepoForUserErr{}
svc := &OpsService{opsRepo: stub}
uid := int64(42)
if _, err := svc.ListUserErrorRequests(context.Background(), uid, &OpsErrorLogFilter{}); err != nil {
t.Fatal(err)
}
if stub.gotFilter == nil || !stub.gotFilter.MatchDeletedKeyOwner {
t.Fatal("ListUserErrorRequests should enable MatchDeletedKeyOwner for the user scope")
}
}
func TestGetUserErrorRequestDetail_DeletedKeyOwnerAccess(t *testing.T) {
ownerUID := int64(777)
otherUID := int64(2)
// 情况2:user_id=NULL,靠 deleted_key_owner_user_id 归因到 ownerUID
mk := func() *OpsErrorLogDetail {
return &OpsErrorLogDetail{
OpsErrorLog: OpsErrorLog{
ID: 55,
Phase: "auth",
Type: "api_error",
StatusCode: 401,
Message: "Invalid API key",
UserID: nil,
APIKeyName: "my-old-key",
APIKeyDeleted: true,
},
DeletedKeyOwnerUserID: &ownerUID,
}
}
// 原所有者(经 deleted_key 归因)→ 放行
svcOwner := &OpsService{opsRepo: &stubOpsRepoForUserErr{detailToReturn: mk()}}
got, err := svcOwner.GetUserErrorRequestDetail(context.Background(), ownerUID, 55)
if err != nil {
t.Fatalf("owner via deleted_key should be allowed, got err: %v", err)
}
if got == nil || got.ID != 55 {
t.Fatalf("expected detail ID=55, got %+v", got)
}
if !got.KeyDeleted || got.KeyName != "my-old-key" {
t.Fatalf("expected KeyDeleted=true KeyName=my-old-key, got %+v", got)
}
// 他人 → NotFound,不泄露存在性
svcOther := &OpsService{opsRepo: &stubOpsRepoForUserErr{detailToReturn: mk()}}
got2, err2 := svcOther.GetUserErrorRequestDetail(context.Background(), otherUID, 55)
if err2 == nil || got2 != nil {
t.Fatalf("non-owner should get (nil, NotFound), got detail=%+v err=%v", got2, err2)
}
if !infraerrors.IsNotFound(err2) {
t.Fatalf("expected NotFound, got %v", err2)
}
}

View File

@ -0,0 +1,123 @@
package service
import "time"
// UserErrorRequest 是面向终端用户的"错误请求"精简脱敏视图(白名单)。
// 严禁包含 client_ip / user_agent / account / api_key_prefix / upstream_endpoint /
// user_email 等敏感或内部字段。注message网关标准化错误描述与 key_name
// (用户自有 API Key 名称KeysView 中本就可见)经产品决策对该用户开放;
// error_body 仅在详情接口GetUserErrorRequestDetail按归属校验后返回。
type UserErrorRequest struct {
ID int64 `json:"id"`
CreatedAt time.Time `json:"created_at"`
Model string `json:"model"`
InboundEndpoint string `json:"inbound_endpoint"`
StatusCode int `json:"status_code"`
Category string `json:"category"`
Platform string `json:"platform"`
Message string `json:"message"`
KeyName string `json:"key_name"`
KeyDeleted bool `json:"key_deleted"`
}
// UserErrorRequestList 是用户错误请求分页结果。
type UserErrorRequestList struct {
Items []*UserErrorRequest `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
// MapUserErrorCategory 把后端 error_phase + error_type 映射为用户侧粗分类码。
// 返回的是稳定的分类 code前端做 i18n不是展示文案。
func MapUserErrorCategory(phase, errType string) string {
switch phase {
case "auth":
return "auth"
case "routing":
return "service_unavailable"
case "upstream", "network":
return "upstream"
case "internal":
return "internal"
case "request":
switch errType {
case "rate_limit_error":
return "rate_limit"
case "billing_error", "subscription_error":
return "quota"
case "invalid_request_error":
return "invalid_request"
}
}
return "other"
}
// CategoryToFilter 把用户侧分类码反向映射为后端过滤条件plain ANY
// 未知分类返回两个空切片(即不施加分类过滤)。
// 注意:"other" 与未知分类都走 default 返回空切片——"other" 无对应的 phase/type 组合,无法精确反查,因此等价于不过滤。
func CategoryToFilter(category string) (phases []string, errorTypes []string) {
switch category {
case "auth":
return []string{"auth"}, nil
case "service_unavailable":
return []string{"routing"}, nil
case "upstream":
return []string{"upstream", "network"}, nil
case "internal":
return []string{"internal"}, nil
case "rate_limit":
return nil, []string{"rate_limit_error"}
case "quota":
return nil, []string{"billing_error", "subscription_error"}
case "invalid_request":
return nil, []string{"invalid_request_error"}
default:
return nil, nil
}
}
// ToUserErrorRequest 把内部 OpsErrorLog 裁剪为用户安全视图。
func ToUserErrorRequest(e *OpsErrorLog) *UserErrorRequest {
if e == nil {
return nil
}
model := e.RequestedModel
if model == "" {
model = e.Model
}
return &UserErrorRequest{
ID: e.ID,
CreatedAt: e.CreatedAt,
Model: model,
InboundEndpoint: e.InboundEndpoint,
StatusCode: e.StatusCode,
Category: MapUserErrorCategory(e.Phase, e.Type),
Platform: e.Platform,
Message: e.Message,
KeyName: e.APIKeyName,
KeyDeleted: e.APIKeyDeleted,
}
}
// UserErrorRequestDetail 是错误请求详情的脱敏视图(点击单行查看)。
// 在 UserErrorRequest 基础上额外暴露 error_body(上游错误响应正文)与 upstream_status_code;
// 仍严禁任何内部/敏感字段。
type UserErrorRequestDetail struct {
UserErrorRequest
ErrorBody string `json:"error_body"`
UpstreamStatusCode *int `json:"upstream_status_code,omitempty"`
}
// ToUserErrorRequestDetail 把内部 OpsErrorLogDetail 裁剪为用户安全详情视图。
func ToUserErrorRequestDetail(e *OpsErrorLogDetail) *UserErrorRequestDetail {
if e == nil {
return nil
}
base := ToUserErrorRequest(&e.OpsErrorLog)
return &UserErrorRequestDetail{
UserErrorRequest: *base,
ErrorBody: e.ErrorBody,
UpstreamStatusCode: e.UpstreamStatusCode,
}
}

View File

@ -0,0 +1,167 @@
package service
import (
"encoding/json"
"strings"
"testing"
"time"
)
func TestMapUserErrorCategory(t *testing.T) {
cases := []struct {
phase, etype, want string
}{
{"auth", "authentication_error", "auth"},
{"request", "rate_limit_error", "rate_limit"},
{"request", "billing_error", "quota"},
{"request", "subscription_error", "quota"},
{"request", "invalid_request_error", "invalid_request"},
{"routing", "api_error", "service_unavailable"},
{"upstream", "upstream_error", "upstream"},
{"network", "api_error", "upstream"},
{"internal", "api_error", "internal"},
{"weird", "weird", "other"},
}
for _, c := range cases {
if got := MapUserErrorCategory(c.phase, c.etype); got != c.want {
t.Errorf("MapUserErrorCategory(%q,%q)=%q want %q", c.phase, c.etype, got, c.want)
}
}
}
func TestCategoryToFilter(t *testing.T) {
phases, types := CategoryToFilter("rate_limit")
if len(types) != 1 || types[0] != "rate_limit_error" || len(phases) != 0 {
t.Fatalf("rate_limit => phases=%v types=%v", phases, types)
}
phases, types = CategoryToFilter("auth")
if len(phases) != 1 || phases[0] != "auth" || len(types) != 0 {
t.Fatalf("auth => phases=%v types=%v", phases, types)
}
phases, types = CategoryToFilter("service_unavailable")
if len(phases) != 1 || phases[0] != "routing" || len(types) != 0 {
t.Fatalf("service_unavailable => phases=%v types=%v", phases, types)
}
phases, types = CategoryToFilter("upstream")
if len(phases) != 2 || phases[0] != "upstream" || phases[1] != "network" || len(types) != 0 {
t.Fatalf("upstream => phases=%v types=%v", phases, types)
}
phases, types = CategoryToFilter("internal")
if len(phases) != 1 || phases[0] != "internal" || len(types) != 0 {
t.Fatalf("internal => phases=%v types=%v", phases, types)
}
phases, types = CategoryToFilter("quota")
if len(types) != 2 || types[0] != "billing_error" || types[1] != "subscription_error" || len(phases) != 0 {
t.Fatalf("quota => phases=%v types=%v", phases, types)
}
phases, types = CategoryToFilter("invalid_request")
if len(types) != 1 || types[0] != "invalid_request_error" || len(phases) != 0 {
t.Fatalf("invalid_request => phases=%v types=%v", phases, types)
}
phases, types = CategoryToFilter("other")
if len(phases) != 0 || len(types) != 0 {
t.Fatalf("other => phases=%v types=%v", phases, types)
}
}
func TestToUserErrorRequest_RedactsSensitiveFields(t *testing.T) {
src := &OpsErrorLog{
ID: 123,
CreatedAt: time.Unix(0, 0).UTC(),
Model: "m",
RequestedModel: "rm",
InboundEndpoint: "/v1/chat/completions",
StatusCode: 429,
Platform: "openai",
Phase: "request",
Type: "rate_limit_error",
Message: "rate limit exceeded",
APIKeyName: "my-key",
APIKeyDeleted: true,
}
out := ToUserErrorRequest(src)
if out.ID != 123 {
t.Errorf("want ID=123, got %d", out.ID)
}
if out.Model != "rm" {
t.Errorf("want requested_model preferred, got %q", out.Model)
}
if out.Category != "rate_limit" {
t.Errorf("category=%q", out.Category)
}
if out.StatusCode != 429 || out.InboundEndpoint != "/v1/chat/completions" || out.Platform != "openai" {
t.Errorf("basic fields wrong: %+v", out)
}
if out.Message != "rate limit exceeded" {
t.Errorf("want message=%q, got %q", "rate limit exceeded", out.Message)
}
if out.KeyName != "my-key" {
t.Errorf("want key_name=my-key, got %q", out.KeyName)
}
if !out.KeyDeleted {
t.Error("want key_deleted=true")
}
}
func TestToUserErrorRequestDetail_WhitelistAndRedacts(t *testing.T) {
uid := int64(42)
upstreamStatus := 503
src := &OpsErrorLogDetail{
OpsErrorLog: OpsErrorLog{
ID: 999,
CreatedAt: time.Unix(1000, 0).UTC(),
Model: "gpt-4",
RequestedModel: "gpt-4-turbo",
InboundEndpoint: "/v1/chat/completions",
StatusCode: 502,
Platform: "openai",
Phase: "upstream",
Type: "api_error",
Message: "upstream error",
UserID: &uid,
UserEmail: "secret@example.com",
ClientIP: func() *string { s := "1.2.3.4"; return &s }(),
UpstreamEndpoint: "https://api.openai.com/v1/chat/completions",
},
ErrorBody: `{"error":{"message":"upstream failed","type":"server_error"}}`,
UserAgent: "Mozilla/5.0 secret-agent",
UpstreamStatusCode: &upstreamStatus,
}
out := ToUserErrorRequestDetail(src)
if out == nil {
t.Fatal("expected non-nil detail")
}
// 基础字段正确映射
if out.ID != 999 {
t.Errorf("want ID=999, got %d", out.ID)
}
if out.Message != "upstream error" {
t.Errorf("want message=%q, got %q", "upstream error", out.Message)
}
if out.ErrorBody != src.ErrorBody {
t.Errorf("ErrorBody mismatch")
}
if out.UpstreamStatusCode == nil || *out.UpstreamStatusCode != 503 {
t.Errorf("UpstreamStatusCode mismatch")
}
// 序列化后不含敏感字段
b, err := json.Marshal(out)
if err != nil {
t.Fatalf("json.Marshal failed: %v", err)
}
raw := string(b)
for _, forbidden := range []string{"user_email", "client_ip", "upstream_endpoint", "user_agent"} {
if strings.Contains(raw, forbidden) {
t.Errorf("sensitive field %q leaked in JSON output: %s", forbidden, raw)
}
}
}
func TestToUserErrorRequestDetail_Nil(t *testing.T) {
if out := ToUserErrorRequestDetail(nil); out != nil {
t.Errorf("expected nil for nil input, got %+v", out)
}
}

View File

@ -761,6 +761,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
SettingKeyAvailableChannelsEnabled,
SettingKeyAffiliateEnabled,
SettingKeyRiskControlEnabled,
SettingKeyAllowUserViewErrorRequests,
}
settings, err := s.settingRepo.GetMultiple(ctx, keys)
@ -873,6 +874,8 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
AffiliateEnabled: settings[SettingKeyAffiliateEnabled] == "true",
RiskControlEnabled: settings[SettingKeyRiskControlEnabled] == "true",
AllowUserViewErrorRequests: settings[SettingKeyAllowUserViewErrorRequests] == "true",
}, nil
}
@ -950,6 +953,17 @@ func (s *SettingService) GetAvailableChannelsRuntime(ctx context.Context) Availa
}
}
// IsUserErrorViewAllowed reads the user-facing error-requests visibility switch
// directly from the settings store. Fail-closed: on error returns false (opt-in default).
func (s *SettingService) IsUserErrorViewAllowed(ctx context.Context) bool {
vals, err := s.settingRepo.GetMultiple(ctx, []string{SettingKeyAllowUserViewErrorRequests})
if err != nil {
slog.Warn("failed to get allow_user_view_error_requests setting, defaulting to false", "error", err)
return false
}
return vals[SettingKeyAllowUserViewErrorRequests] == "true"
}
// GetAntigravityUserAgentVersion 返回 Antigravity 上游请求使用的版本号。
// 后台设置优先;为空、缺失或非法时回退到 ANTIGRAVITY_USER_AGENT_VERSION / 内置默认值。
func (s *SettingService) GetAntigravityUserAgentVersion(ctx context.Context) string {
@ -1175,6 +1189,7 @@ type PublicSettingsInjectionPayload struct {
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
AffiliateEnabled bool `json:"affiliate_enabled"`
RiskControlEnabled bool `json:"risk_control_enabled"`
AllowUserViewErrorRequests bool `json:"allow_user_view_error_requests"`
}
// GetPublicSettingsForInjection returns public settings in a format suitable for HTML injection.
@ -1237,6 +1252,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
AffiliateEnabled: settings.AffiliateEnabled,
RiskControlEnabled: settings.RiskControlEnabled,
AllowUserViewErrorRequests: settings.AllowUserViewErrorRequests,
}, nil
}
@ -1924,6 +1940,8 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
updates[SettingKeyDefaultPlatformQuotas] = string(blob)
}
updates[SettingKeyAllowUserViewErrorRequests] = strconv.FormatBool(settings.AllowUserViewErrorRequests)
return updates, nil
}
@ -2816,6 +2834,8 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
SettingPaymentVisibleMethodAlipayEnabled: "false",
SettingPaymentVisibleMethodWxpayEnabled: "false",
openAIAdvancedSchedulerSettingKey: "false",
SettingKeyAllowUserViewErrorRequests: "false",
}
return s.settingRepo.SetMultiple(ctx, defaults)
@ -3373,6 +3393,8 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
}
}
result.AllowUserViewErrorRequests = settings[SettingKeyAllowUserViewErrorRequests] == "true" // default false
return result
}

View File

@ -91,6 +91,19 @@ func TestSettingService_GetPublicSettings_ExposesForceEmailOnThirdPartySignup(t
require.True(t, settings.ForceEmailOnThirdPartySignup)
}
func TestSettingService_GetPublicSettings_ExposesAllowUserViewErrorRequests(t *testing.T) {
repo := &settingPublicRepoStub{
values: map[string]string{
SettingKeyAllowUserViewErrorRequests: "true",
},
}
svc := NewSettingService(repo, &config.Config{})
settings, err := svc.GetPublicSettings(context.Background())
require.NoError(t, err)
require.True(t, settings.AllowUserViewErrorRequests)
}
func TestSettingService_GetPublicSettings_ExposesWeChatOAuthModeCapabilities(t *testing.T) {
svc := NewSettingService(&settingPublicRepoStub{
values: map[string]string{

View File

@ -0,0 +1,31 @@
//go:build unit
package service
import (
"context"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/stretchr/testify/require"
)
// TestAllowUserViewErrorRequests_PersistsToDB 验证 buildSystemSettingsUpdates 会将
// AllowUserViewErrorRequests 写入 updates map即最终落库这是对 bug 的回归测试:
// 该字段曾因漏写而永远无法持久化。
func TestAllowUserViewErrorRequests_PersistsToDB(t *testing.T) {
// bmUpdateRepoStub 已在 setting_service_backend_mode_test.go 中定义(同 package
// 本测试不触及需要 GetValue 的设置项getValueFn 设为 nil 即可,无需 stub。
repo := &bmUpdateRepoStub{}
svc := NewSettingService(repo, &config.Config{})
err := svc.UpdateSettings(context.Background(), &SystemSettings{
AllowUserViewErrorRequests: true,
})
require.NoError(t, err)
// 断言 updates 中含有该 key且值为 "true"
val, ok := repo.updates[SettingKeyAllowUserViewErrorRequests]
require.True(t, ok, "updates map 中应包含 SettingKeyAllowUserViewErrorRequests但未找到bugbuildSystemSettingsUpdates 漏写)")
require.Equal(t, "true", val)
}

View File

@ -0,0 +1,9 @@
package service
import "testing"
func TestSettingKeyAllowUserViewErrorRequests_Constant(t *testing.T) {
if SettingKeyAllowUserViewErrorRequests != "allow_user_view_error_requests" {
t.Fatalf("unexpected key: %s", SettingKeyAllowUserViewErrorRequests)
}
}

View File

@ -223,6 +223,9 @@ type SystemSettings struct {
// 系统全局默认平台配额key = platformnil/缺省 = 不限制)
DefaultPlatformQuotas map[string]*DefaultPlatformQuotaSetting `json:"default_platform_quotas"`
// 允许终端用户在用量页查看自己的失败请求
AllowUserViewErrorRequests bool
}
type DefaultSubscriptionSetting struct {
@ -293,6 +296,9 @@ type PublicSettings struct {
// 风控中心功能开关
RiskControlEnabled bool `json:"risk_control_enabled"`
// 允许终端用户在用量页查看自己的失败请求
AllowUserViewErrorRequests bool `json:"allow_user_view_error_requests"`
}
type LoginAgreementDocument struct {

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

@ -0,0 +1,6 @@
-- 148_add_ops_error_logs_user_time_index_notx.sql
-- 用户侧"错误请求"按 user_id 时间倒序分页所需的部分索引。
-- 非事务迁移_notxCREATE INDEX CONCURRENTLY 不可在事务内执行。
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_ops_error_logs_user_time
ON ops_error_logs (user_id, created_at DESC)
WHERE user_id IS NOT NULL;

View File

@ -907,6 +907,9 @@ export interface OpsErrorLog {
user_id?: number | null
user_email: string
api_key_id?: number | null
// 关联 api_key 名称(后端 LEFT JOIN api_keys软删保留 name故已删 key 仍有原名)。
api_key_name?: string
api_key_deleted?: boolean
account_id?: number | null
account_name: string
group_id?: number | null
@ -941,6 +944,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>
@ -1075,6 +1087,10 @@ export type OpsErrorListQueryParams = {
platform?: string
group_id?: number | null
account_id?: number | null
user_id?: number
api_key_id?: number
// 模型过滤:后端以 COALESCE(requested_model, model) 精确匹配admin 路径)。
model?: string
phase?: string
error_owner?: string

View File

@ -612,6 +612,9 @@ export interface SystemSettings {
// OpenAI fast/flex policy
openai_fast_policy_settings?: OpenAIFastPolicySettings;
// Allow user view error requests
allow_user_view_error_requests: boolean;
}
export interface UpdateSettingsRequest {
@ -842,6 +845,8 @@ export interface UpdateSettingsRequest {
// OpenAI fast/flex policy
openai_fast_policy_settings?: OpenAIFastPolicySettings;
allow_user_view_error_requests?: boolean;
}
/**

View File

@ -10,7 +10,10 @@ import type {
UsageStatsResponse,
PaginatedResponse,
TrendDataPoint,
ModelStat
ModelStat,
UserErrorRequest,
UserErrorRequestDetail,
UserErrorListParams
} from '@/types'
// ==================== Dashboard Types ====================
@ -304,6 +307,22 @@ export async function getDashboardApiKeysUsage(
return data
}
export async function listMyErrorRequests(
params: UserErrorListParams,
config: { signal?: AbortSignal } = {}
): Promise<PaginatedResponse<UserErrorRequest>> {
const { data } = await apiClient.get<PaginatedResponse<UserErrorRequest>>('/usage/errors', {
...config,
params
})
return data
}
export async function getMyErrorDetail(id: number): Promise<UserErrorRequestDetail> {
const { data } = await apiClient.get<UserErrorRequestDetail>(`/usage/errors/${id}`)
return data
}
export const usageAPI = {
list,
query,
@ -316,7 +335,10 @@ export const usageAPI = {
getDashboardTrend,
getDashboardModels,
getMyApiKeyDailyUsage,
getDashboardApiKeysUsage
getDashboardApiKeysUsage,
// Error requests
listMyErrorRequests,
getMyErrorDetail,
}
export default usageAPI

View File

@ -197,7 +197,7 @@
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useVirtualizer } from '@tanstack/vue-virtual'
import { useVirtualizer, observeElementRect as observeElementRectDefault } from '@tanstack/vue-virtual'
import { useI18n } from 'vue-i18n'
import type { Column } from './types'
import Icon from '@/components/icons/Icon.vue'
@ -218,6 +218,27 @@ const tableWrapperRef = ref<HTMLElement | null>(null)
const isScrollable = ref(false)
const actionsColumnNeedsExpanding = ref(false)
// --- ---
// : .table-wrapper flex:1 / min-h-0, flex @tanstack
// observeElementRect scrollRect; 0 ( flex ,
// reflow),scrollRect 0 calculateRange null
// ( virtualizer ):
// 1) observeElementRect, height<=0 ,scrollRect 0;
// 2) initialRect ,,
// : = - /// 320px
const estimatedViewportHeight = () => {
if (typeof window === 'undefined') return 600
return Math.max(window.innerHeight - 320, 400)
}
// observeElementRect: 0 ()
const observeElementRectNonZero = (
instance: any,
cb: (rect: { width: number; height: number }) => void
) => observeElementRectDefault(instance, (rect) => {
if (rect.height > 0) cb(rect)
})
//
const checkScrollable = () => {
if (tableWrapperRef.value) {
@ -578,6 +599,12 @@ const rowVirtualizer = useVirtualizer(computed(() => ({
getScrollElement: () => tableWrapperRef.value,
estimateSize: () => props.estimateRowHeight ?? 56,
overscan: props.overscan ?? 5,
// :,,
initialRect: { width: 0, height: estimatedViewportHeight() },
// : 0 , scrollRect 0 calculateRange null
observeElementRect: observeElementRectNonZero,
// ResizeObserver rAF, reflow /
useAnimationFrameWithResizeObserver: true,
})))
const virtualItems = computed(() => rowVirtualizer.value.getVirtualItems())

View File

@ -22,6 +22,18 @@
{{ selectedLabel }}
</slot>
</span>
<span
v-if="clearable && hasValue && !disabled"
class="select-clear"
role="button"
tabindex="-1"
aria-label="Clear selection"
@click.stop="clearSelection"
@mousedown.stop
@keydown.enter.stop.prevent="clearSelection"
>
<Icon name="x" size="sm" />
</span>
<span class="select-icon">
<Icon
name="chevronDown"
@ -135,6 +147,7 @@ interface Props {
labelKey?: string
creatable?: boolean
creatablePrefix?: string
clearable?: boolean
}
interface Emits {
@ -148,6 +161,7 @@ const props = withDefaults(defineProps<Props>(), {
searchable: 'auto',
creatable: false,
creatablePrefix: '',
clearable: false,
valueKey: 'value',
labelKey: 'label'
})
@ -239,6 +253,10 @@ const selectedLabel = computed(() => {
return placeholderText.value
})
const hasValue = computed(
() => props.modelValue !== null && props.modelValue !== undefined && props.modelValue !== ''
)
const filteredOptions = computed(() => {
let opts = props.options as any[]
if (isSearchable.value && searchQuery.value) {
@ -355,6 +373,12 @@ const selectOption = (option: any) => {
triggerRef.value?.focus()
}
const clearSelection = () => {
if (props.disabled) return
emit('update:modelValue', null)
emit('change', null, null)
}
// Keyboards
const onTriggerKeyDown = () => {
if (!isOpen.value) {
@ -461,6 +485,12 @@ onUnmounted(() => {
.select-icon {
@apply flex-shrink-0 text-gray-400 dark:text-dark-400;
}
.select-clear {
@apply flex flex-shrink-0 cursor-pointer items-center justify-center;
@apply rounded text-gray-400 transition-colors;
@apply hover:text-gray-600 dark:hover:text-gray-200;
}
</style>
<style>

View File

@ -0,0 +1,127 @@
<template>
<BaseDialog :show="show" :title="t('usage.errors.detail.title')" width="wide" @close="emit('update:show', false)">
<!-- Loading -->
<div v-if="loading" class="flex justify-center py-10">
<svg class="h-7 w-7 animate-spin text-primary-500" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
</div>
<!-- Error state -->
<div v-else-if="loadError" class="py-8 text-center text-sm text-red-500">
{{ t('usage.errors.detail.loadFailed') }}
</div>
<!-- Detail content -->
<div v-else-if="detail" class="space-y-4 text-sm">
<div class="grid grid-cols-2 gap-x-6 gap-y-3">
<!-- Time -->
<div>
<span class="font-medium text-gray-500 dark:text-dark-400">{{ t('usage.errors.time') }}</span>
<p class="mt-0.5 text-gray-900 dark:text-dark-100">{{ formatDateTime(detail.created_at) }}</p>
</div>
<!-- Model -->
<div>
<span class="font-medium text-gray-500 dark:text-dark-400">{{ t('usage.errors.model') }}</span>
<p class="mt-0.5 text-gray-900 dark:text-dark-100">{{ detail.model || '-' }}</p>
</div>
<!-- Endpoint -->
<div>
<span class="font-medium text-gray-500 dark:text-dark-400">{{ t('usage.errors.endpoint') }}</span>
<p class="mt-0.5 text-gray-900 dark:text-dark-100">{{ detail.inbound_endpoint || '-' }}</p>
</div>
<!-- Status Code -->
<div>
<span class="font-medium text-gray-500 dark:text-dark-400">{{ t('usage.errors.status') }}</span>
<p class="mt-0.5">
<span class="badge" :class="statusClass(detail.status_code)">{{ detail.status_code || '-' }}</span>
</p>
</div>
<!-- Category -->
<div>
<span class="font-medium text-gray-500 dark:text-dark-400">{{ t('usage.errors.category') }}</span>
<p class="mt-0.5 text-gray-900 dark:text-dark-100">{{ t('usage.errors.categories.' + detail.category) }}</p>
</div>
<!-- Platform -->
<div>
<span class="font-medium text-gray-500 dark:text-dark-400">{{ t('usage.errors.platform') }}</span>
<p class="mt-0.5 text-gray-900 dark:text-dark-100">{{ detail.platform || '-' }}</p>
</div>
<!-- Upstream status code -->
<div v-if="detail.upstream_status_code != null">
<span class="font-medium text-gray-500 dark:text-dark-400">{{ t('usage.errors.detail.upstreamStatus') }}</span>
<p class="mt-0.5 text-gray-900 dark:text-dark-100">{{ detail.upstream_status_code }}</p>
</div>
</div>
<!-- Message -->
<div v-if="detail.message">
<span class="font-medium text-gray-500 dark:text-dark-400">{{ t('usage.errors.message') }}</span>
<p class="mt-0.5 text-gray-900 dark:text-dark-100 break-all">{{ detail.message }}</p>
</div>
<!-- Error Body -->
<div v-if="detail.error_body">
<span class="font-medium text-gray-500 dark:text-dark-400">{{ t('usage.errors.detail.responseBody') }}</span>
<pre class="mt-1 overflow-auto max-h-[40vh] whitespace-pre-wrap break-all rounded-lg bg-gray-50 dark:bg-dark-900 border border-gray-200 dark:border-dark-700 p-3 text-xs text-gray-800 dark:text-dark-200">{{ detail.error_body }}</pre>
</div>
</div>
</BaseDialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseDialog from '@/components/common/BaseDialog.vue'
import { getMyErrorDetail } from '@/api/usage'
import { formatDateTime } from '@/utils/format'
import type { UserErrorRequestDetail } from '@/types'
const props = defineProps<{
show: boolean
errorId: number | null
}>()
const emit = defineEmits<{
(e: 'update:show', v: boolean): void
}>()
const { t } = useI18n()
const loading = ref(false)
const loadError = ref(false)
const detail = ref<UserErrorRequestDetail | null>(null)
watch(
() => [props.show, props.errorId] as const,
([show, id]) => {
if (show && id != null) {
fetchDetail(id)
} else if (!show) {
detail.value = null
loadError.value = false
}
}
)
async function fetchDetail(id: number) {
loading.value = true
loadError.value = false
detail.value = null
try {
detail.value = await getMyErrorDetail(id)
} catch (e) {
console.error('[UserErrorDetailModal] Failed to load error detail:', e)
loadError.value = true
} finally {
loading.value = false
}
}
function statusClass(code: number) {
if (code >= 500) return 'badge-danger'
if (code === 429) return 'badge-warning'
return 'badge-gray'
}
</script>

View File

@ -0,0 +1,173 @@
<template>
<div class="flex min-h-0 flex-1 flex-col">
<div class="px-6 py-4 flex-shrink-0">
<div class="flex flex-wrap items-end gap-4">
<div class="min-w-[180px]">
<label class="input-label">{{ t('usage.errors.model') }}</label>
<Select
v-model="localModel"
:options="modelOptions"
searchable
creatable
clearable
:placeholder="t('usage.errors.modelPlaceholder')"
@change="apply"
/>
</div>
<div class="min-w-[160px]">
<label class="input-label">{{ t('usage.errors.keyName') }}</label>
<Select
v-model="localApiKeyId"
:options="keyOptions"
:placeholder="t('usage.errors.allKeys')"
@change="apply"
/>
</div>
<div class="min-w-[140px]">
<label class="input-label">{{ t('usage.errors.category') }}</label>
<Select
v-model="localCategory"
:options="categoryOptions"
:placeholder="t('usage.errors.allCategories')"
@change="apply"
/>
</div>
<button class="btn btn-primary" @click="apply">
<Icon name="search" size="sm" />
{{ t('common.search') }}
</button>
</div>
</div>
<div class="min-h-0 flex-1 overflow-auto">
<table class="min-w-full text-sm">
<thead>
<tr>
<th class="px-4 py-2 text-left">{{ t('usage.errors.model') }}</th>
<th class="px-4 py-2 text-left">{{ t('usage.errors.keyName') }}</th>
<th class="px-4 py-2 text-left">{{ t('usage.errors.endpoint') }}</th>
<th class="px-4 py-2 text-left">{{ t('usage.errors.status') }}</th>
<th class="px-4 py-2 text-left">{{ t('usage.errors.category') }}</th>
<th class="px-4 py-2 text-left">{{ t('usage.errors.message') }}</th>
<th class="px-4 py-2 text-left">{{ t('usage.errors.platform') }}</th>
<th class="px-4 py-2 text-left">{{ t('usage.errors.time') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, i) in rows"
:key="i"
class="border-t border-gray-100 dark:border-dark-700 cursor-pointer hover:bg-gray-50 dark:hover:bg-dark-800"
@click="openDetail(row.id)"
>
<td class="px-4 py-2">{{ row.model || '-' }}</td>
<td class="px-4 py-2">
<span>{{ row.key_name || '-' }}</span>
<span
v-if="row.key_deleted"
class="ml-1 inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-gray-100 text-gray-500 dark:bg-dark-700 dark:text-gray-400"
>{{ t('usage.errors.keyDeleted') }}</span>
</td>
<td class="px-4 py-2">{{ row.inbound_endpoint || '-' }}</td>
<td class="px-4 py-2"><span class="badge" :class="statusClass(row.status_code)">{{ row.status_code || '-' }}</span></td>
<td class="px-4 py-2">{{ t('usage.errors.categories.' + row.category) }}</td>
<td class="px-4 py-2 max-w-[280px] truncate" :title="row.message">{{ row.message || '-' }}</td>
<td class="px-4 py-2">{{ row.platform || '-' }}</td>
<td class="px-4 py-2">{{ formatDateTime(row.created_at) }}</td>
</tr>
<tr v-if="!loading && rows.length === 0">
<td colspan="8" class="px-4 py-8 text-center text-gray-400">{{ t('usage.errors.empty') }}</td>
</tr>
</tbody>
</table>
</div>
<div class="flex-shrink-0">
<Pagination :page="page" :page-size="pageSize" :total="total"
@update:page="$emit('update:page', $event)"
@update:pageSize="$emit('update:pageSize', $event)" />
</div>
<UserErrorDetailModal v-model:show="showDetail" :error-id="selectedId" />
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Pagination from '@/components/common/Pagination.vue'
import Select from '@/components/common/Select.vue'
import Icon from '@/components/icons/Icon.vue'
import UserErrorDetailModal from '@/components/user/UserErrorDetailModal.vue'
import { formatDateTime } from '@/utils/format'
import type { UserErrorRequest, ApiKey } from '@/types'
const props = defineProps<{
rows: UserErrorRequest[]
total: number
loading: boolean
page: number
pageSize: number
apiKeys?: ApiKey[]
}>()
const emit = defineEmits<{
(e: 'update:page', v: number): void
(e: 'update:pageSize', v: number): void
(e: 'filter', v: { model: string; category: string; api_key_id: number | null }): void
}>()
const { t } = useI18n()
// string | null:clearable Select null,apply
const localModel = ref<string | null>('')
const localCategory = ref<string>('')
const localApiKeyId = ref<number | null>(null)
const categoryCodes = ['auth', 'rate_limit', 'quota', 'invalid_request', 'service_unavailable', 'upstream', 'internal']
const categoryOptions = computed(() => [
{ value: '', label: t('usage.errors.allCategories') },
...categoryCodes.map((c) => ({ value: c, label: t('usage.errors.categories.' + c) })),
])
// value: null key apiKeys
const keyOptions = computed(() => [
{ value: null, label: t('usage.errors.allKeys') },
...(props.apiKeys ?? []).map((k) => ({ value: k.id, label: k.name })),
])
// creatable
const modelOptions = computed(() => {
const seen = new Set<string>()
const opts: { value: string; label: string }[] = []
for (const r of props.rows) {
if (r.model && !seen.has(r.model)) {
seen.add(r.model)
opts.push({ value: r.model, label: r.model })
}
}
return opts
})
const showDetail = ref(false)
const selectedId = ref<number | null>(null)
function openDetail(id: number) {
selectedId.value = id
showDetail.value = true
}
function apply() {
emit('filter', {
model: (localModel.value ?? '').trim(),
category: localCategory.value || '',
api_key_id: localApiKeyId.value,
})
}
function statusClass(code: number) {
if (code >= 500) return 'badge-danger'
if (code === 429) return 'badge-warning'
return 'badge-gray'
}
</script>

View File

@ -933,7 +933,26 @@ export default {
exportExcelSuccess: 'Usage data exported successfully (Excel format)',
exportExcelFailed: 'Failed to export usage data',
imageUnit: ' images',
userAgent: 'User-Agent'
userAgent: 'User-Agent',
tabs: { usage: 'Usage', errors: 'Error Requests' },
errors: {
time: 'Time', model: 'Model', endpoint: 'Endpoint', status: 'Status',
category: 'Category', platform: 'Platform', message: 'Message',
keyName: 'Key Name', keyDeleted: 'Deleted', allKeys: 'All keys',
modelPlaceholder: 'Search model', allCategories: 'All categories',
empty: 'No error requests', failedToLoad: 'Failed to load error requests',
categories: {
auth: 'Auth failed', rate_limit: 'Rate limited', quota: 'Balance/Subscription',
invalid_request: 'Invalid request', service_unavailable: 'Service unavailable',
upstream: 'Upstream error', internal: 'Platform error', other: 'Other',
},
detail: {
title: 'Error Request Detail',
responseBody: 'Response Body',
upstreamStatus: 'Upstream Status',
loadFailed: 'Failed to load detail, please try again',
},
},
},
// Shared keys for channel monitor (admin + user views)
@ -4757,6 +4776,8 @@ export default {
group: 'Group',
user: 'User',
userId: 'User ID',
apiKey: 'API Key',
keyDeletedBadge: 'Key Deleted',
account: 'Account',
accountId: 'Account ID',
status: 'Status',
@ -4883,7 +4904,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',
@ -6328,6 +6353,14 @@ export default {
title: 'OpenAI experimental scheduler policy',
description: "Disabled by default. When enabled, this only changes the gateway's experimental account-selection policy for OpenAI traffic; it does not indicate an upstream OpenAI capability."
},
usageRecords: {
title: 'Usage Records',
description: 'Settings for usage and failed-request records visible to end users.',
},
user_error_view: {
label: 'Allow users to view their own error requests',
description: 'When enabled, users can see a redacted view of their failed requests on the usage page (no internal/upstream details). Requires ops monitoring enabled to have data.',
},
saveSettings: 'Save Settings',
saving: 'Saving...',
settingsSaved: 'Settings saved successfully',

View File

@ -937,7 +937,26 @@ export default {
exportExcelSuccess: '使用数据导出成功Excel格式',
exportExcelFailed: '使用数据导出失败',
imageUnit: '张',
userAgent: 'User-Agent'
userAgent: 'User-Agent',
tabs: { usage: '用量明细', errors: '错误请求' },
errors: {
time: '时间', model: '模型', endpoint: '端点', status: '状态码',
category: '分类', platform: '平台', message: '错误信息',
keyName: 'Key 名称', keyDeleted: '已删除', allKeys: '全部 Key',
modelPlaceholder: '搜索模型', allCategories: '全部分类',
empty: '暂无错误请求', failedToLoad: '加载错误请求失败',
categories: {
auth: '认证失败', rate_limit: '限流', quota: '余额/订阅',
invalid_request: '参数错误', service_unavailable: '服务暂时不可用',
upstream: '上游错误', internal: '平台错误', other: '其他',
},
detail: {
title: '错误请求详情',
responseBody: '上游响应内容',
upstreamStatus: '上游状态码',
loadFailed: '加载详情失败,请稍后重试',
},
},
},
// Shared keys for channel monitor (admin + user views)
@ -4916,6 +4935,8 @@ export default {
group: '分组',
user: '用户',
userId: '用户 ID',
apiKey: 'API Key',
keyDeletedBadge: 'Key 已删除',
account: '账号',
accountId: '账号 ID',
status: '状态码',
@ -5042,7 +5063,11 @@ export default {
suggestRequest: '⚠️ 客户端请求错误,建议:联系客户修正请求参数 / 手动标记已解决',
suggestAuth: '⚠️ 认证失败,建议:检查 API Key 是否有效 / 联系客户更新凭证',
suggestPlatform: '🚨 平台错误,建议立即排查修复',
suggestGeneric: '查看详情了解更多信息'
suggestGeneric: '查看详情了解更多信息',
apiKeyPrefix: 'Key 前缀',
attemptedKeyPrefix: '尝试的 Key 前缀',
deletedKeyOwner: '已删除 Key 所有者',
keyDeletedBadge: 'Key 已删除'
},
requestDetails: {
title: '请求明细',
@ -6483,6 +6508,14 @@ export default {
title: 'OpenAI 实验调度策略',
description: '默认关闭。开启后仅影响本网关在 OpenAI 账号间的实验性调度选择逻辑,不代表上游 OpenAI 官方能力。'
},
usageRecords: {
title: '使用记录',
description: '与终端用户可见的用量及失败请求记录相关的设置。',
},
user_error_view: {
label: '允许用户查看自己的错误请求',
description: '开启后,用户可在用量页查看自己失败请求的精简信息(不含内部/上游错误细节)。需运维监控开启才有数据。',
},
saveSettings: '保存设置',
saving: '保存中...',
settingsSaved: '设置保存成功',

View File

@ -359,6 +359,7 @@ export const useAppStore = defineStore('app', () => {
available_channels_enabled: false,
risk_control_enabled: false,
affiliate_enabled: false,
allow_user_view_error_requests: false,
}
}

View File

@ -234,6 +234,7 @@ export interface PublicSettings {
channel_monitor_default_interval_seconds: number
available_channels_enabled: boolean
affiliate_enabled: boolean
allow_user_view_error_requests?: boolean
}
export interface AuthResponse {
@ -1225,7 +1226,7 @@ export interface UsageLog {
request_type?: UsageRequestType
stream: boolean
openai_ws_mode?: boolean
duration_ms: number
duration_ms: number | null
first_token_ms: number | null
// 图片生成字段
@ -1592,6 +1593,36 @@ export interface ExtendSubscriptionRequest {
// ==================== Query Parameters ====================
export interface UserErrorRequest {
id: number
created_at: string
model: string
inbound_endpoint: string
status_code: number
category: string
platform: string
message: string
key_name: string
key_deleted: boolean
}
export interface UserErrorRequestDetail extends UserErrorRequest {
error_body: string
upstream_status_code?: number
}
export interface UserErrorListParams {
page?: number
page_size?: number
start_date?: string
end_date?: string
timezone?: string
model?: string
status_code?: number
category?: string
api_key_id?: number
}
export interface UsageQueryParams {
page?: number
page_size?: number

View File

@ -4398,6 +4398,35 @@
</div>
</div>
</div>
<!-- Usage Records Settings -->
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t('admin.settings.usageRecords.title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.usageRecords.description') }}
</p>
</div>
<div class="space-y-4 p-6">
<!-- User error requests visibility -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.user_error_view.label') }}
</label>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.user_error_view.description') }}
</p>
</div>
<label class="toggle">
<input v-model="form.allow_user_view_error_requests" type="checkbox" />
<span class="toggle-slider"></span>
</label>
</div>
</div>
</div>
</div>
<!-- /Tab: Gateway Claude Code, Scheduling -->
@ -7190,6 +7219,8 @@ const form = reactive<SettingsForm>({
available_channels_enabled: false,
// Affiliate () feature switch
affiliate_enabled: false,
// Allow user view error requests
allow_user_view_error_requests: false,
});
const authSourceDefaults = reactive<AuthSourceDefaultsState>(
@ -8331,6 +8362,7 @@ async function saveSettings() {
available_channels_enabled: form.available_channels_enabled,
// Affiliate () feature switch
affiliate_enabled: form.affiliate_enabled,
allow_user_view_error_requests: form.allow_user_view_error_requests,
};
// openai_fast_policy_settings

View File

@ -100,17 +100,36 @@
</div>
</template>
</UsageFilters>
<UsageTable
:data="usageLogs"
:loading="loading"
:columns="visibleColumns"
:server-side-sort="true"
:default-sort-key="'created_at'"
:default-sort-order="'desc'"
@sort="handleSort"
@userClick="handleUserClick"
/>
<Pagination v-if="pagination.total > 0" :page="pagination.page" :total="pagination.total" :page-size="pagination.page_size" @update:page="handlePageChange" @update:pageSize="handlePageSizeChange" />
<div class="mb-4 flex gap-2 border-b border-gray-200 dark:border-dark-700">
<button class="tab" :class="{ 'tab-active': activeTab === 'usage' }" @click="activeTab = 'usage'">
{{ t('usage.tabs.usage') }}
</button>
<button class="tab" :class="{ 'tab-active': activeTab === 'errors' }" @click="switchToErrorsTab">
{{ t('usage.tabs.errors') }}
</button>
</div>
<div v-show="activeTab === 'usage'">
<UsageTable
:data="usageLogs"
:loading="loading"
:columns="visibleColumns"
:server-side-sort="true"
:default-sort-key="'created_at'"
:default-sort-order="'desc'"
@sort="handleSort"
@userClick="handleUserClick"
/>
<Pagination v-if="pagination.total > 0" :page="pagination.page" :total="pagination.total" :page-size="pagination.page_size" @update:page="handlePageChange" @update:pageSize="handlePageSizeChange" />
</div>
<div v-show="activeTab === 'errors'">
<OpsErrorLogTable
:rows="errRows" :total="errTotal" :loading="errLoading"
:page="errPage" :page-size="errPageSize"
@openErrorDetail="openError"
@update:page="onErrPage"
@update:pageSize="onErrPageSize" />
<OpsErrorDetailModal v-model:show="showErrorModal" :error-id="selectedErrorId" :error-type="'request'" />
</div>
</div>
</AppLayout>
<UsageExportProgress :show="exportProgress.show" :progress="exportProgress.progress" :current="exportProgress.current" :total="exportProgress.total" :estimated-time="exportProgress.estimatedTime" @cancel="cancelExport" />
@ -144,6 +163,10 @@ import UsageStatsCards from '@/components/admin/usage/UsageStatsCards.vue'; impo
import UsageTable from '@/components/admin/usage/UsageTable.vue'; import UsageExportProgress from '@/components/admin/usage/UsageExportProgress.vue'
import UsageCleanupDialog from '@/components/admin/usage/UsageCleanupDialog.vue'
import UserBalanceHistoryModal from '@/components/admin/user/UserBalanceHistoryModal.vue'
import OpsErrorLogTable from '@/views/admin/ops/components/OpsErrorLogTable.vue'
import OpsErrorDetailModal from '@/views/admin/ops/components/OpsErrorDetailModal.vue'
import { listErrorLogs } from '@/api/admin/ops'
import type { OpsErrorLog } from '@/api/admin/ops'
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'; import GroupDistributionChart from '@/components/charts/GroupDistributionChart.vue'; import TokenUsageTrend from '@/components/charts/TokenUsageTrend.vue'
import EndpointDistributionChart from '@/components/charts/EndpointDistributionChart.vue'
import Icon from '@/components/icons/Icon.vue'
@ -432,6 +455,12 @@ const applyFilters = () => {
loadStats()
loadModelStats(modelDistributionSource.value, true)
loadChartData()
errPage.value = 1
if (activeTab.value === 'errors') {
loadAdminErrors()
} else {
errRows.value = []
}
}
const refreshData = () => {
invalidateModelStatsCache()
@ -439,6 +468,7 @@ const refreshData = () => {
loadStats(true)
loadModelStats(modelDistributionSource.value, true)
loadChartData()
if (activeTab.value === 'errors') loadAdminErrors()
}
const resetFilters = () => {
const range = getLast24HoursRangeDates()
@ -592,6 +622,50 @@ const loadSavedColumns = () => {
}
}
// Error tab state
const activeTab = ref<'usage' | 'errors'>('usage')
const errRows = ref<OpsErrorLog[]>([])
const errLoading = ref(false)
const errPage = ref(1)
const errPageSize = ref(20)
const errTotal = ref(0)
const showErrorModal = ref(false)
const selectedErrorId = ref<number | null>(null)
// 'YYYY-MM-DDT00:00:00' UTC 'T00:00:00Z'
const toRFC3339 = (d: string | undefined, endOfDay = false): string | undefined =>
d ? new Date(d + (endOfDay ? 'T23:59:59.999' : 'T00:00:00')).toISOString() : undefined
const loadAdminErrors = async () => {
errLoading.value = true
try {
const resp = await listErrorLogs({
page: errPage.value,
page_size: errPageSize.value,
view: 'all',
start_time: toRFC3339(filters.value.start_date),
end_time: toRFC3339(filters.value.end_date, true),
user_id: filters.value.user_id ?? undefined,
api_key_id: filters.value.api_key_id ?? undefined,
account_id: filters.value.account_id ?? undefined,
group_id: filters.value.group_id ?? undefined,
model: filters.value.model || undefined,
})
errRows.value = resp.items
errTotal.value = resp.total
} catch (error) {
console.error('Failed to load admin errors:', error)
appStore.showError(t('usage.errors.failedToLoad'))
} finally {
errLoading.value = false
}
}
const onErrPage = (p: number) => { errPage.value = p; loadAdminErrors() }
const onErrPageSize = (s: number) => { errPageSize.value = s; errPage.value = 1; loadAdminErrors() }
const openError = (id: number) => { selectedErrorId.value = id; showErrorModal.value = true }
const switchToErrorsTab = () => { activeTab.value = 'errors'; if (errRows.value.length === 0) loadAdminErrors() }
const showColumnDropdown = ref(false)
const columnDropdownRef = ref<HTMLElement | null>(null)

View File

@ -3,7 +3,7 @@ import { flushPromises, mount } from '@vue/test-utils'
import UsageView from '../UsageView.vue'
const { list, getStats, getSnapshotV2, getById, getModelStats } = vi.hoisted(() => {
const { list, getStats, getSnapshotV2, getById, getModelStats, listErrorLogs } = vi.hoisted(() => {
vi.stubGlobal('localStorage', {
getItem: vi.fn(() => null),
setItem: vi.fn(),
@ -16,6 +16,7 @@ const { list, getStats, getSnapshotV2, getById, getModelStats } = vi.hoisted(()
getSnapshotV2: vi.fn(),
getById: vi.fn(),
getModelStats: vi.fn(),
listErrorLogs: vi.fn(),
}
})
@ -55,6 +56,10 @@ vi.mock('@/api/admin/usage', () => ({
},
}))
vi.mock('@/api/admin/ops', () => ({
listErrorLogs,
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({
showError: vi.fn(),
@ -289,3 +294,61 @@ describe('admin UsageView handleUserClick', () => {
expect(getById).toHaveBeenCalledWith(2, true)
})
})
describe('admin UsageView errors tab filter forwarding', () => {
beforeEach(() => {
vi.useFakeTimers()
list.mockReset()
getStats.mockReset()
getSnapshotV2.mockReset()
getModelStats.mockReset()
listErrorLogs.mockReset()
list.mockResolvedValue({ items: [], total: 0, pages: 0 })
getStats.mockResolvedValue({
total_requests: 0, total_input_tokens: 0, total_output_tokens: 0,
total_cache_tokens: 0, total_tokens: 0, total_cost: 0, total_actual_cost: 0, average_duration_ms: 0,
})
getSnapshotV2.mockResolvedValue({ trend: [], models: [], groups: [] })
getModelStats.mockResolvedValue({ models: [] })
listErrorLogs.mockResolvedValue({ items: [], total: 0, pages: 0 })
})
afterEach(() => {
vi.useRealTimers()
})
it('forwards model/account_id/group_id to listErrorLogs on the errors tab', async () => {
const wrapper = mount(UsageView, {
global: { stubs: {
AppLayout: AppLayoutStub, UsageStatsCards: true, UsageFilters: UsageFiltersStub,
UsageTable: true, UsageExportProgress: true, UsageCleanupDialog: true,
UserBalanceHistoryModal: true, AuditLogModal: true, Pagination: true, Select: true,
DateRangePicker: true, Icon: true, TokenUsageTrend: true,
ModelDistributionChart: true, GroupDistributionChart: true, EndpointDistributionChart: true,
OpsErrorLogTable: true, OpsErrorDetailModal: true,
} },
})
vi.advanceTimersByTime(120)
await flushPromises()
// 模拟用户在过滤器里选择了模型/账户/分组
const vm = wrapper.vm as any
vm.filters.model = 'gpt-5.3-codex'
vm.filters.account_id = 7
vm.filters.group_id = 3
await flushPromises()
// 切换到「错误请求」标签(第二个 .tab 按钮)触发 loadAdminErrors
const tabs = wrapper.findAll('button.tab')
await tabs[1].trigger('click')
await flushPromises()
expect(listErrorLogs).toHaveBeenCalledWith(expect.objectContaining({
view: 'all',
model: 'gpt-5.3-codex',
account_id: 7,
group_id: 3,
}))
})
})

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

View File

@ -32,6 +32,12 @@
<th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
{{ t('admin.ops.errorLog.user') }}
</th>
<th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
{{ t('admin.ops.errorLog.apiKey') }}
</th>
<th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
{{ t('admin.ops.errorLog.account') }}
</th>
<th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
{{ t('admin.ops.errorLog.status') }}
</th>
@ -45,7 +51,7 @@
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-dark-700">
<tr v-if="rows.length === 0">
<td colspan="10" class="py-12 text-center text-sm text-gray-400 dark:text-dark-500">
<td colspan="12" class="py-12 text-center text-sm text-gray-400 dark:text-dark-500">
{{ t('admin.ops.errorLog.noErrors') }}
</td>
</tr>
@ -127,24 +133,40 @@
<span v-else class="text-xs text-gray-400">-</span>
</td>
<!-- User / Account -->
<!-- User -->
<td class="px-4 py-2">
<template v-if="isUpstreamRow(log)">
<el-tooltip v-if="log.account_id" :content="t('admin.ops.errorLog.accountId') + ' ' + log.account_id" placement="top" :show-after="500">
<span class="max-w-[100px] truncate text-xs font-medium text-gray-900 dark:text-gray-200">
{{ log.account_name || '-' }}
</span>
</el-tooltip>
<span v-else class="text-xs text-gray-400">-</span>
</template>
<template v-else>
<el-tooltip v-if="log.user_id" :content="t('admin.ops.errorLog.userId') + ' ' + log.user_id" placement="top" :show-after="500">
<span class="max-w-[100px] truncate text-xs font-medium text-gray-900 dark:text-gray-200">
{{ log.user_email || '-' }}
</span>
</el-tooltip>
<span v-else class="text-xs text-gray-400">-</span>
</template>
<el-tooltip v-if="log.user_id" :content="t('admin.ops.errorLog.userId') + ' ' + log.user_id" placement="top" :show-after="500">
<span class="block max-w-[140px] truncate text-xs font-medium text-gray-900 dark:text-gray-200">
{{ log.user_email || '-' }}
</span>
</el-tooltip>
<span v-else class="text-xs text-gray-400">-</span>
</td>
<!-- API Key -->
<td class="px-4 py-2">
<div v-if="log.api_key_id || log.api_key_name" class="flex max-w-[140px] items-center gap-1">
<span class="truncate text-xs font-medium text-gray-900 dark:text-gray-200" :title="log.api_key_name || ('#' + log.api_key_id)">
{{ log.api_key_name || ('#' + log.api_key_id) }}
</span>
<span
v-if="log.api_key_deleted"
class="flex-shrink-0 rounded px-1 py-0.5 text-[9px] 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.errorLog.keyDeletedBadge') }}
</span>
</div>
<span v-else class="text-xs text-gray-400">-</span>
</td>
<!-- Account -->
<td class="px-4 py-2">
<el-tooltip v-if="log.account_id" :content="t('admin.ops.errorLog.accountId') + ' ' + log.account_id" placement="top" :show-after="500">
<span class="block max-w-[120px] truncate text-xs font-medium text-gray-900 dark:text-gray-200">
{{ log.account_name || '-' }}
</span>
</el-tooltip>
<span v-else class="text-xs text-gray-400">-</span>
</td>
<!-- Status -->

View File

@ -0,0 +1,93 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import OpsErrorLogTable from '../OpsErrorLogTable.vue'
import zhLocale from '@/i18n/locales/zh'
import enLocale from '@/i18n/locales/en'
import type { OpsErrorLog } from '@/api/admin/ops'
vi.mock('vue-i18n', async (importOriginal) => {
const actual = await importOriginal<typeof import('vue-i18n')>()
return {
...actual,
useI18n: () => ({ t: (key: string) => key }),
}
})
const TooltipStub = { template: '<div><slot /></div>' }
const PaginationStub = { template: '<div class="pagination-stub" />' }
function mountTable(row: Partial<OpsErrorLog>) {
const base = {
id: 1,
created_at: '2026-06-05T23:59:50Z',
phase: 'upstream',
type: '',
error_owner: 'provider',
error_source: 'upstream_http',
severity: 'error',
status_code: 529,
platform: 'anthropic',
model: 'claude-opus-4-8',
resolved: false,
client_request_id: '',
request_id: 'req-1',
message: 'boom',
user_email: '',
account_name: '',
group_name: '',
...row,
} as OpsErrorLog
return mount(OpsErrorLogTable, {
props: { rows: [base], total: 1, loading: false, page: 1, pageSize: 20 },
global: { stubs: { 'el-tooltip': TooltipStub, Pagination: PaginationStub } },
})
}
describe('OpsErrorLogTable user/api-key/account columns', () => {
// 回归:上游错误行(phase=upstream, owner=provider)以前在单一「用户」列里只显示账号、
// 丢失用户;现在用户/API Key/账号各占独立列,三者同时可见。
it('renders user, api key and account in separate columns for an upstream row', () => {
const wrapper = mountTable({
user_id: 2,
user_email: 'alice@test.com',
api_key_id: 5,
api_key_name: 'my-key',
account_id: 9,
account_name: 'acct-A',
})
const text = wrapper.text()
expect(text).toContain('alice@test.com') // 用户列(上游行也显示用户)
expect(text).toContain('my-key') // API Key 列
expect(text).toContain('acct-A') // 账号列
})
it('shows the deleted badge for a soft-deleted api key', () => {
const wrapper = mountTable({
api_key_id: 5,
api_key_name: 'old-key',
api_key_deleted: true,
})
expect(wrapper.text()).toContain('old-key')
expect(wrapper.text()).toContain('admin.ops.errorLog.keyDeletedBadge')
})
})
// 防回归:组件用 admin.ops.errorLog.* 命名空间。若 i18n 键写错命名空间(如误放到
// errorDetail),真实 vue-i18n 会回退返回 key 本身 → 界面显示原始路径字符串。
// 这里用真实 locale 校验键确实可解析(返回译文而非 key)。
// 防回归:组件用 admin.ops.errorLog.* 命名空间。若键写错命名空间(如误放到
// errorDetail),界面会显示原始路径字符串而非译文。vitest 的 vue-i18n 为 runtime-only
// (无消息编译器,t() 对任何键都回退返回 key),故直接校验 locale 对象的命名空间含这些键。
describe('OpsErrorLogTable i18n keys exist in the errorLog namespace', () => {
const locales: Record<string, any> = { zh: zhLocale, en: enLocale }
for (const [name, msgs] of Object.entries(locales)) {
it(`has apiKey & keyDeletedBadge for ${name}`, () => {
const errorLog = msgs?.admin?.ops?.errorLog
expect(errorLog?.apiKey).toBeTruthy()
expect(errorLog?.keyDeletedBadge).toBeTruthy()
})
}
})

View File

@ -149,11 +149,27 @@
</template>
<template #table>
<DataTable
<!-- Tab 切换栏 -->
<div v-if="errorViewEnabled" class="mb-0 flex gap-2 border-b border-gray-200 px-4 pt-3 dark:border-dark-700">
<button class="tab" :class="{ 'tab-active': activeTab === 'usage' }" @click="activeTab = 'usage'">
{{ t('usage.tabs.usage') }}
</button>
<button class="tab" :class="{ 'tab-active': activeTab === 'errors' }" @click="switchToErrors">
{{ t('usage.tabs.errors') }}
</button>
</div>
<!-- 用量明细表 -->
<!-- flex 链让 DataTable .table-wrapper(flex:1)拿到有界高度以启用内部滚动
虚拟化器测高 race 导致的概率空白,已在 DataTable 内用就绪门控 + initialRect 兜底根治 -->
<div v-show="activeTab === 'usage'" class="flex min-h-0 flex-1 flex-col">
<DataTable
:columns="columns"
:data="usageLogs"
:loading="loading"
:server-side-sort="true"
:estimate-row-height="88"
:overscan="12"
default-sort-key="created_at"
default-sort-order="desc"
@sort="handleSort"
@ -224,14 +240,14 @@
<div class="inline-flex items-center gap-1">
<Icon name="arrowDown" size="sm" class="text-emerald-500" />
<span class="font-medium text-gray-900 dark:text-white">{{
row.input_tokens.toLocaleString()
(row.input_tokens ?? 0).toLocaleString()
}}</span>
</div>
<!-- Output -->
<div class="inline-flex items-center gap-1">
<Icon name="arrowUp" size="sm" class="text-violet-500" />
<span class="font-medium text-gray-900 dark:text-white">{{
row.output_tokens.toLocaleString()
(row.output_tokens ?? 0).toLocaleString()
}}</span>
</div>
</div>
@ -280,7 +296,7 @@
<template #cell-cost="{ row }">
<div class="flex items-center gap-1.5 text-sm">
<span class="font-medium text-green-600 dark:text-green-400">
${{ row.actual_cost.toFixed(6) }}
${{ (row.actual_cost ?? 0).toFixed(6) }}
</span>
<!-- Cost Detail Tooltip -->
<div
@ -332,11 +348,27 @@
<EmptyState :message="t('usage.noRecords')" />
</template>
</DataTable>
</div>
<!-- 错误请求表 -->
<div v-if="errorViewEnabled" v-show="activeTab === 'errors'" class="flex min-h-0 flex-1 flex-col">
<UserErrorRequestsTable
:rows="errorRows"
:total="errorTotal"
:loading="errorLoading"
:page="errorPage"
:page-size="errorPageSize"
:api-keys="apiKeys"
@filter="onErrorFilter"
@update:page="onErrorPage"
@update:pageSize="onErrorPageSize"
/>
</div>
</template>
<template #pagination>
<Pagination
v-if="pagination.total > 0"
v-if="pagination.total > 0 && activeTab === 'usage'"
:page="pagination.page"
:total="pagination.total"
:page-size="pagination.page_size"
@ -550,7 +582,8 @@ import EmptyState from '@/components/common/EmptyState.vue'
import Select from '@/components/common/Select.vue'
import DateRangePicker from '@/components/common/DateRangePicker.vue'
import Icon from '@/components/icons/Icon.vue'
import type { UsageLog, ApiKey, UsageQueryParams, UsageStatsResponse } from '@/types'
import UserErrorRequestsTable from '@/components/user/UserErrorRequestsTable.vue'
import type { UsageLog, ApiKey, UsageQueryParams, UsageStatsResponse, UserErrorRequest } from '@/types'
import type { Column } from '@/components/common/types'
import { formatDateTime, formatReasoningEffort } from '@/utils/format'
import { getPersistedPageSize } from '@/composables/usePersistedPageSize'
@ -653,6 +686,12 @@ const onDateRangeChange = (range: {
filters.value.start_date = range.startDate
filters.value.end_date = range.endDate
applyFilters()
errorPage.value = 1
if (activeTab.value === 'errors') {
loadErrors()
} else {
errorRows.value = [] // errors tab
}
}
const pagination = reactive({
@ -666,7 +705,8 @@ const sortState = reactive({
sort_order: 'desc' as 'asc' | 'desc'
})
const formatDuration = (ms: number): string => {
const formatDuration = (ms: number | null | undefined): string => {
if (ms == null) return '-'
if (ms < 1000) return `${ms.toFixed(0)}ms`
return `${(ms / 1000).toFixed(2)}s`
}
@ -926,8 +966,8 @@ const exportToCSV = async () => {
log.cache_read_tokens,
log.cache_creation_tokens,
log.rate_multiplier,
log.actual_cost.toFixed(8),
log.total_cost.toFixed(8),
(log.actual_cost ?? 0).toFixed(8),
(log.total_cost ?? 0).toFixed(8),
log.first_token_ms ?? '',
log.duration_ms
].map(escapeCSVValue)
@ -988,6 +1028,52 @@ const hideTokenTooltip = () => {
tokenTooltipData.value = null
}
// Error Requests Tab
const activeTab = ref<'usage' | 'errors'>('usage')
const errorViewEnabled = computed(() => appStore.cachedPublicSettings?.allow_user_view_error_requests ?? false)
const errorRows = ref<UserErrorRequest[]>([])
const errorLoading = ref(false)
const errorPage = ref(1)
const errorPageSize = ref(20)
const errorTotal = ref(0)
const errorFilter = ref<{ model: string; category: string; api_key_id: number | null }>({ model: '', category: '', api_key_id: null })
const loadErrors = async () => {
errorLoading.value = true
try {
const resp = await usageAPI.listMyErrorRequests({
page: errorPage.value,
page_size: errorPageSize.value,
start_date: startDate.value,
end_date: endDate.value,
model: errorFilter.value.model || undefined,
category: errorFilter.value.category || undefined,
api_key_id: errorFilter.value.api_key_id ?? undefined,
})
errorRows.value = resp.items
errorTotal.value = resp.total
} catch (error) {
console.error('[UsageView] loadErrors failed:', error)
appStore.showError(t('usage.errors.failedToLoad'))
} finally {
errorLoading.value = false
}
}
const onErrorFilter = (f: { model: string; category: string; api_key_id: number | null }) => {
errorFilter.value = f
errorPage.value = 1
loadErrors()
}
const onErrorPage = (p: number) => { errorPage.value = p; loadErrors() }
const onErrorPageSize = (s: number) => { errorPageSize.value = s; errorPage.value = 1; loadErrors() }
const switchToErrors = () => {
activeTab.value = 'errors'
if (errorRows.value.length === 0) loadErrors()
}
onMounted(() => {
loadApiKeys()
loadUsageLogs()