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:
DaydreamCoding 2026-06-04 19:06:24 +08:00 committed by QTom
parent ddf063352a
commit cfb195c7b2
37 changed files with 1746 additions and 84 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -64,6 +64,10 @@ type OpsErrorLog struct {
RequestedModel string `json:"requested_model"`
UpstreamModel string `json:"upstream_model"`
RequestType *int16 `json:"request_type"`
// 关联 api_key 名称LEFT JOIN api_keys 取得;软删只覆盖 key 列name 保留,故已删 key 仍有原名)。
APIKeyName string `json:"api_key_name,omitempty"`
APIKeyDeleted bool `json:"api_key_deleted,omitempty"`
}
type OpsErrorLogDetail struct {
@ -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

View File

@ -338,6 +338,50 @@ func (s *OpsService) GetErrorLogs(ctx context.Context, filter *OpsErrorLogFilter
return result, nil
}
// ListUserErrorRequests 返回某个用户自己的错误请求(精简脱敏)。
// 强制仅当前用户、View=all含业务限流/余额类)、排除 count_tokens 噪声。
func (s *OpsService) ListUserErrorRequests(ctx context.Context, userID int64, filter *OpsErrorLogFilter) (*UserErrorRequestList, error) {
if filter == nil {
filter = &OpsErrorLogFilter{}
}
f := *filter // 拷贝快照,避免原地篡改调用方的 filterslice 字段只读,浅拷贝足够)
filter = &f
uid := userID
filter.UserID = &uid
// 用户侧放宽归属:纳入「删 key 后认证失败」(user_id=NULL,靠 deleted_key_owner 归因)的记录。
filter.MatchDeletedKeyOwner = true
// APIKeyID 透传:保留 handler 传入的值。安全由 buildOpsErrorLogsWhere 的
// "user_id = 自己 AND api_key_id = X" 双重约束保证——传入他人 key 只会得到空集,无泄露。
filter.View = "all"
filter.ExcludeCountTokens = true
filter.ModelFuzzy = true // 用户端模型过滤走 ILIKE 模糊;管理端不设此字段,保持精确
// 防御:用户端不接受这些 admin-only / 特殊维度
filter.UserQuery = ""
filter.Owner = ""
filter.Source = ""
// 清空 Phase 是防御:Phase 是单值特殊字段,仅当其 == "upstream" 时 buildOpsErrorLogsWhere 才跳过 status>=400 子句。
// 用户端一律改走 category→ErrorPhasesAny/ErrorTypesAny(纯 ANY 过滤,不影响 status>=400 子句),
// 因此 recovered upstream(error_phase='upstream' 但 status<400,最终成功返回)记录对用户不可见——符合预期。
filter.Phase = ""
list, err := s.opsRepo.ListErrorLogs(ctx, filter)
if err != nil {
return nil, err
}
items := make([]*UserErrorRequest, 0, len(list.Errors))
for _, e := range list.Errors {
if r := ToUserErrorRequest(e); r != nil {
items = append(items, r)
}
}
return &UserErrorRequestList{
Items: items,
Total: list.Total,
Page: list.Page,
PageSize: list.PageSize,
}, nil
}
func (s *OpsService) GetErrorLogByID(ctx context.Context, id int64) (*OpsErrorLogDetail, error) {
if err := s.RequireMonitoringEnabled(ctx); err != nil {
return nil, err
@ -355,6 +399,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 {

View File

@ -0,0 +1,222 @@
package service
import (
"context"
"database/sql"
"testing"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
)
type stubOpsRepoForUserErr struct {
OpsRepository // 嵌入接口,未实现的方法 panic仅覆盖 ListErrorLogs
gotFilter *OpsErrorLogFilter
// GetErrorLogByID 控制字段
detailToReturn *OpsErrorLogDetail
detailErrToReturn error
}
func (s *stubOpsRepoForUserErr) ListErrorLogs(ctx context.Context, f *OpsErrorLogFilter) (*OpsErrorLogList, error) {
s.gotFilter = f
return &OpsErrorLogList{
Errors: []*OpsErrorLog{{
Phase: "request", Type: "rate_limit_error",
Model: "m", RequestedModel: "rm", StatusCode: 429,
Message: "secret", UserEmail: "a@b.c",
}},
Total: 1, Page: 1, PageSize: 20,
}, nil
}
func (s *stubOpsRepoForUserErr) GetErrorLogByID(ctx context.Context, id int64) (*OpsErrorLogDetail, error) {
if s.detailErrToReturn != nil {
return nil, s.detailErrToReturn
}
return s.detailToReturn, nil
}
func TestListUserErrorRequests_ForcesScopeAndRedacts(t *testing.T) {
stub := &stubOpsRepoForUserErr{}
svc := &OpsService{opsRepo: stub}
uid := int64(42)
kid := int64(7)
in := &OpsErrorLogFilter{UserID: nil, View: "errors", Phase: "upstream", APIKeyID: &kid}
out, err := svc.ListUserErrorRequests(context.Background(), uid, in)
if err != nil {
t.Fatal(err)
}
// 强制按用户
if stub.gotFilter.UserID == nil || *stub.gotFilter.UserID != uid {
t.Fatalf("UserID not forced: %+v", stub.gotFilter.UserID)
}
// 强制 View=all含业务限流/余额)
if stub.gotFilter.View != "all" {
t.Fatalf("View not forced to all: %q", stub.gotFilter.View)
}
// 强制排除 count_tokens
if !stub.gotFilter.ExcludeCountTokens {
t.Fatal("ExcludeCountTokens not forced")
}
// 强制清空 Phase防止 "upstream" 绕过 status>=400 子句 + 与 ErrorPhasesAny 双重约束)
if stub.gotFilter.Phase != "" {
t.Fatalf("Phase not cleared: %q", stub.gotFilter.Phase)
}
// APIKeyID 透传保留(用户可按自己 key 过滤;越权由 user_id AND api_key_id 双重防护)
if stub.gotFilter.APIKeyID == nil || *stub.gotFilter.APIKeyID != kid {
t.Fatalf("APIKeyID should be preserved, got %v", stub.gotFilter.APIKeyID)
}
// 调用方传入的 filter 不应被原地篡改(验证 shallow copy 隔离生效)
if in.View != "errors" || in.UserID != nil || in.Phase != "upstream" {
t.Fatalf("caller filter was mutated: View=%q UserID=%v Phase=%q", in.View, in.UserID, in.Phase)
}
// 脱敏:返回条目含 message 字段
if len(out.Items) != 1 || out.Items[0].Category != "rate_limit" || out.Items[0].Model != "rm" {
t.Fatalf("bad item: %+v", out.Items)
}
}
func TestGetUserErrorRequestDetail_OwnershipEnforced(t *testing.T) {
ownerUID := int64(999)
callerUID := int64(1)
upstreamStatus := 503
detail := &OpsErrorLogDetail{
OpsErrorLog: OpsErrorLog{
ID: 42,
Phase: "upstream",
Type: "api_error",
Model: "gpt-4",
RequestedModel: "gpt-4-turbo",
InboundEndpoint: "/v1/chat/completions",
StatusCode: 502,
Platform: "openai",
Message: "upstream failed",
UserID: &ownerUID,
},
ErrorBody: `{"error":"upstream"}`,
UpstreamStatusCode: &upstreamStatus,
}
stub := &stubOpsRepoForUserErr{detailToReturn: detail}
svc := &OpsService{opsRepo: stub}
// 越权调用callerUID=1,但记录属于 ownerUID=999→ 应返回 NotFound,detail 为 nil
got, err := svc.GetUserErrorRequestDetail(context.Background(), callerUID, 42)
if err == nil {
t.Fatal("expected error for unauthorized access, got nil")
}
if got != nil {
t.Fatalf("expected nil detail for unauthorized access, got %+v", got)
}
// 验证错误为 NotFound(不暴露存在性)
if !infraerrors.IsNotFound(err) {
t.Fatalf("expected NotFound error, got: %v", err)
}
// 合法调用callerUID=999 = ownerUID→ 应返回 non-nil detail
got2, err2 := svc.GetUserErrorRequestDetail(context.Background(), ownerUID, 42)
if err2 != nil {
t.Fatalf("expected no error for legitimate access, got %v", err2)
}
if got2 == nil {
t.Fatal("expected non-nil detail for legitimate access")
}
if got2.ID != 42 {
t.Errorf("want ID=42, got %d", got2.ID)
}
if got2.ErrorBody != `{"error":"upstream"}` {
t.Errorf("want ErrorBody=%q, got %q", `{"error":"upstream"}`, got2.ErrorBody)
}
if got2.UpstreamStatusCode == nil || *got2.UpstreamStatusCode != 503 {
t.Errorf("want UpstreamStatusCode=503, got %v", got2.UpstreamStatusCode)
}
if got2.Message != "upstream failed" {
t.Errorf("want Message=%q, got %q", "upstream failed", got2.Message)
}
}
func TestGetUserErrorRequestDetail_NotFound(t *testing.T) {
stub := &stubOpsRepoForUserErr{detailErrToReturn: sql.ErrNoRows}
svc := &OpsService{opsRepo: stub}
got, err := svc.GetUserErrorRequestDetail(context.Background(), 1, 999)
if err == nil {
t.Fatal("expected error for not found, got nil")
}
if got != nil {
t.Fatalf("expected nil detail, got %+v", got)
}
}
func TestGetUserErrorRequestDetail_InvalidID(t *testing.T) {
stub := &stubOpsRepoForUserErr{}
svc := &OpsService{opsRepo: stub}
_, err := svc.GetUserErrorRequestDetail(context.Background(), 1, 0)
if err == nil {
t.Fatal("expected error for id=0")
}
_, err = svc.GetUserErrorRequestDetail(context.Background(), 1, -5)
if err == nil {
t.Fatal("expected error for id=-5")
}
}
func TestListUserErrorRequests_EnablesMatchDeletedKeyOwner(t *testing.T) {
stub := &stubOpsRepoForUserErr{}
svc := &OpsService{opsRepo: stub}
uid := int64(42)
if _, err := svc.ListUserErrorRequests(context.Background(), uid, &OpsErrorLogFilter{}); err != nil {
t.Fatal(err)
}
if stub.gotFilter == nil || !stub.gotFilter.MatchDeletedKeyOwner {
t.Fatal("ListUserErrorRequests should enable MatchDeletedKeyOwner for the user scope")
}
}
func TestGetUserErrorRequestDetail_DeletedKeyOwnerAccess(t *testing.T) {
ownerUID := int64(777)
otherUID := int64(2)
// 情况2:user_id=NULL,靠 deleted_key_owner_user_id 归因到 ownerUID
mk := func() *OpsErrorLogDetail {
return &OpsErrorLogDetail{
OpsErrorLog: OpsErrorLog{
ID: 55,
Phase: "auth",
Type: "api_error",
StatusCode: 401,
Message: "Invalid API key",
UserID: nil,
APIKeyName: "my-old-key",
APIKeyDeleted: true,
},
DeletedKeyOwnerUserID: &ownerUID,
}
}
// 原所有者(经 deleted_key 归因)→ 放行
svcOwner := &OpsService{opsRepo: &stubOpsRepoForUserErr{detailToReturn: mk()}}
got, err := svcOwner.GetUserErrorRequestDetail(context.Background(), ownerUID, 55)
if err != nil {
t.Fatalf("owner via deleted_key should be allowed, got err: %v", err)
}
if got == nil || got.ID != 55 {
t.Fatalf("expected detail ID=55, got %+v", got)
}
if !got.KeyDeleted || got.KeyName != "my-old-key" {
t.Fatalf("expected KeyDeleted=true KeyName=my-old-key, got %+v", got)
}
// 他人 → NotFound,不泄露存在性
svcOther := &OpsService{opsRepo: &stubOpsRepoForUserErr{detailToReturn: mk()}}
got2, err2 := svcOther.GetUserErrorRequestDetail(context.Background(), otherUID, 55)
if err2 == nil || got2 != nil {
t.Fatalf("non-owner should get (nil, NotFound), got detail=%+v err=%v", got2, err2)
}
if !infraerrors.IsNotFound(err2) {
t.Fatalf("expected NotFound, got %v", err2)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -937,7 +937,26 @@ export default {
exportExcelSuccess: '使用数据导出成功Excel格式',
exportExcelFailed: '使用数据导出失败',
imageUnit: '张',
userAgent: 'User-Agent'
userAgent: 'User-Agent',
tabs: { usage: '用量明细', errors: '错误请求' },
errors: {
time: '时间', model: '模型', endpoint: '端点', status: '状态码',
category: '分类', platform: '平台', message: '错误信息',
keyName: 'Key 名称', keyDeleted: '已删除', allKeys: '全部 Key',
modelPlaceholder: '搜索模型', allCategories: '全部分类',
empty: '暂无错误请求', failedToLoad: '加载错误请求失败',
categories: {
auth: '认证失败', rate_limit: '限流', quota: '余额/订阅',
invalid_request: '参数错误', service_unavailable: '服务暂时不可用',
upstream: '上游错误', internal: '平台错误', other: '其他',
},
detail: {
title: '错误请求详情',
responseBody: '上游响应内容',
upstreamStatus: '上游状态码',
loadFailed: '加载详情失败,请稍后重试',
},
},
},
// Shared keys for channel monitor (admin + user views)
@ -6487,6 +6506,14 @@ export default {
title: 'OpenAI 实验调度策略',
description: '默认关闭。开启后仅影响本网关在 OpenAI 账号间的实验性调度选择逻辑,不代表上游 OpenAI 官方能力。'
},
usageRecords: {
title: '使用记录',
description: '与终端用户可见的用量及失败请求记录相关的设置。',
},
user_error_view: {
label: '允许用户查看自己的错误请求',
description: '开启后,用户可在用量页查看自己失败请求的精简信息(不含内部/上游错误细节)。需运维监控开启才有数据。',
},
saveSettings: '保存设置',
saving: '保存中...',
settingsSaved: '设置保存成功',

View File

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

View File

@ -234,6 +234,7 @@ export interface PublicSettings {
channel_monitor_default_interval_seconds: number
available_channels_enabled: boolean
affiliate_enabled: boolean
allow_user_view_error_requests?: boolean
}
export interface AuthResponse {
@ -1592,6 +1593,36 @@ export interface ExtendSubscriptionRequest {
// ==================== Query Parameters ====================
export interface UserErrorRequest {
id: number
created_at: string
model: string
inbound_endpoint: string
status_code: number
category: string
platform: string
message: string
key_name: string
key_deleted: boolean
}
export interface UserErrorRequestDetail extends UserErrorRequest {
error_body: string
upstream_status_code?: number
}
export interface UserErrorListParams {
page?: number
page_size?: number
start_date?: string
end_date?: string
timezone?: string
model?: string
status_code?: number
category?: string
api_key_id?: number
}
export interface UsageQueryParams {
page?: number
page_size?: number

View File

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

View File

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

View File

@ -149,6 +149,18 @@
</template>
<template #table>
<!-- Tab 切换栏 -->
<div v-if="errorViewEnabled" class="mb-0 flex gap-2 border-b border-gray-200 px-4 pt-3 dark:border-dark-700">
<button class="tab" :class="{ 'tab-active': activeTab === 'usage' }" @click="activeTab = 'usage'">
{{ t('usage.tabs.usage') }}
</button>
<button class="tab" :class="{ 'tab-active': activeTab === 'errors' }" @click="switchToErrors">
{{ t('usage.tabs.errors') }}
</button>
</div>
<!-- 用量明细表 -->
<div v-show="activeTab === 'usage'" class="flex min-h-0 flex-1 flex-col">
<DataTable
:columns="columns"
:data="usageLogs"
@ -332,11 +344,27 @@
<EmptyState :message="t('usage.noRecords')" />
</template>
</DataTable>
</div>
<!-- 错误请求表 -->
<div v-if="errorViewEnabled" v-show="activeTab === 'errors'" class="flex min-h-0 flex-1 flex-col">
<UserErrorRequestsTable
:rows="errorRows"
:total="errorTotal"
:loading="errorLoading"
:page="errorPage"
:page-size="errorPageSize"
:api-keys="apiKeys"
@filter="onErrorFilter"
@update:page="onErrorPage"
@update:pageSize="onErrorPageSize"
/>
</div>
</template>
<template #pagination>
<Pagination
v-if="pagination.total > 0"
v-if="pagination.total > 0 && activeTab === 'usage'"
:page="pagination.page"
:total="pagination.total"
:page-size="pagination.page_size"
@ -550,7 +578,8 @@ import EmptyState from '@/components/common/EmptyState.vue'
import Select from '@/components/common/Select.vue'
import DateRangePicker from '@/components/common/DateRangePicker.vue'
import Icon from '@/components/icons/Icon.vue'
import type { UsageLog, ApiKey, UsageQueryParams, UsageStatsResponse } from '@/types'
import UserErrorRequestsTable from '@/components/user/UserErrorRequestsTable.vue'
import type { UsageLog, ApiKey, UsageQueryParams, UsageStatsResponse, UserErrorRequest } from '@/types'
import type { Column } from '@/components/common/types'
import { formatDateTime, formatReasoningEffort } from '@/utils/format'
import { getPersistedPageSize } from '@/composables/usePersistedPageSize'
@ -653,6 +682,12 @@ const onDateRangeChange = (range: {
filters.value.start_date = range.startDate
filters.value.end_date = range.endDate
applyFilters()
errorPage.value = 1
if (activeTab.value === 'errors') {
loadErrors()
} else {
errorRows.value = [] // errors tab
}
}
const pagination = reactive({
@ -988,6 +1023,52 @@ const hideTokenTooltip = () => {
tokenTooltipData.value = null
}
// Error Requests Tab
const activeTab = ref<'usage' | 'errors'>('usage')
const errorViewEnabled = computed(() => appStore.cachedPublicSettings?.allow_user_view_error_requests ?? false)
const errorRows = ref<UserErrorRequest[]>([])
const errorLoading = ref(false)
const errorPage = ref(1)
const errorPageSize = ref(20)
const errorTotal = ref(0)
const errorFilter = ref<{ model: string; category: string; api_key_id: number | null }>({ model: '', category: '', api_key_id: null })
const loadErrors = async () => {
errorLoading.value = true
try {
const resp = await usageAPI.listMyErrorRequests({
page: errorPage.value,
page_size: errorPageSize.value,
start_date: startDate.value,
end_date: endDate.value,
model: errorFilter.value.model || undefined,
category: errorFilter.value.category || undefined,
api_key_id: errorFilter.value.api_key_id ?? undefined,
})
errorRows.value = resp.items
errorTotal.value = resp.total
} catch (error) {
console.error('[UsageView] loadErrors failed:', error)
appStore.showError(t('usage.errors.failedToLoad'))
} finally {
errorLoading.value = false
}
}
const onErrorFilter = (f: { model: string; category: string; api_key_id: number | null }) => {
errorFilter.value = f
errorPage.value = 1
loadErrors()
}
const onErrorPage = (p: number) => { errorPage.value = p; loadErrors() }
const onErrorPageSize = (s: number) => { errorPageSize.value = s; errorPage.value = 1; loadErrors() }
const switchToErrors = () => {
activeTab.value = 'errors'
if (errorRows.value.length === 0) loadErrors()
}
onMounted(() => {
loadApiKeys()
loadUsageLogs()