Merge pull request #3042 from DaydreamCoding/feat/usage-error-requests
feat(usage/ops): 失败请求记录与展示(用户端 + 管理端)+ 错误日志 Key 归因
This commit is contained in:
commit
d895d765b6
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -252,6 +252,9 @@ type SystemSettings struct {
|
||||
|
||||
// 系统全局默认平台配额(key = platform,nil/缺省 = 不限制)
|
||||
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 {
|
||||
|
||||
@ -71,6 +71,49 @@ const (
|
||||
opsErrorLogBatchSize = 32
|
||||
)
|
||||
|
||||
// looksLikeSystemKey 粗筛"形似本系统 key"的输入:长度 16-128 且仅含 [a-zA-Z0-9_-]。
|
||||
// 不用前缀匹配(APIKeyPrefix 可配置)。用于反查审计表前挡掉随机扫描的乱码输入。
|
||||
func looksLikeSystemKey(key string) bool {
|
||||
if len(key) < 16 || len(key) > 128 {
|
||||
return false
|
||||
}
|
||||
for _, c := range key {
|
||||
allowed := (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
|
||||
(c >= '0' && c <= '9') || c == '_' || c == '-'
|
||||
if !allowed {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// keyPrefix 返回脱敏前缀(前 n 个字符);不足 n 则原样返回。
|
||||
func keyPrefix(key string, n int) string {
|
||||
if len(key) <= n {
|
||||
return key
|
||||
}
|
||||
return key[:n]
|
||||
}
|
||||
|
||||
// extractAttemptedKey 按认证中间件同样的顺序从请求头提取提交的 key 明文。
|
||||
// 与 api_key_auth.go:43-59 一致:Authorization 仅取 Bearer scheme,非 Bearer 则忽略并继续 x-api-key → x-goog-api-key。
|
||||
func extractAttemptedKey(c *gin.Context) string {
|
||||
if h := c.GetHeader("Authorization"); h != "" {
|
||||
parts := strings.SplitN(h, " ", 2)
|
||||
if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") {
|
||||
return strings.TrimSpace(parts[1])
|
||||
}
|
||||
// 非 Bearer:与中间件一致,忽略 Authorization,继续尝试其它 header(不在此 return)。
|
||||
}
|
||||
if k := c.GetHeader("x-api-key"); k != "" {
|
||||
return strings.TrimSpace(k)
|
||||
}
|
||||
if k := c.GetHeader("x-goog-api-key"); k != "" {
|
||||
return strings.TrimSpace(k)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type opsErrorLogJob struct {
|
||||
ops *service.OpsService
|
||||
entry *service.OpsInsertErrorLogInput
|
||||
@ -546,7 +589,7 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
apiKey, _ := middleware2.GetAPIKeyFromContext(c)
|
||||
apiKey := getOpsAPIKey(c)
|
||||
clientRequestID, _ := c.Request.Context().Value(ctxkey.ClientRequestID).(string)
|
||||
|
||||
model, _ := c.Get(opsModelKey)
|
||||
@ -721,6 +764,7 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc {
|
||||
|
||||
if apiKey != nil {
|
||||
entry.APIKeyID = &apiKey.ID
|
||||
entry.APIKeyPrefix = keyPrefix(apiKey.Key, 8)
|
||||
if apiKey.User != nil {
|
||||
entry.UserID = &apiKey.User.ID
|
||||
}
|
||||
@ -765,7 +809,7 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
apiKey, _ := middleware2.GetAPIKeyFromContext(c)
|
||||
apiKey := getOpsAPIKey(c)
|
||||
|
||||
clientRequestID, _ := c.Request.Context().Value(ctxkey.ClientRequestID).(string)
|
||||
|
||||
@ -911,6 +955,8 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc {
|
||||
|
||||
if apiKey != nil {
|
||||
entry.APIKeyID = &apiKey.ID
|
||||
// 有效(未删除)key 报错时快照前缀,key 之后被删也保留;与 INVALID_API_KEY 的 attempted_key_prefix 互斥。
|
||||
entry.APIKeyPrefix = keyPrefix(apiKey.Key, 8)
|
||||
if apiKey.User != nil {
|
||||
entry.UserID = &apiKey.User.ID
|
||||
}
|
||||
@ -929,6 +975,22 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc {
|
||||
entry.ClientIP = &clientIP
|
||||
}
|
||||
|
||||
// 已删除 key 归因:仅 INVALID_API_KEY 才尝试。响应已写出,此处不阻塞客户端。
|
||||
if parsed.Code == opsCodeInvalidAPIKey {
|
||||
if attemptedKey := extractAttemptedKey(c); attemptedKey != "" {
|
||||
entry.AttemptedKeyPrefix = keyPrefix(attemptedKey, 8)
|
||||
if looksLikeSystemKey(attemptedKey) {
|
||||
if res, lookupErr := ops.LookupDeletedKeyAudit(c.Request.Context(), attemptedKey); lookupErr != nil {
|
||||
log.Printf("[OpsErrorLogger] LookupDeletedKeyAudit failed: %v", lookupErr)
|
||||
} else if res != nil {
|
||||
owner := res.UserID
|
||||
entry.DeletedKeyOwnerUserID = &owner
|
||||
entry.DeletedKeyName = res.KeyName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enqueueOpsErrorLog(ops, entry)
|
||||
}
|
||||
}
|
||||
@ -1035,6 +1097,20 @@ func parseOpsErrorResponse(body []byte) parsedOpsError {
|
||||
return parsedOpsError{Message: truncateString(string(body), 1024)}
|
||||
}
|
||||
|
||||
// getOpsAPIKey 返回用于 Ops 错误日志的 API Key:优先取已鉴权写入的正式 key;
|
||||
// 鉴权早退(分组停用/删除、Key 停用/过期/额度、用户停用、IP 限制等)时,
|
||||
// 正式 key 尚未写入,回退到 middleware 写入的 ops fallback key
|
||||
// (含 User/Group/Platform),从而让日志能展示 用户/分组/平台。
|
||||
func getOpsAPIKey(c *gin.Context) *service.APIKey {
|
||||
if apiKey, ok := middleware2.GetAPIKeyFromContext(c); ok && apiKey != nil {
|
||||
return apiKey
|
||||
}
|
||||
if apiKey, ok := middleware2.GetOpsFallbackAPIKey(c); ok && apiKey != nil {
|
||||
return apiKey
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveOpsPlatform(apiKey *service.APIKey, fallback string) string {
|
||||
if apiKey != nil && apiKey.Group != nil && apiKey.Group.Platform != "" {
|
||||
return apiKey.Group.Platform
|
||||
|
||||
118
backend/internal/handler/ops_error_logger_attribution_test.go
Normal file
118
backend/internal/handler/ops_error_logger_attribution_test.go
Normal file
@ -0,0 +1,118 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestLooksLikeSystemKey(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"sk-abcdef0123456789", true},
|
||||
{"ABCdef_-0123456789", true},
|
||||
{"short", false},
|
||||
{"with space xxxxxxxxxx", false},
|
||||
{"汉字key1234567890", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := looksLikeSystemKey(c.in); got != c.want {
|
||||
t.Errorf("looksLikeSystemKey(%q)=%v want %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
long := make([]byte, 129)
|
||||
for i := range long {
|
||||
long[i] = 'a'
|
||||
}
|
||||
if looksLikeSystemKey(string(long)) {
|
||||
t.Errorf("129-char key should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyPrefix(t *testing.T) {
|
||||
if got := keyPrefix("sk-3f2a9c7e", 8); got != "sk-3f2a9" {
|
||||
t.Errorf("keyPrefix=%q want %q", got, "sk-3f2a9")
|
||||
}
|
||||
if got := keyPrefix("abc", 8); got != "abc" {
|
||||
t.Errorf("short key should be returned as-is, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractAttemptedKey(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
headers map[string]string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "Bearer in Authorization",
|
||||
headers: map[string]string{"Authorization": "Bearer sk-testkey0123456789"},
|
||||
want: "sk-testkey0123456789",
|
||||
},
|
||||
{
|
||||
name: "Bearer case-insensitive",
|
||||
headers: map[string]string{"Authorization": "BEARER sk-testkey0123456789"},
|
||||
want: "sk-testkey0123456789",
|
||||
},
|
||||
{
|
||||
name: "x-api-key header",
|
||||
headers: map[string]string{"x-api-key": "sk-xapikey0123456789"},
|
||||
want: "sk-xapikey0123456789",
|
||||
},
|
||||
{
|
||||
name: "x-goog-api-key header",
|
||||
headers: map[string]string{"x-goog-api-key": "sk-goog0123456789"},
|
||||
want: "sk-goog0123456789",
|
||||
},
|
||||
{
|
||||
name: "Authorization takes priority over x-api-key",
|
||||
headers: map[string]string{"Authorization": "Bearer sk-auth0123456789", "x-api-key": "sk-xapi0123456789"},
|
||||
want: "sk-auth0123456789",
|
||||
},
|
||||
{
|
||||
name: "x-api-key takes priority over x-goog-api-key",
|
||||
headers: map[string]string{"x-api-key": "sk-xapi0123456789", "x-goog-api-key": "sk-goog0123456789"},
|
||||
want: "sk-xapi0123456789",
|
||||
},
|
||||
{
|
||||
name: "no key headers",
|
||||
headers: map[string]string{},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "Bearer with leading/trailing spaces trimmed",
|
||||
headers: map[string]string{"Authorization": "Bearer sk-trimmed0123456789 "},
|
||||
want: "sk-trimmed0123456789",
|
||||
},
|
||||
{
|
||||
// 非 Bearer Authorization 应被忽略,继续 fall-through 到 x-api-key(与认证中间件一致)
|
||||
name: "non-Bearer Authorization falls through to x-api-key",
|
||||
headers: map[string]string{"Authorization": "junk-not-bearer", "x-api-key": "sk-realkey1234567"},
|
||||
want: "sk-realkey1234567",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
for k, v := range tc.headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
c.Request = req
|
||||
|
||||
got := extractAttemptedKey(c)
|
||||
if got != tc.want {
|
||||
t.Errorf("extractAttemptedKey(%v) = %q, want %q", tc.headers, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -931,3 +931,45 @@ func TestSetOpsEndpointContext_NilContext(t *testing.T) {
|
||||
setOpsEndpointContext(nil, "model", int16(1))
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetOpsAPIKeyFallsBackToOpsFallbackKey(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
|
||||
// 主 key 缺席(鉴权早退场景):返回 nil。
|
||||
require.Nil(t, getOpsAPIKey(c))
|
||||
|
||||
// 写入 ops 专用 fallback key 后应能取到,且带齐 user/group。
|
||||
groupID := int64(55)
|
||||
apiKey := &service.APIKey{
|
||||
ID: 100,
|
||||
GroupID: &groupID,
|
||||
User: &service.User{ID: 7},
|
||||
Group: &service.Group{ID: groupID, Platform: service.PlatformAnthropic},
|
||||
}
|
||||
c.Set(string(middleware2.ContextKeyOpsFallbackAPIKey), apiKey)
|
||||
|
||||
got := getOpsAPIKey(c)
|
||||
require.NotNil(t, got)
|
||||
require.Equal(t, int64(100), got.ID)
|
||||
require.NotNil(t, got.User)
|
||||
require.Equal(t, int64(7), got.User.ID)
|
||||
require.NotNil(t, got.Group)
|
||||
require.Equal(t, service.PlatformAnthropic, got.Group.Platform)
|
||||
}
|
||||
|
||||
func TestGetOpsAPIKeyPrefersPrimaryContextKey(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
|
||||
primary := &service.APIKey{ID: 1}
|
||||
fallback := &service.APIKey{ID: 2}
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), primary)
|
||||
c.Set(string(middleware2.ContextKeyOpsFallbackAPIKey), fallback)
|
||||
|
||||
got := getOpsAPIKey(c)
|
||||
require.NotNil(t, got)
|
||||
require.Equal(t, int64(1), got.ID, "已鉴权请求应优先使用正式 api key")
|
||||
}
|
||||
|
||||
@ -98,6 +98,8 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
|
||||
AffiliateEnabled: settings.AffiliateEnabled,
|
||||
|
||||
RiskControlEnabled: settings.RiskControlEnabled,
|
||||
|
||||
AllowUserViewErrorRequests: settings.AllowUserViewErrorRequests,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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})
|
||||
|
||||
@ -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})
|
||||
|
||||
@ -3,6 +3,7 @@ package repository
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@ -304,6 +305,66 @@ func (r *apiKeyRepository) Delete(ctx context.Context, id int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteWithAudit 在同一事务内:
|
||||
// 1. 把(明文 key、所有者、key 名称)写入 deleted_api_key_audits;
|
||||
// 2. 软删除该 key(tombstone 覆盖 key 列以释放唯一约束)。
|
||||
//
|
||||
// 保证"被删除的 key 一定能反查到所有者"。事务模式与 group_repo.DeleteCascade 一致。
|
||||
func (r *apiKeyRepository) DeleteWithAudit(ctx context.Context, id int64) error {
|
||||
tombstoneKey := fmt.Sprintf("__deleted__%d__%d", id, time.Now().UnixNano())
|
||||
|
||||
tx, err := r.client.Tx(ctx)
|
||||
if err != nil && !errors.Is(err, dbent.ErrTxStarted) {
|
||||
return err
|
||||
}
|
||||
exec := r.client
|
||||
if err == nil {
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
exec = tx.Client()
|
||||
}
|
||||
// err == dbent.ErrTxStarted 时复用当前事务(exec = r.client)。
|
||||
|
||||
// 1. 审计:数据源即 api_keys 当前行;WHERE deleted_at IS NULL 保证只对未删除行写一次。
|
||||
if _, err := exec.ExecContext(ctx, `
|
||||
INSERT INTO deleted_api_key_audits (key, api_key_id, user_id, key_name, deleted_at)
|
||||
SELECT key, id, user_id, name, NOW()
|
||||
FROM api_keys
|
||||
WHERE id = $1 AND deleted_at IS NULL`, id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. 软删除(tombstone 覆盖 key)。
|
||||
res, err := exec.ExecContext(ctx, `
|
||||
UPDATE api_keys
|
||||
SET key = $1, deleted_at = NOW(), updated_at = NOW()
|
||||
WHERE id = $2 AND deleted_at IS NULL`, tombstoneKey, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
// 并发/重复删除:记录已存在(已软删)则幂等返回 nil(defer 回滚空事务),否则 NotFound。
|
||||
exists, existErr := r.client.APIKey.Query().
|
||||
Where(apikey.IDEQ(id)).
|
||||
Exist(mixins.SkipSoftDelete(ctx))
|
||||
if existErr != nil {
|
||||
return existErr
|
||||
}
|
||||
if exists {
|
||||
return nil
|
||||
}
|
||||
return service.ErrAPIKeyNotFound
|
||||
}
|
||||
|
||||
if tx != nil {
|
||||
return tx.Commit()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *apiKeyRepository) ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams, filters service.APIKeyListFilters) ([]service.APIKey, *pagination.PaginationResult, error) {
|
||||
q := r.activeQuery().Where(apikey.UserIDEQ(userID))
|
||||
|
||||
|
||||
@ -555,3 +555,46 @@ func TestIncrementQuotaUsed_Concurrent(t *testing.T) {
|
||||
require.Equal(t, float64(goroutines)*increment, got.QuotaUsed,
|
||||
"并发递增后总和应为 %v,实际为 %v", float64(goroutines)*increment, got.QuotaUsed)
|
||||
}
|
||||
|
||||
func (s *APIKeyRepoSuite) TestDeleteWithAudit_WritesAuditAndSoftDeletes() {
|
||||
user := s.mustCreateUser("delwithaudit@test.com")
|
||||
key := &service.APIKey{
|
||||
UserID: user.ID,
|
||||
Key: "sk-del-audit-1",
|
||||
Name: "Audit Me",
|
||||
Status: service.StatusActive,
|
||||
}
|
||||
s.Require().NoError(s.repo.Create(s.ctx, key))
|
||||
|
||||
s.Require().NoError(s.repo.DeleteWithAudit(s.ctx, key.ID))
|
||||
|
||||
_, err := s.repo.GetByID(s.ctx, key.ID)
|
||||
s.Require().Error(err)
|
||||
|
||||
rows, qErr := s.client.QueryContext(s.ctx,
|
||||
`SELECT key, key_name, user_id, api_key_id FROM deleted_api_key_audits WHERE api_key_id = $1`, key.ID)
|
||||
s.Require().NoError(qErr)
|
||||
defer rows.Close()
|
||||
s.Require().True(rows.Next(), "expected one audit row")
|
||||
var auditKey, auditName string
|
||||
var auditUserID, auditAPIKeyID int64
|
||||
s.Require().NoError(rows.Scan(&auditKey, &auditName, &auditUserID, &auditAPIKeyID))
|
||||
s.Require().Equal("sk-del-audit-1", auditKey)
|
||||
s.Require().Equal("Audit Me", auditName)
|
||||
s.Require().Equal(user.ID, auditUserID)
|
||||
s.Require().Equal(key.ID, auditAPIKeyID)
|
||||
}
|
||||
|
||||
func (s *APIKeyRepoSuite) TestDeleteWithAudit_RepeatIsIdempotent() {
|
||||
user := s.mustCreateUser("delwithaudit-idem@test.com")
|
||||
key := &service.APIKey{UserID: user.ID, Key: "sk-del-audit-2", Name: "K", Status: service.StatusActive}
|
||||
s.Require().NoError(s.repo.Create(s.ctx, key))
|
||||
|
||||
s.Require().NoError(s.repo.DeleteWithAudit(s.ctx, key.ID))
|
||||
s.Require().NoError(s.repo.DeleteWithAudit(s.ctx, key.ID))
|
||||
}
|
||||
|
||||
func (s *APIKeyRepoSuite) TestDeleteWithAudit_NotFound() {
|
||||
err := s.repo.DeleteWithAudit(s.ctx, 999999)
|
||||
s.Require().ErrorIs(err, service.ErrAPIKeyNotFound)
|
||||
}
|
||||
|
||||
95
backend/internal/repository/ops_error_where_test.go
Normal file
95
backend/internal/repository/ops_error_where_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,94 @@
|
||||
//go:build integration
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestGetErrorLogByID_DeletedKeyOwner 验证:
|
||||
// 1. 带 deleted_key_owner_user_id 的记录能正确 JOIN users 返回 DeletedKeyOwnerEmail
|
||||
// 2. 新列全为 NULL 的普通记录 Scan 不报错,这些字段为空/nil
|
||||
func TestGetErrorLogByID_DeletedKeyOwner(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
_, _ = integrationDB.ExecContext(ctx, "TRUNCATE ops_error_logs RESTART IDENTITY CASCADE")
|
||||
|
||||
repo := NewOpsRepository(integrationDB).(*opsRepository)
|
||||
|
||||
// ── Case 1: 带 deleted_key_owner 信息的记录 ──────────────────────────────
|
||||
owner := mustCreateUser(t, integrationEntClient, &service.User{
|
||||
Email: "deleted-key-owner-" + time.Now().Format("150405.000000000") + "@example.com",
|
||||
})
|
||||
|
||||
var insertedID int64
|
||||
err := integrationDB.QueryRowContext(ctx, `
|
||||
INSERT INTO ops_error_logs (
|
||||
error_phase, error_type, severity, status_code, created_at,
|
||||
attempted_key_prefix, deleted_key_owner_user_id, deleted_key_name
|
||||
) VALUES (
|
||||
'auth', 'INVALID_API_KEY', 'error', 401, NOW(),
|
||||
'sk-test-abc', $1, 'my-deleted-key'
|
||||
) RETURNING id`,
|
||||
owner.ID,
|
||||
).Scan(&insertedID)
|
||||
require.NoError(t, err)
|
||||
require.Positive(t, insertedID)
|
||||
|
||||
detail, err := repo.GetErrorLogByID(ctx, insertedID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, detail)
|
||||
|
||||
require.Equal(t, "sk-test-abc", detail.AttemptedKeyPrefix)
|
||||
require.NotNil(t, detail.DeletedKeyOwnerUserID)
|
||||
require.Equal(t, owner.ID, *detail.DeletedKeyOwnerUserID)
|
||||
require.Equal(t, owner.Email, detail.DeletedKeyOwnerEmail)
|
||||
require.Equal(t, "my-deleted-key", detail.DeletedKeyName)
|
||||
|
||||
// ── Case 2: 新列全为 NULL 的普通错误记录 ──────────────────────────────────
|
||||
var plainID int64
|
||||
err = integrationDB.QueryRowContext(ctx, `
|
||||
INSERT INTO ops_error_logs (
|
||||
error_phase, error_type, severity, status_code, created_at
|
||||
) VALUES (
|
||||
'upstream', 'upstream_error', 'error', 500, NOW()
|
||||
) RETURNING id`,
|
||||
).Scan(&plainID)
|
||||
require.NoError(t, err)
|
||||
require.Positive(t, plainID)
|
||||
|
||||
plain, err := repo.GetErrorLogByID(ctx, plainID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, plain)
|
||||
|
||||
require.Empty(t, plain.AttemptedKeyPrefix, "no prefix for plain error")
|
||||
require.Nil(t, plain.DeletedKeyOwnerUserID, "no owner for plain error")
|
||||
require.Empty(t, plain.DeletedKeyOwnerEmail, "no owner email for plain error")
|
||||
require.Empty(t, plain.DeletedKeyName, "no key name for plain error")
|
||||
require.Empty(t, plain.APIKeyPrefix, "no api key prefix for plain error")
|
||||
|
||||
// ── Case 3: 有效(未删除)key 报错,经 InsertErrorLog 快照 api_key_prefix ──────
|
||||
// 走真实 InsertErrorLog 写入路径(覆盖新列 + $41 占位符),再 GetErrorLogByID 读回。
|
||||
validID, err := repo.InsertErrorLog(ctx, &service.OpsInsertErrorLogInput{
|
||||
ErrorPhase: "request",
|
||||
ErrorType: "api_error",
|
||||
Severity: "error",
|
||||
StatusCode: 402,
|
||||
CreatedAt: time.Now(),
|
||||
APIKeyPrefix: "sk-valid",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Positive(t, validID)
|
||||
|
||||
valid, err := repo.GetErrorLogByID(ctx, validID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, valid)
|
||||
|
||||
require.Equal(t, "sk-valid", valid.APIKeyPrefix)
|
||||
require.Empty(t, valid.AttemptedKeyPrefix, "attempted prefix and api key prefix are mutually exclusive")
|
||||
require.Nil(t, valid.DeletedKeyOwnerUserID, "valid key error has no deleted owner")
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
//go:build integration
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOpsRepositoryLookupDeletedKeyAudit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
_, _ = integrationDB.ExecContext(ctx, "TRUNCATE deleted_api_key_audits RESTART IDENTITY")
|
||||
repo := NewOpsRepository(integrationDB).(*opsRepository)
|
||||
|
||||
// 同一 key 两条审计,取最近一条(deleted_at DESC, id DESC)
|
||||
_, err := integrationDB.ExecContext(ctx, `
|
||||
INSERT INTO deleted_api_key_audits (key, api_key_id, user_id, key_name, deleted_at)
|
||||
VALUES ('sk-lookup-1', 10, 100, 'old', $1),
|
||||
('sk-lookup-1', 11, 200, 'new', $2)`,
|
||||
time.Now().Add(-time.Hour), time.Now())
|
||||
require.NoError(t, err)
|
||||
|
||||
res, err := repo.LookupDeletedKeyAudit(ctx, "sk-lookup-1")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, res)
|
||||
require.Equal(t, int64(200), res.UserID)
|
||||
require.Equal(t, "new", res.KeyName)
|
||||
|
||||
// 未命中返回 nil
|
||||
miss, err := repo.LookupDeletedKeyAudit(ctx, "sk-never-existed")
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, miss)
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -76,6 +76,10 @@ func apiKeyAuthWithSubscription(apiKeyService *service.APIKeyService, subscripti
|
||||
return
|
||||
}
|
||||
|
||||
// apiKey 已加载(含 User/Group)。即便后续因分组停用/Key 停用/用户停用/
|
||||
// IP 限制等早退中断,也让 Ops 错误日志能回退取到 user/group/platform。
|
||||
SetOpsFallbackAPIKey(c, apiKey)
|
||||
|
||||
// ── 3. 基础鉴权(始终执行) ─────────────────────────────────
|
||||
|
||||
// disabled / 未知状态 → 无条件拦截(expired 和 quota_exhausted 留给计费阶段)
|
||||
@ -237,6 +241,26 @@ func GetAPIKeyFromContext(c *gin.Context) (*service.APIKey, bool) {
|
||||
return apiKey, ok
|
||||
}
|
||||
|
||||
// SetOpsFallbackAPIKey 记录已加载的 API Key,供 Ops 错误日志在鉴权早退时回退使用。
|
||||
// 与 ContextKeyAPIKey 区分:写入它不代表请求已通过鉴权,因此不影响 handler、
|
||||
// 审计日志等对“已鉴权”的判断。
|
||||
func SetOpsFallbackAPIKey(c *gin.Context, apiKey *service.APIKey) {
|
||||
if c == nil || apiKey == nil {
|
||||
return
|
||||
}
|
||||
c.Set(string(ContextKeyOpsFallbackAPIKey), apiKey)
|
||||
}
|
||||
|
||||
// GetOpsFallbackAPIKey 读取 Ops 错误日志专用的回退 API Key。
|
||||
func GetOpsFallbackAPIKey(c *gin.Context) (*service.APIKey, bool) {
|
||||
value, exists := c.Get(string(ContextKeyOpsFallbackAPIKey))
|
||||
if !exists {
|
||||
return nil, false
|
||||
}
|
||||
apiKey, ok := value.(*service.APIKey)
|
||||
return apiKey, ok
|
||||
}
|
||||
|
||||
// GetSubscriptionFromContext 从上下文中获取订阅信息
|
||||
func GetSubscriptionFromContext(c *gin.Context) (*service.UserSubscription, bool) {
|
||||
value, exists := c.Get(string(ContextKeySubscription))
|
||||
|
||||
@ -42,6 +42,10 @@ func APIKeyAuthWithSubscriptionGoogle(apiKeyService *service.APIKeyService, subs
|
||||
return
|
||||
}
|
||||
|
||||
// 同 api_key_auth.go:早退中断前也写入 Ops 回退 key,便于错误日志展示
|
||||
// user/group/platform。
|
||||
SetOpsFallbackAPIKey(c, apiKey)
|
||||
|
||||
if !apiKey.IsActive() {
|
||||
abortWithGoogleError(c, 401, "API key is disabled")
|
||||
return
|
||||
|
||||
@ -56,6 +56,9 @@ func (f fakeAPIKeyRepo) Update(ctx context.Context, key *service.APIKey) error {
|
||||
func (f fakeAPIKeyRepo) Delete(ctx context.Context, id int64) error {
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
func (f fakeAPIKeyRepo) DeleteWithAudit(ctx context.Context, id int64) error {
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
func (f fakeAPIKeyRepo) ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams, _ service.APIKeyListFilters) ([]service.APIKey, *pagination.PaginationResult, error) {
|
||||
return nil, nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
@ -419,6 +419,138 @@ func TestAPIKeyAuthRejectsUnavailableGroup(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIKeyAuthSetsOpsFallbackKeyOnEarlyAbort(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
groupID := int64(101)
|
||||
user := &service.User{
|
||||
ID: 7,
|
||||
Role: service.RoleUser,
|
||||
Status: service.StatusActive,
|
||||
Balance: 10,
|
||||
Concurrency: 3,
|
||||
}
|
||||
apiKey := &service.APIKey{
|
||||
ID: 100,
|
||||
UserID: user.ID,
|
||||
GroupID: &groupID,
|
||||
Key: "test-key",
|
||||
Status: service.StatusActive,
|
||||
User: user,
|
||||
Group: &service.Group{
|
||||
ID: groupID,
|
||||
Name: "disabled",
|
||||
Status: service.StatusDisabled,
|
||||
Platform: service.PlatformAnthropic,
|
||||
Hydrated: true,
|
||||
},
|
||||
}
|
||||
apiKeyRepo := &stubApiKeyRepo{
|
||||
getByKey: func(ctx context.Context, key string) (*service.APIKey, error) {
|
||||
if key != apiKey.Key {
|
||||
return nil, service.ErrAPIKeyNotFound
|
||||
}
|
||||
clone := *apiKey
|
||||
return &clone, nil
|
||||
},
|
||||
}
|
||||
cfg := &config.Config{RunMode: config.RunModeStandard}
|
||||
apiKeyService := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, nil, cfg)
|
||||
|
||||
router := gin.New()
|
||||
var fallback *service.APIKey
|
||||
var fallbackOK bool
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Next()
|
||||
fallback, fallbackOK = GetOpsFallbackAPIKey(c)
|
||||
})
|
||||
router.Use(gin.HandlerFunc(NewAPIKeyAuthMiddleware(apiKeyService, nil, cfg)))
|
||||
router.GET("/t", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/t", nil)
|
||||
req.Header.Set("x-api-key", apiKey.Key)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
// 分组停用 → 早退中断,但 ops fallback key 仍应写入,含 user/group/platform。
|
||||
require.Equal(t, http.StatusForbidden, w.Code)
|
||||
require.Contains(t, w.Body.String(), "GROUP_DISABLED")
|
||||
require.True(t, fallbackOK, "鉴权早退时也应写入 ops fallback api key")
|
||||
require.NotNil(t, fallback)
|
||||
require.Equal(t, apiKey.ID, fallback.ID)
|
||||
require.NotNil(t, fallback.User)
|
||||
require.Equal(t, user.ID, fallback.User.ID)
|
||||
require.NotNil(t, fallback.GroupID)
|
||||
require.Equal(t, groupID, *fallback.GroupID)
|
||||
require.NotNil(t, fallback.Group)
|
||||
require.Equal(t, service.PlatformAnthropic, fallback.Group.Platform)
|
||||
}
|
||||
|
||||
func TestAPIKeyAuthGoogleSetsOpsFallbackKeyOnEarlyAbort(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
groupID := int64(202)
|
||||
user := &service.User{
|
||||
ID: 9,
|
||||
Role: service.RoleUser,
|
||||
Status: service.StatusActive,
|
||||
Balance: 10,
|
||||
Concurrency: 3,
|
||||
}
|
||||
apiKey := &service.APIKey{
|
||||
ID: 200,
|
||||
UserID: user.ID,
|
||||
GroupID: &groupID,
|
||||
Key: "g-key",
|
||||
Status: service.StatusActive,
|
||||
User: user,
|
||||
Group: &service.Group{
|
||||
ID: groupID,
|
||||
Name: "disabled",
|
||||
Status: service.StatusDisabled,
|
||||
Platform: service.PlatformGemini,
|
||||
Hydrated: true,
|
||||
},
|
||||
}
|
||||
apiKeyRepo := &stubApiKeyRepo{
|
||||
getByKey: func(ctx context.Context, key string) (*service.APIKey, error) {
|
||||
if key != apiKey.Key {
|
||||
return nil, service.ErrAPIKeyNotFound
|
||||
}
|
||||
clone := *apiKey
|
||||
return &clone, nil
|
||||
},
|
||||
}
|
||||
cfg := &config.Config{RunMode: config.RunModeStandard}
|
||||
apiKeyService := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, nil, cfg)
|
||||
|
||||
router := gin.New()
|
||||
var fallback *service.APIKey
|
||||
var fallbackOK bool
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Next()
|
||||
fallback, fallbackOK = GetOpsFallbackAPIKey(c)
|
||||
})
|
||||
router.Use(gin.HandlerFunc(APIKeyAuthWithSubscriptionGoogle(apiKeyService, nil, cfg)))
|
||||
router.GET("/t", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/t", nil)
|
||||
req.Header.Set("x-goog-api-key", apiKey.Key)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusForbidden, w.Code)
|
||||
require.True(t, fallbackOK, "Google 鉴权早退时也应写入 ops fallback api key")
|
||||
require.NotNil(t, fallback)
|
||||
require.Equal(t, apiKey.ID, fallback.ID)
|
||||
require.NotNil(t, fallback.User)
|
||||
require.Equal(t, user.ID, fallback.User.ID)
|
||||
}
|
||||
|
||||
func TestRequireGroupAssignmentMarksUngroupedKeyBusinessLimited(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
@ -761,6 +893,10 @@ func (r *stubApiKeyRepo) Delete(ctx context.Context, id int64) error {
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (r *stubApiKeyRepo) DeleteWithAudit(ctx context.Context, id int64) error {
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (r *stubApiKeyRepo) ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams, _ service.APIKeyListFilters) ([]service.APIKey, *pagination.PaginationResult, error) {
|
||||
return nil, nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
@ -24,6 +24,11 @@ const (
|
||||
ContextKeySubscription ContextKey = "subscription"
|
||||
// ContextKeyForcePlatform 强制平台(用于 /antigravity 路由)
|
||||
ContextKeyForcePlatform ContextKey = "force_platform"
|
||||
// ContextKeyOpsFallbackAPIKey 运维错误日志专用回退键。
|
||||
// 鉴权早退(分组停用/删除、Key 停用/过期/额度、用户停用、IP 限制等)时,
|
||||
// apiKey 已加载但尚未写入 ContextKeyAPIKey;该键让 Ops 错误日志仍能取到
|
||||
// user/group/platform。仅供 Ops 错误日志读取,不代表请求已通过鉴权。
|
||||
ContextKeyOpsFallbackAPIKey ContextKey = "ops_fallback_api_key"
|
||||
)
|
||||
|
||||
// ForcePlatform 返回设置强制平台的中间件
|
||||
|
||||
@ -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
|
||||
|
||||
@ -146,6 +146,9 @@ func (s *apiKeyRepoStubForGroupUpdate) GetByKeyForAuth(context.Context, string)
|
||||
panic("unexpected")
|
||||
}
|
||||
func (s *apiKeyRepoStubForGroupUpdate) Delete(context.Context, int64) error { panic("unexpected") }
|
||||
func (s *apiKeyRepoStubForGroupUpdate) DeleteWithAudit(context.Context, int64) error {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (s *apiKeyRepoStubForGroupUpdate) ListByUserID(context.Context, int64, pagination.PaginationParams, APIKeyListFilters) ([]APIKey, *pagination.PaginationResult, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
|
||||
@ -55,6 +55,8 @@ type APIKeyRepository interface {
|
||||
GetByKeyForAuth(ctx context.Context, key string) (*APIKey, error)
|
||||
Update(ctx context.Context, key *APIKey) error
|
||||
Delete(ctx context.Context, id int64) error
|
||||
// DeleteWithAudit 在同一事务内先写 deleted_api_key_audits 审计、再软删除该 key。
|
||||
DeleteWithAudit(ctx context.Context, id int64) error
|
||||
|
||||
ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams, filters APIKeyListFilters) ([]APIKey, *pagination.PaginationResult, error)
|
||||
VerifyOwnership(ctx context.Context, userID int64, apiKeyIDs []int64) ([]int64, error)
|
||||
@ -648,15 +650,16 @@ func (s *APIKeyService) Delete(ctx context.Context, id int64, userID int64) erro
|
||||
return ErrInsufficientPerms
|
||||
}
|
||||
|
||||
// 清除Redis缓存(使用 userID 而非 apiKey.UserID)
|
||||
// 事务内:写审计 + 软删除(tombstone)。
|
||||
if err := s.apiKeyRepo.DeleteWithAudit(ctx, id); err != nil {
|
||||
return fmt.Errorf("delete api key: %w", err)
|
||||
}
|
||||
|
||||
// 删除成功后再清理缓存,避免"缓存已清但删除失败"的竞态。
|
||||
if s.cache != nil {
|
||||
_ = s.cache.DeleteCreateAttemptCount(ctx, userID)
|
||||
}
|
||||
s.InvalidateAuthCacheByKey(ctx, key)
|
||||
|
||||
if err := s.apiKeyRepo.Delete(ctx, id); err != nil {
|
||||
return fmt.Errorf("delete api key: %w", err)
|
||||
}
|
||||
s.lastUsedTouchL1.Delete(id)
|
||||
|
||||
return nil
|
||||
|
||||
@ -53,6 +53,10 @@ func (s *authRepoStub) Delete(ctx context.Context, id int64) error {
|
||||
panic("unexpected Delete call")
|
||||
}
|
||||
|
||||
func (s *authRepoStub) DeleteWithAudit(ctx context.Context, id int64) error {
|
||||
panic("unexpected DeleteWithAudit call")
|
||||
}
|
||||
|
||||
func (s *authRepoStub) ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams, filters APIKeyListFilters) ([]APIKey, *pagination.PaginationResult, error) {
|
||||
panic("unexpected ListByUserID call")
|
||||
}
|
||||
|
||||
@ -79,6 +79,12 @@ func (s *apiKeyRepoStub) Delete(ctx context.Context, id int64) error {
|
||||
return s.deleteErr
|
||||
}
|
||||
|
||||
// DeleteWithAudit 与 Delete 一样记录被删除的 ID,供 service 测试断言。
|
||||
func (s *apiKeyRepoStub) DeleteWithAudit(ctx context.Context, id int64) error {
|
||||
s.deletedIDs = append(s.deletedIDs, id)
|
||||
return s.deleteErr
|
||||
}
|
||||
|
||||
// 以下是接口要求实现但本测试不关心的方法
|
||||
|
||||
func (s *apiKeyRepoStub) ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams, filters APIKeyListFilters) ([]APIKey, *pagination.PaginationResult, error) {
|
||||
@ -274,8 +280,8 @@ func TestApiKeyService_Delete_NotFound(t *testing.T) {
|
||||
// 预期行为:
|
||||
// - GetKeyAndOwnerID 返回正确的所有者 ID
|
||||
// - 所有权验证通过
|
||||
// - 缓存被清除(在删除之前)
|
||||
// - Delete 被调用但返回错误
|
||||
// - DeleteWithAudit 被调用但返回错误
|
||||
// - 删除失败时缓存不被清除(缓存清理在删除成功后执行,消除竞态)
|
||||
// - 返回包含 "delete api key" 的错误信息
|
||||
func TestApiKeyService_Delete_DeleteFails(t *testing.T) {
|
||||
repo := &apiKeyRepoStub{
|
||||
@ -288,7 +294,7 @@ func TestApiKeyService_Delete_DeleteFails(t *testing.T) {
|
||||
err := svc.Delete(context.Background(), 3, 3) // API Key ID=3, 调用者 userID=3
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "delete api key")
|
||||
require.Equal(t, []int64{3}, repo.deletedIDs) // 验证删除操作被调用
|
||||
require.Equal(t, []int64{3}, cache.invalidated) // 验证缓存已被清除(即使删除失败)
|
||||
require.Equal(t, []string{svc.authCacheKey("k")}, cache.deleteAuthKeys)
|
||||
require.Equal(t, []int64{3}, repo.deletedIDs) // 验证 DeleteWithAudit 被调用
|
||||
require.Empty(t, cache.invalidated) // 验证删除失败时缓存未被清除(新顺序:先删后清)
|
||||
require.Empty(t, cache.deleteAuthKeys) // 验证删除失败时 auth 缓存未被清除
|
||||
}
|
||||
|
||||
@ -101,6 +101,9 @@ func (s *quotaBaseAPIKeyRepoStub) Update(context.Context, *APIKey) error {
|
||||
func (s *quotaBaseAPIKeyRepoStub) Delete(context.Context, int64) error {
|
||||
panic("unexpected Delete call")
|
||||
}
|
||||
func (s *quotaBaseAPIKeyRepoStub) DeleteWithAudit(context.Context, int64) error {
|
||||
panic("unexpected DeleteWithAudit call")
|
||||
}
|
||||
func (s *quotaBaseAPIKeyRepoStub) ListByUserID(context.Context, int64, pagination.PaginationParams, APIKeyListFilters) ([]APIKey, *pagination.PaginationResult, error) {
|
||||
panic("unexpected ListByUserID call")
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -10,6 +10,8 @@ type OpsRepository interface {
|
||||
BatchInsertErrorLogs(ctx context.Context, inputs []*OpsInsertErrorLogInput) (int64, error)
|
||||
ListErrorLogs(ctx context.Context, filter *OpsErrorLogFilter) (*OpsErrorLogList, error)
|
||||
GetErrorLogByID(ctx context.Context, id int64) (*OpsErrorLogDetail, error)
|
||||
// LookupDeletedKeyAudit 按明文 key 反查最近一条已删除 key 审计;未命中返回 (nil, nil)。
|
||||
LookupDeletedKeyAudit(ctx context.Context, key string) (*DeletedKeyAuditResult, error)
|
||||
ListRequestDetails(ctx context.Context, filter *OpsRequestDetailFilter) ([]*OpsRequestDetail, int64, error)
|
||||
BatchInsertSystemLogs(ctx context.Context, inputs []*OpsInsertSystemLogInput) (int64, error)
|
||||
ListSystemLogs(ctx context.Context, filter *OpsSystemLogFilter) (*OpsSystemLogList, error)
|
||||
@ -61,6 +63,12 @@ type OpsRepository interface {
|
||||
GetLatestDailyBucketDate(ctx context.Context) (time.Time, bool, error)
|
||||
}
|
||||
|
||||
// DeletedKeyAuditResult 是按明文 key 反查 deleted_api_key_audits 的结果。
|
||||
type DeletedKeyAuditResult struct {
|
||||
UserID int64
|
||||
KeyName string
|
||||
}
|
||||
|
||||
type OpsInsertErrorLogInput struct {
|
||||
RequestID string
|
||||
ClientRequestID string
|
||||
@ -118,6 +126,15 @@ type OpsInsertErrorLogInput struct {
|
||||
TimeToFirstTokenMs *int64
|
||||
|
||||
CreatedAt time.Time
|
||||
|
||||
// 已删除 key 归因(仅 INVALID_API_KEY 认证失败时可能非空)
|
||||
AttemptedKeyPrefix string // 提交 key 的脱敏前缀(前 8 位)
|
||||
DeletedKeyOwnerUserID *int64 // 反查命中的原所有者 user_id
|
||||
DeletedKeyName string // 反查命中的 key 名称
|
||||
|
||||
// 有效(未删除)key 报错时快照的 key 脱敏前缀(前 8 位);与 AttemptedKeyPrefix 互斥。
|
||||
// 落库快照而非读时 JOIN:key 之后被删(key 列被 tombstone 覆盖)仍保留当时前缀。
|
||||
APIKeyPrefix string
|
||||
}
|
||||
|
||||
type OpsInsertSystemMetricsInput struct {
|
||||
|
||||
@ -13,6 +13,7 @@ type opsRepoMock struct {
|
||||
ListSystemLogsFn func(ctx context.Context, filter *OpsSystemLogFilter) (*OpsSystemLogList, error)
|
||||
DeleteSystemLogsFn func(ctx context.Context, filter *OpsSystemLogCleanupFilter) (int64, error)
|
||||
InsertSystemLogCleanupAuditFn func(ctx context.Context, input *OpsSystemLogCleanupAudit) error
|
||||
LookupDeletedKeyAuditFn func(ctx context.Context, key string) (*DeletedKeyAuditResult, error)
|
||||
}
|
||||
|
||||
func (m *opsRepoMock) InsertErrorLog(ctx context.Context, input *OpsInsertErrorLogInput) (int64, error) {
|
||||
@ -189,4 +190,11 @@ func (m *opsRepoMock) GetLatestDailyBucketDate(ctx context.Context) (time.Time,
|
||||
return time.Time{}, false, nil
|
||||
}
|
||||
|
||||
func (m *opsRepoMock) LookupDeletedKeyAudit(ctx context.Context, key string) (*DeletedKeyAuditResult, error) {
|
||||
if m.LookupDeletedKeyAuditFn != nil {
|
||||
return m.LookupDeletedKeyAuditFn(ctx, key)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var _ OpsRepository = (*opsRepoMock)(nil)
|
||||
|
||||
@ -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 // 拷贝快照,避免原地篡改调用方的 filter(slice 字段只读,浅拷贝足够)
|
||||
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
|
||||
|
||||
222
backend/internal/service/ops_service_user_error_test.go
Normal file
222
backend/internal/service/ops_service_user_error_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
123
backend/internal/service/ops_user_error.go
Normal file
123
backend/internal/service/ops_user_error.go
Normal 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,
|
||||
}
|
||||
}
|
||||
167
backend/internal/service/ops_user_error_test.go
Normal file
167
backend/internal/service/ops_user_error_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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,但未找到(bug:buildSystemSettingsUpdates 漏写)")
|
||||
require.Equal(t, "true", val)
|
||||
}
|
||||
9
backend/internal/service/setting_user_error_view_test.go
Normal file
9
backend/internal/service/setting_user_error_view_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -223,6 +223,9 @@ type SystemSettings struct {
|
||||
|
||||
// 系统全局默认平台配额(key = platform,nil/缺省 = 不限制)
|
||||
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 {
|
||||
|
||||
22
backend/migrations/145_deleted_api_key_audit.sql
Normal file
22
backend/migrations/145_deleted_api_key_audit.sql
Normal file
@ -0,0 +1,22 @@
|
||||
-- 已删除 API key 审计表:删除 key 时同步留存(明文 key、所有者、key 信息),
|
||||
-- 供认证失败(INVALID_API_KEY)反查"这个失效 key 曾属于谁"。
|
||||
-- 仅对本表上线后删除的 key 生效;此前已删的 key 原值已被 tombstone 覆盖,无法补录。
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
SET LOCAL statement_timeout = '10min';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS deleted_api_key_audits (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
key VARCHAR(128) NOT NULL, -- 原 key 明文(复用 api_keys 策略),非唯一
|
||||
api_key_id BIGINT NOT NULL, -- 原 api_keys.id
|
||||
user_id BIGINT NOT NULL, -- 原所有者(不加外键,与 ops 表设计哲学一致)
|
||||
key_name VARCHAR(100) NOT NULL DEFAULT '', -- 原 key 名称,便于展示
|
||||
deleted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS deletedapikeyaudit_key ON deleted_api_key_audits (key);
|
||||
CREATE INDEX IF NOT EXISTS deletedapikeyaudit_user_id ON deleted_api_key_audits (user_id);
|
||||
|
||||
ALTER TABLE ops_error_logs
|
||||
ADD COLUMN IF NOT EXISTS attempted_key_prefix VARCHAR(32),
|
||||
ADD COLUMN IF NOT EXISTS deleted_key_owner_user_id BIGINT,
|
||||
ADD COLUMN IF NOT EXISTS deleted_key_name VARCHAR(100);
|
||||
12
backend/migrations/147_ops_error_log_api_key_prefix.sql
Normal file
12
backend/migrations/147_ops_error_log_api_key_prefix.sql
Normal file
@ -0,0 +1,12 @@
|
||||
-- 有效(未删除)key 报错时,在 ops 落库层快照该 key 的脱敏前缀(前 8 位),
|
||||
-- 便于在 /admin/ops 错误详情识别是用户的哪一个 key 出的错。
|
||||
-- 与 attempted_key_prefix 互补且互斥:
|
||||
-- api_key_id 非空(有效 key 报错) => api_key_prefix
|
||||
-- api_key_id 为空(INVALID_API_KEY 无效) => attempted_key_prefix
|
||||
-- 落库快照(而非读时 JOIN api_keys):key 之后被删时 api_keys.key 会被 tombstone
|
||||
-- 覆盖,快照可保留报错当时的真实前缀。
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
SET LOCAL statement_timeout = '10min';
|
||||
|
||||
ALTER TABLE ops_error_logs
|
||||
ADD COLUMN IF NOT EXISTS api_key_prefix VARCHAR(32);
|
||||
@ -0,0 +1,6 @@
|
||||
-- 148_add_ops_error_logs_user_time_index_notx.sql
|
||||
-- 用户侧"错误请求"按 user_id 时间倒序分页所需的部分索引。
|
||||
-- 非事务迁移(_notx):CREATE 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;
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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>
|
||||
|
||||
127
frontend/src/components/user/UserErrorDetailModal.vue
Normal file
127
frontend/src/components/user/UserErrorDetailModal.vue
Normal 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>
|
||||
173
frontend/src/components/user/UserErrorRequestsTable.vue
Normal file
173
frontend/src/components/user/UserErrorRequestsTable.vue
Normal 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>
|
||||
@ -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',
|
||||
|
||||
@ -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: '设置保存成功',
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 已成功从后端加载时才回写,
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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,
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
@ -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) -->
|
||||
|
||||
@ -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 -->
|
||||
|
||||
@ -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()
|
||||
})
|
||||
}
|
||||
})
|
||||
@ -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user