mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
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:
committed by
QTom
co-authored by
Claude Opus 4.8
parent
ddf063352a
commit
cfb195c7b2
@@ -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})
|
||||
|
||||
Reference in New Issue
Block a user