From cfb195c7b2c91c6fa7ffb2bbcd8f816e0efcd3c9 Mon Sep 17 00:00:00 2001 From: DaydreamCoding Date: Thu, 4 Jun 2026 19:06:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(usage):=20=E8=AE=B0=E5=BD=95=E5=B9=B6?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=E5=A4=B1=E8=B4=A5=E8=AF=B7=E6=B1=82(?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E7=AB=AF+=E7=AE=A1=E7=90=86=E7=AB=AF)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 记录失败请求并在用户端/管理端展示;分类下拉改用统一 Select 组件 - 模型过滤改后端 ILIKE 模糊匹配;新增「Key 名称」列(含已删除标记)与按 Key 过滤;时间列移至末列 - 用户可见「已删除 key 失败请求」:OpsErrorLogFilter 加 MatchDeletedKeyOwner,用户侧归属 放宽为 (user_id OR deleted_key_owner_user_id),让 key 原所有者能看到删除 key 后继续请求 导致的认证失败记录(他人仍 NotFound,不泄露存在性) - 迁移 148:ops_error_logs 用户+时间索引 Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/cmd/server/wire_gen.go | 104 ++++---- backend/internal/handler/admin/ops_handler.go | 16 ++ .../internal/handler/admin/setting_handler.go | 13 +- backend/internal/handler/dto/settings.go | 5 + backend/internal/handler/setting_handler.go | 2 + backend/internal/handler/usage_handler.go | 131 ++++++++++- .../handler/usage_handler_daily_test.go | 2 +- .../usage_handler_request_type_test.go | 2 +- .../repository/ops_error_where_test.go | 95 ++++++++ backend/internal/repository/ops_repo.go | 81 ++++++- backend/internal/server/api_contract_test.go | 8 +- backend/internal/server/routes/user.go | 2 + backend/internal/service/domain_constants.go | 4 + backend/internal/service/ops_models.go | 32 ++- backend/internal/service/ops_service.go | 69 ++++++ .../service/ops_service_user_error_test.go | 222 ++++++++++++++++++ backend/internal/service/ops_user_error.go | 123 ++++++++++ .../internal/service/ops_user_error_test.go | 167 +++++++++++++ backend/internal/service/setting_service.go | 22 ++ .../service/setting_service_public_test.go | 13 + ...setting_service_user_error_persist_test.go | 31 +++ .../service/setting_user_error_view_test.go | 9 + backend/internal/service/settings_view.go | 6 + ...dd_ops_error_logs_user_time_index_notx.sql | 6 + frontend/src/api/admin/ops.ts | 2 + frontend/src/api/admin/settings.ts | 5 + frontend/src/api/usage.ts | 26 +- frontend/src/components/common/Select.vue | 30 +++ .../components/user/UserErrorDetailModal.vue | 127 ++++++++++ .../user/UserErrorRequestsTable.vue | 173 ++++++++++++++ frontend/src/i18n/locales/en.ts | 29 ++- frontend/src/i18n/locales/zh.ts | 29 ++- frontend/src/stores/app.ts | 1 + frontend/src/types/index.ts | 31 +++ frontend/src/views/admin/SettingsView.vue | 32 +++ frontend/src/views/admin/UsageView.vue | 93 +++++++- frontend/src/views/user/UsageView.vue | 87 ++++++- 37 files changed, 1746 insertions(+), 84 deletions(-) create mode 100644 backend/internal/repository/ops_error_where_test.go create mode 100644 backend/internal/service/ops_service_user_error_test.go create mode 100644 backend/internal/service/ops_user_error.go create mode 100644 backend/internal/service/ops_user_error_test.go create mode 100644 backend/internal/service/setting_service_user_error_persist_test.go create mode 100644 backend/internal/service/setting_user_error_view_test.go create mode 100644 backend/migrations/148_add_ops_error_logs_user_time_index_notx.sql create mode 100644 frontend/src/components/user/UserErrorDetailModal.vue create mode 100644 frontend/src/components/user/UserErrorRequestsTable.vue diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 10643215..814c07e6 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -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 diff --git a/backend/internal/handler/admin/ops_handler.go b/backend/internal/handler/admin/ops_handler.go index 418c302f..0ae93e65 100644 --- a/backend/internal/handler/admin/ops_handler.go +++ b/backend/internal/handler/admin/ops_handler.go @@ -137,6 +137,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": diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index c229d340..0beb15d3 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -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) diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 17772a2e..da89ac23 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -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 { diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go index 7413b840..7c79a19e 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -98,6 +98,8 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { AffiliateEnabled: settings.AffiliateEnabled, RiskControlEnabled: settings.RiskControlEnabled, + + AllowUserViewErrorRequests: settings.AllowUserViewErrorRequests, }) } diff --git a/backend/internal/handler/usage_handler.go b/backend/internal/handler/usage_handler.go index daa5695d..23bb62dd 100644 --- a/backend/internal/handler/usage_handler.go +++ b/backend/internal/handler/usage_handler.go @@ -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) { diff --git a/backend/internal/handler/usage_handler_daily_test.go b/backend/internal/handler/usage_handler_daily_test.go index 36311fac..2a9186cf 100644 --- a/backend/internal/handler/usage_handler_daily_test.go +++ b/backend/internal/handler/usage_handler_daily_test.go @@ -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}) diff --git a/backend/internal/handler/usage_handler_request_type_test.go b/backend/internal/handler/usage_handler_request_type_test.go index b49ed59b..ed08c5a8 100644 --- a/backend/internal/handler/usage_handler_request_type_test.go +++ b/backend/internal/handler/usage_handler_request_type_test.go @@ -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}) diff --git a/backend/internal/repository/ops_error_where_test.go b/backend/internal/repository/ops_error_where_test.go new file mode 100644 index 00000000..9bebb158 --- /dev/null +++ b/backend/internal/repository/ops_error_where_test.go @@ -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) + } +} diff --git a/backend/internal/repository/ops_repo.go b/backend/internal/repository/ops_repo.go index a7773713..f300a171 100644 --- a/backend/internal/repository/ops_repo.go +++ b/backend/internal/repository/ops_repo.go @@ -240,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) @@ -272,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, @@ -305,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 } @@ -345,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 { @@ -416,12 +435,15 @@ SELECT e.deleted_key_owner_user_id, COALESCE(du.email, ''), COALESCE(e.deleted_key_name, ''), - COALESCE(e.api_key_prefix, '') + 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` @@ -442,6 +464,8 @@ LIMIT 1` 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, @@ -492,6 +516,8 @@ LIMIT 1` &out.DeletedKeyOwnerEmail, &out.DeletedKeyName, &out.APIKeyPrefix, + &detailAPIKeyName, + &detailAPIKeyDeletedAt, ) if err != nil { return nil, err @@ -558,6 +584,14 @@ LIMIT 1` 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) @@ -860,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) @@ -972,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 } diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index d4901da1..766225ff 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -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) diff --git a/backend/internal/server/routes/user.go b/backend/internal/server/routes/user.go index 07ae33de..0f3758f7 100644 --- a/backend/internal/server/routes/user.go +++ b/backend/internal/server/routes/user.go @@ -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 diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index b6441238..11245d00 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -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" diff --git a/backend/internal/service/ops_models.go b/backend/internal/service/ops_models.go index 63c58cad..0bbe4220 100644 --- a/backend/internal/service/ops_models.go +++ b/backend/internal/service/ops_models.go @@ -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 { @@ -108,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 @@ -119,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 diff --git a/backend/internal/service/ops_service.go b/backend/internal/service/ops_service.go index 3d234b28..a8c8a4bb 100644 --- a/backend/internal/service/ops_service.go +++ b/backend/internal/service/ops_service.go @@ -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,31 @@ 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 { diff --git a/backend/internal/service/ops_service_user_error_test.go b/backend/internal/service/ops_service_user_error_test.go new file mode 100644 index 00000000..9027ff07 --- /dev/null +++ b/backend/internal/service/ops_service_user_error_test.go @@ -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) + } +} diff --git a/backend/internal/service/ops_user_error.go b/backend/internal/service/ops_user_error.go new file mode 100644 index 00000000..817c139a --- /dev/null +++ b/backend/internal/service/ops_user_error.go @@ -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, + } +} diff --git a/backend/internal/service/ops_user_error_test.go b/backend/internal/service/ops_user_error_test.go new file mode 100644 index 00000000..31b0c269 --- /dev/null +++ b/backend/internal/service/ops_user_error_test.go @@ -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) + } +} diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 98acdb80..7043736a 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -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 } diff --git a/backend/internal/service/setting_service_public_test.go b/backend/internal/service/setting_service_public_test.go index 2faa4d82..621620fd 100644 --- a/backend/internal/service/setting_service_public_test.go +++ b/backend/internal/service/setting_service_public_test.go @@ -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{ diff --git a/backend/internal/service/setting_service_user_error_persist_test.go b/backend/internal/service/setting_service_user_error_persist_test.go new file mode 100644 index 00000000..1ec76d46 --- /dev/null +++ b/backend/internal/service/setting_service_user_error_persist_test.go @@ -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) +} diff --git a/backend/internal/service/setting_user_error_view_test.go b/backend/internal/service/setting_user_error_view_test.go new file mode 100644 index 00000000..264a5a79 --- /dev/null +++ b/backend/internal/service/setting_user_error_view_test.go @@ -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) + } +} diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 7b45ef1a..97217202 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -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 { diff --git a/backend/migrations/148_add_ops_error_logs_user_time_index_notx.sql b/backend/migrations/148_add_ops_error_logs_user_time_index_notx.sql new file mode 100644 index 00000000..54d73ba5 --- /dev/null +++ b/backend/migrations/148_add_ops_error_logs_user_time_index_notx.sql @@ -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; diff --git a/frontend/src/api/admin/ops.ts b/frontend/src/api/admin/ops.ts index 557fd00f..defdcf2e 100644 --- a/frontend/src/api/admin/ops.ts +++ b/frontend/src/api/admin/ops.ts @@ -1084,6 +1084,8 @@ export type OpsErrorListQueryParams = { platform?: string group_id?: number | null account_id?: number | null + user_id?: number + api_key_id?: number phase?: string error_owner?: string diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 6d8e6cee..5be63076 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -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; } /** diff --git a/frontend/src/api/usage.ts b/frontend/src/api/usage.ts index ee08ee9d..f0aec3d6 100644 --- a/frontend/src/api/usage.ts +++ b/frontend/src/api/usage.ts @@ -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> { + const { data } = await apiClient.get>('/usage/errors', { + ...config, + params + }) + return data +} + +export async function getMyErrorDetail(id: number): Promise { + const { data } = await apiClient.get(`/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 diff --git a/frontend/src/components/common/Select.vue b/frontend/src/components/common/Select.vue index a8948145..fbb5c545 100644 --- a/frontend/src/components/common/Select.vue +++ b/frontend/src/components/common/Select.vue @@ -22,6 +22,18 @@ {{ selectedLabel }} + + + (), { 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; +}