feat(usage): 记录并展示失败请求(用户端+管理端)
- 记录失败请求并在用户端/管理端展示;分类下拉改用统一 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) <noreply@anthropic.com>
This commit is contained in:
1 parent
ddf063352a
commit
cfb195c7b2
37 files changed
+1746
-84
No files matched your search
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user