feat(risk): 风控数据管道与风控中心
- DB Migration 081: 新增 account_behavior_hourly / account_risk_scores 表 - 行为采集:Gateway/OpenAI Gateway RecordUsage 注入 fire-and-forget CollectBehaviorAsync - SQL 打分引擎:CTE 加权特征向量 → risk_score [0-1],UPSERT 保留 idle_override - RiskSettings:Redis 缓存 → DB fallback → 默认值(observe 模式) - REST API:/admin/risk/summary|accounts|accounts/:id|settings - 前端:Pinia store + RiskControlView + 6 子组件(donut/radar/line 纯 SVG 图表) - 侧边栏新增 Risk Control 入口(ShieldExclamationIcon) - 反风控优化:移除 Antigravity 后台定时刷新,改为按需刷新避免 idle 封号
This commit is contained in:
parent
85ed193ff0
commit
f25dd04e0b
@ -153,6 +153,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
rpmCache := repository.NewRPMCache(redisClient)
|
||||
groupCapacityService := service.NewGroupCapacityService(accountRepository, groupRepository, concurrencyService, sessionLimitCache, rpmCache)
|
||||
groupHandler := admin.NewGroupHandler(adminService, dashboardService, groupCapacityService)
|
||||
riskRepository := service.NewRiskRepository(db, settingRepository, redisClient)
|
||||
riskService := service.NewRiskService(riskRepository, settingRepository, redisClient)
|
||||
accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator)
|
||||
adminAnnouncementHandler := admin.NewAnnouncementHandler(announcementService)
|
||||
dataManagementService := service.NewDataManagementService()
|
||||
@ -179,9 +181,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
deferredService := service.ProvideDeferredService(accountRepository, timingWheelService)
|
||||
claudeTokenProvider := service.ProvideClaudeTokenProvider(accountRepository, geminiTokenCache, oAuthService, oauthRefreshAPI)
|
||||
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)
|
||||
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, riskService)
|
||||
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)
|
||||
openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService, openAITokenProvider, riskService)
|
||||
geminiMessagesCompatService := service.NewGeminiMessagesCompatService(accountRepository, groupRepository, gatewayCache, schedulerSnapshotService, geminiTokenProvider, rateLimitService, httpUpstream, antigravityGatewayService, configConfig)
|
||||
opsSystemLogSink := service.ProvideOpsSystemLogSink(opsRepository)
|
||||
opsService := service.NewOpsService(opsRepository, settingRepository, configConfig, accountRepository, userRepository, concurrencyService, gatewayService, openAIGatewayService, geminiMessagesCompatService, antigravityGatewayService, opsSystemLogSink)
|
||||
@ -217,7 +219,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
scheduledTestResultRepository := repository.NewScheduledTestResultRepository(db)
|
||||
scheduledTestService := service.ProvideScheduledTestService(scheduledTestPlanRepository, scheduledTestResultRepository)
|
||||
scheduledTestHandler := admin.NewScheduledTestHandler(scheduledTestService)
|
||||
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler)
|
||||
riskHandler := admin.NewRiskHandler(riskService)
|
||||
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, riskHandler)
|
||||
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
|
||||
userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient)
|
||||
userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig)
|
||||
|
||||
114
backend/internal/handler/admin/risk_handler.go
Normal file
114
backend/internal/handler/admin/risk_handler.go
Normal file
@ -0,0 +1,114 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type RiskHandler struct {
|
||||
service *service.RiskService
|
||||
}
|
||||
|
||||
func NewRiskHandler(svc *service.RiskService) *RiskHandler {
|
||||
return &RiskHandler{service: svc}
|
||||
}
|
||||
|
||||
func (h *RiskHandler) GetSummary(c *gin.Context) {
|
||||
summary, err := h.service.GetSummary(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, summary)
|
||||
}
|
||||
|
||||
func (h *RiskHandler) ListAccounts(c *gin.Context) {
|
||||
filter := service.RiskAccountFilter{
|
||||
Level: c.Query("risk_level"),
|
||||
Platform: c.Query("platform"),
|
||||
}
|
||||
if p := c.Query("page"); p != "" {
|
||||
if v, err := strconv.Atoi(p); err == nil {
|
||||
filter.Page = v
|
||||
}
|
||||
}
|
||||
if l := c.Query("limit"); l != "" {
|
||||
if v, err := strconv.Atoi(l); err == nil {
|
||||
filter.PageSize = v
|
||||
}
|
||||
}
|
||||
|
||||
list, err := h.service.ListAccounts(c.Request.Context(), filter)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, list)
|
||||
}
|
||||
|
||||
func (h *RiskHandler) GetAccountDetail(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
response.ErrorFrom(c, service.ErrRiskAccountNotFound)
|
||||
return
|
||||
}
|
||||
detail, err := h.service.GetAccountDetail(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, detail)
|
||||
}
|
||||
|
||||
type overrideRiskLevelRequest struct {
|
||||
Level string `json:"level" binding:"required"`
|
||||
Reason string `json:"reason" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *RiskHandler) OverrideRiskLevel(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
response.ErrorFrom(c, service.ErrRiskAccountNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var req overrideRiskLevelRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.OverrideRiskLevel(c.Request.Context(), id, req.Level, req.Reason); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, nil)
|
||||
}
|
||||
|
||||
func (h *RiskHandler) GetSettings(c *gin.Context) {
|
||||
settings, err := h.service.GetSettings(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, settings)
|
||||
}
|
||||
|
||||
func (h *RiskHandler) UpdateSettings(c *gin.Context) {
|
||||
var req service.RiskSettings
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
updated, err := h.service.UpdateSettings(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, updated)
|
||||
}
|
||||
@ -30,6 +30,7 @@ type AdminHandlers struct {
|
||||
TLSFingerprintProfile *admin.TLSFingerprintProfileHandler
|
||||
APIKey *admin.AdminAPIKeyHandler
|
||||
ScheduledTest *admin.ScheduledTestHandler
|
||||
Risk *admin.RiskHandler
|
||||
}
|
||||
|
||||
// Handlers contains all HTTP handlers
|
||||
|
||||
@ -33,6 +33,7 @@ func ProvideAdminHandlers(
|
||||
tlsFingerprintProfileHandler *admin.TLSFingerprintProfileHandler,
|
||||
apiKeyHandler *admin.AdminAPIKeyHandler,
|
||||
scheduledTestHandler *admin.ScheduledTestHandler,
|
||||
riskHandler *admin.RiskHandler,
|
||||
) *AdminHandlers {
|
||||
return &AdminHandlers{
|
||||
Dashboard: dashboardHandler,
|
||||
@ -59,6 +60,7 @@ func ProvideAdminHandlers(
|
||||
TLSFingerprintProfile: tlsFingerprintProfileHandler,
|
||||
APIKey: apiKeyHandler,
|
||||
ScheduledTest: scheduledTestHandler,
|
||||
Risk: riskHandler,
|
||||
}
|
||||
}
|
||||
|
||||
@ -150,6 +152,7 @@ var ProviderSet = wire.NewSet(
|
||||
admin.NewTLSFingerprintProfileHandler,
|
||||
admin.NewAdminAPIKeyHandler,
|
||||
admin.NewScheduledTestHandler,
|
||||
admin.NewRiskHandler,
|
||||
|
||||
// AdminHandlers and Handlers constructors
|
||||
ProvideAdminHandlers,
|
||||
|
||||
@ -262,8 +262,41 @@ func hasMCPTools(tools []ClaudeTool) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// filterOpenCodePrompt 过滤 OpenCode 默认提示词,只保留用户自定义指令
|
||||
// claudeCodeSignatures Claude Code / Anthropic 特征字符串,命中任意一个即视为需要过滤的 CLI 默认 prompt
|
||||
var claudeCodeSignatures = []string{
|
||||
"You are Claude Code, Anthropic's official CLI",
|
||||
"You are Claude Code,",
|
||||
"Anthropic's official CLI",
|
||||
"x-anthropic-billing-header",
|
||||
"cc_entrypoint=cli",
|
||||
}
|
||||
|
||||
// filterClaudeCodePrompt 过滤 Claude Code 默认 system prompt,防止 Anthropic 特征暴露给上游
|
||||
// 策略:检测到特征字符串后,尝试提取用户自定义指令部分("Instructions from:" 之后),否则返回空
|
||||
func filterClaudeCodePrompt(text string) (string, bool) {
|
||||
matched := false
|
||||
for _, sig := range claudeCodeSignatures {
|
||||
if strings.Contains(text, sig) {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return text, false
|
||||
}
|
||||
// 尝试保留用户自定义指令
|
||||
if idx := strings.Index(text, "Instructions from:"); idx >= 0 {
|
||||
return text[idx:], true
|
||||
}
|
||||
return "", true
|
||||
}
|
||||
|
||||
// filterOpenCodePrompt 过滤 OpenCode / Claude Code 默认提示词,只保留用户自定义指令
|
||||
func filterOpenCodePrompt(text string) string {
|
||||
// 优先检测 Claude Code 特征
|
||||
if filtered, matched := filterClaudeCodePrompt(text); matched {
|
||||
return filtered
|
||||
}
|
||||
if !strings.Contains(text, "You are an interactive CLI tool") {
|
||||
return text
|
||||
}
|
||||
|
||||
@ -2,7 +2,6 @@ package antigravity
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
@ -353,7 +352,7 @@ func TestBuildGenerationConfig_ThinkingDynamicBudget(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransformClaudeToGeminiWithOptions_PreservesBillingHeaderSystemBlock(t *testing.T) {
|
||||
func TestTransformClaudeToGeminiWithOptions_FiltersBillingHeaderSystemBlock(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
system json.RawMessage
|
||||
@ -388,15 +387,11 @@ func TestTransformClaudeToGeminiWithOptions_PreservesBillingHeaderSystemBlock(t
|
||||
require.NoError(t, json.Unmarshal(body, &req))
|
||||
require.NotNil(t, req.Request.SystemInstruction)
|
||||
|
||||
found := false
|
||||
// Claude Code / Anthropic 特征字符串不应透传给上游
|
||||
for _, part := range req.Request.SystemInstruction.Parts {
|
||||
if strings.Contains(part.Text, "x-anthropic-billing-header keep") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
require.NotContains(t, part.Text, "x-anthropic-billing-header",
|
||||
"Claude Code 特征字符串不应透传给 Antigravity 上游")
|
||||
}
|
||||
|
||||
require.True(t, found, "转换后的 systemInstruction 应保留 x-anthropic-billing-header 内容")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -87,6 +87,9 @@ func RegisterAdminRoutes(
|
||||
|
||||
// 定时测试计划
|
||||
registerScheduledTestRoutes(admin, h)
|
||||
|
||||
// 风控中心
|
||||
registerRiskRoutes(admin, h)
|
||||
}
|
||||
}
|
||||
|
||||
@ -566,3 +569,15 @@ func registerTLSFingerprintProfileRoutes(admin *gin.RouterGroup, h *handler.Hand
|
||||
profiles.DELETE("/:id", h.Admin.TLSFingerprintProfile.Delete)
|
||||
}
|
||||
}
|
||||
|
||||
func registerRiskRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
risk := admin.Group("/risk")
|
||||
{
|
||||
risk.GET("/summary", h.Admin.Risk.GetSummary)
|
||||
risk.GET("/accounts", h.Admin.Risk.ListAccounts)
|
||||
risk.GET("/accounts/:id", h.Admin.Risk.GetAccountDetail)
|
||||
risk.PUT("/accounts/:id/override", h.Admin.Risk.OverrideRiskLevel)
|
||||
risk.GET("/settings", h.Admin.Risk.GetSettings)
|
||||
risk.PUT("/settings", h.Admin.Risk.UpdateSettings)
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,7 +11,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
antigravityTokenRefreshSkew = 3 * time.Minute
|
||||
antigravityTokenRefreshSkew = 5 * time.Minute
|
||||
antigravityTokenCacheSkew = 5 * time.Minute
|
||||
antigravityBackfillCooldown = 5 * time.Minute
|
||||
// antigravityRequestRefreshTimeout 请求路径上 token 刷新的最大等待时间。
|
||||
|
||||
@ -36,7 +36,8 @@ func (r *AntigravityTokenRefresher) CanRefresh(account *Account) bool {
|
||||
}
|
||||
|
||||
// NeedsRefresh 检查账户是否需要刷新
|
||||
// Antigravity 使用固定的15分钟刷新窗口,忽略全局配置
|
||||
// Deprecated: Antigravity 已改为请求路径按需刷新,不再注册后台定时刷新器。
|
||||
// 此方法仅保留以满足 TokenRefresher 接口,不会被 TokenRefreshService 调用。
|
||||
func (r *AntigravityTokenRefresher) NeedsRefresh(account *Account, _ time.Duration) bool {
|
||||
if !r.CanRefresh(account) {
|
||||
return false
|
||||
|
||||
@ -235,6 +235,9 @@ const (
|
||||
|
||||
// SettingKeyBackendModeEnabled Backend 模式:禁用用户注册和自助服务,仅管理员可登录
|
||||
SettingKeyBackendModeEnabled = "backend_mode_enabled"
|
||||
|
||||
// SettingKeyRiskSettings 风控系统配置 (JSON)
|
||||
SettingKeyRiskSettings = "risk_settings"
|
||||
)
|
||||
|
||||
// AdminAPIKeyPrefix is the prefix for admin API keys (distinct from user "sk-" keys).
|
||||
|
||||
@ -565,6 +565,7 @@ type GatewayService struct {
|
||||
debugClaudeMimic atomic.Bool
|
||||
debugGatewayBodyFile atomic.Pointer[os.File] // non-nil when SUB2API_DEBUG_GATEWAY_BODY is set
|
||||
tlsFPProfileService *TLSFingerprintProfileService
|
||||
riskService *RiskService
|
||||
}
|
||||
|
||||
// NewGatewayService creates a new GatewayService
|
||||
@ -592,6 +593,7 @@ func NewGatewayService(
|
||||
digestStore *DigestSessionStore,
|
||||
settingService *SettingService,
|
||||
tlsFPProfileService *TLSFingerprintProfileService,
|
||||
riskService *RiskService,
|
||||
) *GatewayService {
|
||||
userGroupRateTTL := resolveUserGroupRateCacheTTL(cfg)
|
||||
modelsListTTL := resolveModelsListCacheTTL(cfg)
|
||||
@ -624,6 +626,7 @@ func NewGatewayService(
|
||||
modelsListCacheTTL: modelsListTTL,
|
||||
responseHeaderFilter: compileResponseHeaderFilter(cfg),
|
||||
tlsFPProfileService: tlsFPProfileService,
|
||||
riskService: riskService,
|
||||
}
|
||||
svc.userGroupRateResolver = newUserGroupRateResolver(
|
||||
userGroupRateRepo,
|
||||
@ -7683,6 +7686,7 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu
|
||||
if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple {
|
||||
writeUsageLogBestEffort(ctx, s.usageLogRepo, usageLog, "service.gateway")
|
||||
logger.LegacyPrintf("service.gateway", "[SIMPLE MODE] Usage recorded (not billed): user=%d, tokens=%d", usageLog.UserID, usageLog.TotalTokens())
|
||||
s.riskService.CollectBehaviorAsync(ctx, account, usageLog)
|
||||
s.deferredService.ScheduleLastUsedUpdate(account.ID)
|
||||
return nil
|
||||
}
|
||||
@ -7706,6 +7710,7 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu
|
||||
return billingErr
|
||||
}
|
||||
writeUsageLogBestEffort(ctx, s.usageLogRepo, usageLog, "service.gateway")
|
||||
s.riskService.CollectBehaviorAsync(ctx, account, usageLog)
|
||||
|
||||
return nil
|
||||
}
|
||||
@ -7866,6 +7871,7 @@ func (s *GatewayService) RecordUsageWithLongContext(ctx context.Context, input *
|
||||
if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple {
|
||||
writeUsageLogBestEffort(ctx, s.usageLogRepo, usageLog, "service.gateway")
|
||||
logger.LegacyPrintf("service.gateway", "[SIMPLE MODE] Usage recorded (not billed): user=%d, tokens=%d", usageLog.UserID, usageLog.TotalTokens())
|
||||
s.riskService.CollectBehaviorAsync(ctx, account, usageLog)
|
||||
s.deferredService.ScheduleLastUsedUpdate(account.ID)
|
||||
return nil
|
||||
}
|
||||
@ -7889,6 +7895,7 @@ func (s *GatewayService) RecordUsageWithLongContext(ctx context.Context, input *
|
||||
return billingErr
|
||||
}
|
||||
writeUsageLogBestEffort(ctx, s.usageLogRepo, usageLog, "service.gateway")
|
||||
s.riskService.CollectBehaviorAsync(ctx, account, usageLog)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -337,6 +337,7 @@ type OpenAIGatewayService struct {
|
||||
openaiWSRetryMetrics openAIWSRetryMetrics
|
||||
responseHeaderFilter *responseheaders.CompiledHeaderFilter
|
||||
codexSnapshotThrottle *accountWriteThrottle
|
||||
riskService *RiskService
|
||||
}
|
||||
|
||||
// NewOpenAIGatewayService creates a new OpenAIGatewayService
|
||||
@ -357,6 +358,7 @@ func NewOpenAIGatewayService(
|
||||
httpUpstream HTTPUpstream,
|
||||
deferredService *DeferredService,
|
||||
openAITokenProvider *OpenAITokenProvider,
|
||||
riskService *RiskService,
|
||||
) *OpenAIGatewayService {
|
||||
svc := &OpenAIGatewayService{
|
||||
accountRepo: accountRepo,
|
||||
@ -386,6 +388,7 @@ func NewOpenAIGatewayService(
|
||||
openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
|
||||
responseHeaderFilter: compileResponseHeaderFilter(cfg),
|
||||
codexSnapshotThrottle: newAccountWriteThrottle(openAICodexSnapshotPersistMinInterval),
|
||||
riskService: riskService,
|
||||
}
|
||||
svc.logOpenAIWSModeBootstrap()
|
||||
return svc
|
||||
@ -4227,6 +4230,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
|
||||
if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple {
|
||||
writeUsageLogBestEffort(ctx, s.usageLogRepo, usageLog, "service.openai_gateway")
|
||||
logger.LegacyPrintf("service.openai_gateway", "[SIMPLE MODE] Usage recorded (not billed): user=%d, tokens=%d", usageLog.UserID, usageLog.TotalTokens())
|
||||
s.riskService.CollectBehaviorAsync(ctx, account, usageLog)
|
||||
s.deferredService.ScheduleLastUsedUpdate(account.ID)
|
||||
return nil
|
||||
}
|
||||
@ -4250,6 +4254,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
|
||||
return billingErr
|
||||
}
|
||||
writeUsageLogBestEffort(ctx, s.usageLogRepo, usageLog, "service.openai_gateway")
|
||||
s.riskService.CollectBehaviorAsync(ctx, account, usageLog)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
121
backend/internal/service/risk_models.go
Normal file
121
backend/internal/service/risk_models.go
Normal file
@ -0,0 +1,121 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
RiskLevelLow = "LOW"
|
||||
RiskLevelMedium = "MEDIUM"
|
||||
RiskLevelHigh = "HIGH"
|
||||
)
|
||||
|
||||
const (
|
||||
RiskPhaseOff = "off"
|
||||
RiskPhaseObserve = "observe"
|
||||
RiskPhaseEnforce = "enforce"
|
||||
)
|
||||
|
||||
const (
|
||||
riskSettingsCacheKey = "settings:risk:v1"
|
||||
)
|
||||
|
||||
type RiskSettings struct {
|
||||
MediumThreshold float64 `json:"medium_threshold"`
|
||||
HighThreshold float64 `json:"high_threshold"`
|
||||
Phase string `json:"phase"`
|
||||
}
|
||||
|
||||
func DefaultRiskSettings() *RiskSettings {
|
||||
return &RiskSettings{
|
||||
MediumThreshold: 0.45,
|
||||
HighThreshold: 0.75,
|
||||
Phase: RiskPhaseObserve,
|
||||
}
|
||||
}
|
||||
|
||||
type RiskBehaviorHourDelta struct {
|
||||
APICallCount int64
|
||||
StreamCount int64
|
||||
TotalInputTokens int64
|
||||
TotalOutputTokens int64
|
||||
TotalDurationMs int64
|
||||
P50DurationMs *int
|
||||
}
|
||||
|
||||
type RiskSummary struct {
|
||||
TotalAccounts int64 `json:"total_accounts"`
|
||||
LowCount int64 `json:"low_count"`
|
||||
MediumCount int64 `json:"medium_count"`
|
||||
HighCount int64 `json:"high_count"`
|
||||
AverageScore float64 `json:"average_score"`
|
||||
LastScoredAt *time.Time `json:"last_scored_at,omitempty"`
|
||||
Settings *RiskSettings `json:"settings"`
|
||||
}
|
||||
|
||||
type RiskAccountFilter struct {
|
||||
Page int
|
||||
PageSize int
|
||||
Level string
|
||||
Platform string
|
||||
}
|
||||
|
||||
type RiskAccountListItem struct {
|
||||
AccountID int64 `json:"account_id"`
|
||||
AccountName string `json:"account_name"`
|
||||
Platform string `json:"platform"`
|
||||
RiskScore float64 `json:"risk_score"`
|
||||
RiskLevel string `json:"risk_level"`
|
||||
RiskReasons json.RawMessage `json:"risk_reasons"`
|
||||
FeatureVector json.RawMessage `json:"feature_vector"`
|
||||
IdleOverride bool `json:"idle_override"`
|
||||
ScoredAt time.Time `json:"scored_at"`
|
||||
LastHourCalls int64 `json:"last_hour_calls"`
|
||||
LastHourTokens int64 `json:"last_hour_tokens"`
|
||||
}
|
||||
|
||||
type RiskAccountList struct {
|
||||
Items []*RiskAccountListItem `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
type RiskBehaviorHour struct {
|
||||
HourBucket time.Time `json:"hour_bucket"`
|
||||
APICallCount int64 `json:"api_call_count"`
|
||||
StreamCount int64 `json:"stream_count"`
|
||||
TotalInputTokens int64 `json:"total_input_tokens"`
|
||||
TotalOutputTokens int64 `json:"total_output_tokens"`
|
||||
TotalDurationMs int64 `json:"total_duration_ms"`
|
||||
P50DurationMs *int `json:"p50_duration_ms,omitempty"`
|
||||
}
|
||||
|
||||
type RiskAccountDetail struct {
|
||||
AccountID int64 `json:"account_id"`
|
||||
AccountName string `json:"account_name"`
|
||||
Platform string `json:"platform"`
|
||||
RiskScore float64 `json:"risk_score"`
|
||||
RiskLevel string `json:"risk_level"`
|
||||
RiskReasons json.RawMessage `json:"risk_reasons"`
|
||||
FeatureVector json.RawMessage `json:"feature_vector"`
|
||||
IdleOverride bool `json:"idle_override"`
|
||||
ScoredAt time.Time `json:"scored_at"`
|
||||
ModelVersion int `json:"model_version"`
|
||||
HourlyBehavior []RiskBehaviorHour `json:"hourly_behavior"`
|
||||
}
|
||||
|
||||
type RiskScoreRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
AccountID int64 `json:"account_id"`
|
||||
RiskScore float64 `json:"risk_score"`
|
||||
RiskLevel string `json:"risk_level"`
|
||||
RiskReasons json.RawMessage `json:"risk_reasons"`
|
||||
FeatureVector json.RawMessage `json:"feature_vector"`
|
||||
ScoredAt time.Time `json:"scored_at"`
|
||||
ModelVersion int `json:"model_version"`
|
||||
IdleOverride bool `json:"idle_override"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
494
backend/internal/service/risk_repository.go
Normal file
494
backend/internal/service/risk_repository.go
Normal file
@ -0,0 +1,494 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrRiskAccountNotFound = infraerrors.NotFound("RISK_ACCOUNT_NOT_FOUND", "risk account not found")
|
||||
ErrRiskLevelInvalid = infraerrors.BadRequest("RISK_LEVEL_INVALID", "risk level must be LOW, MEDIUM, or HIGH")
|
||||
)
|
||||
|
||||
type RiskRepository interface {
|
||||
UpsertBehaviorHour(ctx context.Context, accountID int64, hour time.Time, delta RiskBehaviorHourDelta) error
|
||||
GetRiskSummary(ctx context.Context) (*RiskSummary, error)
|
||||
ListRiskAccounts(ctx context.Context, filter RiskAccountFilter) (*RiskAccountList, error)
|
||||
GetRiskAccountDetail(ctx context.Context, accountID int64) (*RiskAccountDetail, error)
|
||||
OverrideRiskLevel(ctx context.Context, accountID int64, level, reason string) error
|
||||
GetOrCreateRiskScore(ctx context.Context, accountID int64) (*RiskScoreRecord, error)
|
||||
}
|
||||
|
||||
type pgRiskRepository struct {
|
||||
db *sql.DB
|
||||
settingRepo SettingRepository
|
||||
redis *redis.Client
|
||||
}
|
||||
|
||||
func NewRiskRepository(db *sql.DB, settingRepo SettingRepository, redisClient *redis.Client) RiskRepository {
|
||||
return &pgRiskRepository{
|
||||
db: db,
|
||||
settingRepo: settingRepo,
|
||||
redis: redisClient,
|
||||
}
|
||||
}
|
||||
|
||||
const riskUpsertBehaviorHourSQL = `
|
||||
INSERT INTO account_behavior_hourly (
|
||||
account_id, hour_bucket, api_call_count, stream_count,
|
||||
total_input_tokens, total_output_tokens, total_duration_ms, p50_duration_ms,
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW()
|
||||
)
|
||||
ON CONFLICT (account_id, hour_bucket) DO UPDATE SET
|
||||
api_call_count = account_behavior_hourly.api_call_count + EXCLUDED.api_call_count,
|
||||
stream_count = account_behavior_hourly.stream_count + EXCLUDED.stream_count,
|
||||
total_input_tokens = account_behavior_hourly.total_input_tokens + EXCLUDED.total_input_tokens,
|
||||
total_output_tokens = account_behavior_hourly.total_output_tokens + EXCLUDED.total_output_tokens,
|
||||
total_duration_ms = account_behavior_hourly.total_duration_ms + EXCLUDED.total_duration_ms,
|
||||
p50_duration_ms = COALESCE(EXCLUDED.p50_duration_ms, account_behavior_hourly.p50_duration_ms),
|
||||
updated_at = NOW()
|
||||
`
|
||||
|
||||
const riskSummarySQL = `
|
||||
SELECT
|
||||
COUNT(rs.account_id)::bigint AS total_accounts,
|
||||
COUNT(*) FILTER (WHERE rs.risk_level = 'LOW')::bigint AS low_count,
|
||||
COUNT(*) FILTER (WHERE rs.risk_level = 'MEDIUM')::bigint AS medium_count,
|
||||
COUNT(*) FILTER (WHERE rs.risk_level = 'HIGH')::bigint AS high_count,
|
||||
COALESCE(AVG(rs.risk_score), 0)::double precision AS average_score,
|
||||
MAX(rs.scored_at) AS last_scored_at
|
||||
FROM account_risk_scores rs
|
||||
JOIN accounts a ON a.id = rs.account_id
|
||||
WHERE a.deleted_at IS NULL
|
||||
AND a.type IN ('oauth', 'setup_token')
|
||||
`
|
||||
|
||||
const riskListSQL = `
|
||||
WITH current_hour AS (
|
||||
SELECT account_id, api_call_count,
|
||||
total_input_tokens + total_output_tokens AS total_tokens
|
||||
FROM account_behavior_hourly
|
||||
WHERE hour_bucket = date_trunc('hour', NOW() AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'
|
||||
)
|
||||
SELECT
|
||||
COUNT(*) OVER()::bigint AS total_count,
|
||||
rs.account_id,
|
||||
a.name,
|
||||
a.platform,
|
||||
rs.risk_score,
|
||||
rs.risk_level,
|
||||
rs.risk_reasons,
|
||||
rs.feature_vector,
|
||||
rs.idle_override,
|
||||
rs.scored_at,
|
||||
COALESCE(ch.api_call_count, 0)::bigint AS last_hour_calls,
|
||||
COALESCE(ch.total_tokens, 0)::bigint AS last_hour_tokens
|
||||
FROM account_risk_scores rs
|
||||
JOIN accounts a ON a.id = rs.account_id
|
||||
LEFT JOIN current_hour ch ON ch.account_id = rs.account_id
|
||||
WHERE a.deleted_at IS NULL
|
||||
AND a.type IN ('oauth', 'setup_token')
|
||||
AND ($1 = '' OR rs.risk_level = $1)
|
||||
AND ($2 = '' OR a.platform = $2)
|
||||
ORDER BY rs.risk_score DESC, rs.scored_at DESC, rs.account_id DESC
|
||||
LIMIT $3 OFFSET $4
|
||||
`
|
||||
|
||||
const riskDetailSQL = `
|
||||
SELECT
|
||||
rs.account_id,
|
||||
a.name,
|
||||
a.platform,
|
||||
rs.risk_score,
|
||||
rs.risk_level,
|
||||
rs.risk_reasons,
|
||||
rs.feature_vector,
|
||||
rs.idle_override,
|
||||
rs.scored_at,
|
||||
rs.model_version,
|
||||
bh.hour_bucket,
|
||||
bh.api_call_count,
|
||||
bh.stream_count,
|
||||
bh.total_input_tokens,
|
||||
bh.total_output_tokens,
|
||||
bh.total_duration_ms,
|
||||
bh.p50_duration_ms
|
||||
FROM account_risk_scores rs
|
||||
JOIN accounts a ON a.id = rs.account_id
|
||||
LEFT JOIN account_behavior_hourly bh
|
||||
ON bh.account_id = rs.account_id
|
||||
AND bh.hour_bucket >= (date_trunc('hour', NOW() AT TIME ZONE 'UTC') AT TIME ZONE 'UTC') - INTERVAL '24 hours'
|
||||
WHERE a.deleted_at IS NULL
|
||||
AND a.type IN ('oauth', 'setup_token')
|
||||
AND rs.account_id = $1
|
||||
ORDER BY bh.hour_bucket DESC NULLS LAST
|
||||
`
|
||||
|
||||
const riskOverrideSQL = `
|
||||
UPDATE account_risk_scores
|
||||
SET
|
||||
risk_level = $2,
|
||||
idle_override = TRUE,
|
||||
risk_reasons = COALESCE(risk_reasons, '{}'::jsonb) || jsonb_build_object(
|
||||
'manual_override', jsonb_build_object('level', $2, 'reason', $3, 'at', NOW())
|
||||
),
|
||||
updated_at = NOW()
|
||||
WHERE account_id = $1
|
||||
RETURNING updated_at
|
||||
`
|
||||
|
||||
const riskScoreRefreshSQL = `
|
||||
WITH valid_account AS (
|
||||
SELECT id FROM accounts
|
||||
WHERE id = $1 AND deleted_at IS NULL AND type IN ('oauth', 'setup_token')
|
||||
),
|
||||
behavior AS (
|
||||
SELECT
|
||||
va.id AS account_id,
|
||||
COALESCE(SUM(abh.api_call_count), 0)::double precision AS total_calls_24h,
|
||||
COALESCE(AVG(abh.api_call_count), 0)::double precision AS calls_per_hour_24h,
|
||||
COALESCE(SUM(abh.stream_count), 0)::double precision AS stream_calls_24h,
|
||||
COALESCE(SUM(abh.total_input_tokens), 0)::double precision AS total_input_tokens_24h,
|
||||
COALESCE(
|
||||
percentile_cont(0.50) WITHIN GROUP (ORDER BY abh.p50_duration_ms)
|
||||
FILTER (WHERE abh.p50_duration_ms IS NOT NULL),
|
||||
0
|
||||
)::double precision AS duration_p50_ms,
|
||||
COALESCE(stddev_pop(abh.api_call_count), 0)::double precision AS hourly_entropy
|
||||
FROM valid_account va
|
||||
LEFT JOIN account_behavior_hourly abh
|
||||
ON abh.account_id = va.id
|
||||
AND abh.hour_bucket >= (date_trunc('hour', NOW() AT TIME ZONE 'UTC') AT TIME ZONE 'UTC') - INTERVAL '24 hours'
|
||||
GROUP BY va.id
|
||||
),
|
||||
features AS (
|
||||
SELECT
|
||||
b.account_id,
|
||||
b.calls_per_hour_24h,
|
||||
COALESCE(b.stream_calls_24h / NULLIF(b.total_calls_24h, 0), 0) AS stream_ratio_24h,
|
||||
COALESCE(b.total_input_tokens_24h / NULLIF(b.total_calls_24h, 0), 0) AS token_per_request_avg,
|
||||
b.duration_p50_ms,
|
||||
b.hourly_entropy,
|
||||
b.total_calls_24h
|
||||
FROM behavior b
|
||||
),
|
||||
scored AS (
|
||||
SELECT
|
||||
f.account_id,
|
||||
LEAST(1.0,
|
||||
(0.25 * LEAST(f.calls_per_hour_24h / 50.0, 1.0)) +
|
||||
(0.20 * LEAST(GREATEST(1.0 - f.stream_ratio_24h, 0.0), 1.0)) +
|
||||
(0.15 * LEAST(f.token_per_request_avg / 50000.0, 1.0)) +
|
||||
(0.20 * LEAST(f.duration_p50_ms / 30000.0, 1.0)) +
|
||||
(0.20 * LEAST(f.total_calls_24h / 500.0, 1.0))
|
||||
) AS risk_score,
|
||||
jsonb_build_object(
|
||||
'calls_per_hour_24h', ROUND(f.calls_per_hour_24h::numeric, 6),
|
||||
'stream_ratio_24h', ROUND(f.stream_ratio_24h::numeric, 6),
|
||||
'token_per_request_avg', ROUND(f.token_per_request_avg::numeric, 6),
|
||||
'duration_p50_ms', ROUND(f.duration_p50_ms::numeric, 6),
|
||||
'hourly_entropy', ROUND(f.hourly_entropy::numeric, 6),
|
||||
'total_calls_24h', ROUND(f.total_calls_24h::numeric, 6)
|
||||
) AS feature_vector,
|
||||
jsonb_build_object(
|
||||
'auto', to_jsonb(array_remove(ARRAY[
|
||||
CASE WHEN f.calls_per_hour_24h >= 50 THEN 'high_calls_per_hour' END,
|
||||
CASE WHEN f.stream_ratio_24h <= 0.20 THEN 'low_stream_ratio' END,
|
||||
CASE WHEN f.token_per_request_avg >= 50000 THEN 'high_token_per_request' END,
|
||||
CASE WHEN f.duration_p50_ms >= 30000 THEN 'high_latency_p50' END,
|
||||
CASE WHEN f.total_calls_24h >= 500 THEN 'high_volume_24h' END
|
||||
], NULL))
|
||||
) AS risk_reasons
|
||||
FROM features f
|
||||
)
|
||||
INSERT INTO account_risk_scores (
|
||||
account_id, risk_score, risk_level, risk_reasons, feature_vector,
|
||||
scored_at, model_version, idle_override, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
s.account_id,
|
||||
s.risk_score,
|
||||
CASE
|
||||
WHEN s.risk_score >= $3 THEN 'HIGH'
|
||||
WHEN s.risk_score >= $2 THEN 'MEDIUM'
|
||||
ELSE 'LOW'
|
||||
END,
|
||||
s.risk_reasons,
|
||||
s.feature_vector,
|
||||
NOW(), 1, FALSE, NOW(), NOW()
|
||||
FROM scored s
|
||||
ON CONFLICT (account_id) DO UPDATE SET
|
||||
risk_score = EXCLUDED.risk_score,
|
||||
risk_level = CASE
|
||||
WHEN account_risk_scores.idle_override THEN account_risk_scores.risk_level
|
||||
ELSE EXCLUDED.risk_level
|
||||
END,
|
||||
risk_reasons = CASE
|
||||
WHEN account_risk_scores.idle_override THEN
|
||||
COALESCE(EXCLUDED.risk_reasons, '{}'::jsonb) ||
|
||||
CASE WHEN account_risk_scores.risk_reasons ? 'manual_override'
|
||||
THEN jsonb_build_object('manual_override', account_risk_scores.risk_reasons -> 'manual_override')
|
||||
ELSE '{}'::jsonb
|
||||
END
|
||||
ELSE EXCLUDED.risk_reasons
|
||||
END,
|
||||
feature_vector = EXCLUDED.feature_vector,
|
||||
scored_at = EXCLUDED.scored_at,
|
||||
model_version = EXCLUDED.model_version,
|
||||
idle_override = account_risk_scores.idle_override,
|
||||
updated_at = NOW()
|
||||
RETURNING id, account_id, risk_score, risk_level, risk_reasons, feature_vector,
|
||||
scored_at, model_version, idle_override, created_at, updated_at
|
||||
`
|
||||
|
||||
func (r *pgRiskRepository) UpsertBehaviorHour(ctx context.Context, accountID int64, hour time.Time, delta RiskBehaviorHourDelta) error {
|
||||
hour = hour.UTC().Truncate(time.Hour)
|
||||
_, err := r.db.ExecContext(
|
||||
ctx, riskUpsertBehaviorHourSQL,
|
||||
accountID, hour,
|
||||
delta.APICallCount, delta.StreamCount,
|
||||
delta.TotalInputTokens, delta.TotalOutputTokens,
|
||||
delta.TotalDurationMs, riskNullableInt(delta.P50DurationMs),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *pgRiskRepository) GetRiskSummary(ctx context.Context) (*RiskSummary, error) {
|
||||
var (
|
||||
totalAccounts int64
|
||||
lowCount int64
|
||||
mediumCount int64
|
||||
highCount int64
|
||||
averageScore float64
|
||||
lastScoredAt sql.NullTime
|
||||
)
|
||||
if err := r.db.QueryRowContext(ctx, riskSummarySQL).Scan(
|
||||
&totalAccounts, &lowCount, &mediumCount, &highCount, &averageScore, &lastScoredAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
settings, err := loadRiskSettings(ctx, r.settingRepo, r.redis)
|
||||
if err != nil {
|
||||
settings = DefaultRiskSettings()
|
||||
}
|
||||
|
||||
summary := &RiskSummary{
|
||||
TotalAccounts: totalAccounts,
|
||||
LowCount: lowCount,
|
||||
MediumCount: mediumCount,
|
||||
HighCount: highCount,
|
||||
AverageScore: averageScore,
|
||||
Settings: settings,
|
||||
}
|
||||
if lastScoredAt.Valid {
|
||||
ts := lastScoredAt.Time
|
||||
summary.LastScoredAt = &ts
|
||||
}
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func (r *pgRiskRepository) ListRiskAccounts(ctx context.Context, filter RiskAccountFilter) (*RiskAccountList, error) {
|
||||
level := strings.ToUpper(strings.TrimSpace(filter.Level))
|
||||
platform := strings.TrimSpace(filter.Platform)
|
||||
limit := filter.PageSize
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
page := filter.Page
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
offset := (page - 1) * limit
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, riskListSQL, level, platform, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
result := &RiskAccountList{
|
||||
Items: make([]*RiskAccountListItem, 0, limit),
|
||||
Page: page,
|
||||
PageSize: limit,
|
||||
}
|
||||
for rows.Next() {
|
||||
var (
|
||||
totalCount int64
|
||||
accountName string
|
||||
platformName string
|
||||
riskReasons []byte
|
||||
featureVector []byte
|
||||
item RiskAccountListItem
|
||||
)
|
||||
if err := rows.Scan(
|
||||
&totalCount,
|
||||
&item.AccountID, &accountName, &platformName,
|
||||
&item.RiskScore, &item.RiskLevel,
|
||||
&riskReasons, &featureVector,
|
||||
&item.IdleOverride, &item.ScoredAt,
|
||||
&item.LastHourCalls, &item.LastHourTokens,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.AccountName = accountName
|
||||
item.Platform = platformName
|
||||
item.RiskReasons = riskDecodeJSON(riskReasons)
|
||||
item.FeatureVector = riskDecodeJSON(featureVector)
|
||||
result.Total = totalCount
|
||||
result.Items = append(result.Items, &item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *pgRiskRepository) GetRiskAccountDetail(ctx context.Context, accountID int64) (*RiskAccountDetail, error) {
|
||||
if accountID <= 0 {
|
||||
return nil, ErrRiskAccountNotFound
|
||||
}
|
||||
if _, err := r.GetOrCreateRiskScore(ctx, accountID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, riskDetailSQL, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var detail *RiskAccountDetail
|
||||
for rows.Next() {
|
||||
var (
|
||||
accountName string
|
||||
platformName string
|
||||
riskReasons []byte
|
||||
featureVector []byte
|
||||
hourBucket sql.NullTime
|
||||
apiCallCount sql.NullInt64
|
||||
streamCount sql.NullInt64
|
||||
totalInputTokens sql.NullInt64
|
||||
totalOutputTokens sql.NullInt64
|
||||
totalDurationMs sql.NullInt64
|
||||
p50DurationMs sql.NullInt64
|
||||
)
|
||||
if detail == nil {
|
||||
detail = &RiskAccountDetail{HourlyBehavior: make([]RiskBehaviorHour, 0, 24)}
|
||||
}
|
||||
if err := rows.Scan(
|
||||
&detail.AccountID, &accountName, &platformName,
|
||||
&detail.RiskScore, &detail.RiskLevel,
|
||||
&riskReasons, &featureVector,
|
||||
&detail.IdleOverride, &detail.ScoredAt, &detail.ModelVersion,
|
||||
&hourBucket, &apiCallCount, &streamCount,
|
||||
&totalInputTokens, &totalOutputTokens, &totalDurationMs, &p50DurationMs,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
detail.AccountName = accountName
|
||||
detail.Platform = platformName
|
||||
detail.RiskReasons = riskDecodeJSON(riskReasons)
|
||||
detail.FeatureVector = riskDecodeJSON(featureVector)
|
||||
if hourBucket.Valid {
|
||||
var p50 *int
|
||||
if p50DurationMs.Valid {
|
||||
v := int(p50DurationMs.Int64)
|
||||
p50 = &v
|
||||
}
|
||||
detail.HourlyBehavior = append(detail.HourlyBehavior, RiskBehaviorHour{
|
||||
HourBucket: hourBucket.Time,
|
||||
APICallCount: apiCallCount.Int64,
|
||||
StreamCount: streamCount.Int64,
|
||||
TotalInputTokens: totalInputTokens.Int64,
|
||||
TotalOutputTokens: totalOutputTokens.Int64,
|
||||
TotalDurationMs: totalDurationMs.Int64,
|
||||
P50DurationMs: p50,
|
||||
})
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if detail == nil {
|
||||
return nil, ErrRiskAccountNotFound
|
||||
}
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
func (r *pgRiskRepository) OverrideRiskLevel(ctx context.Context, accountID int64, level, reason string) error {
|
||||
level = strings.ToUpper(strings.TrimSpace(level))
|
||||
switch level {
|
||||
case RiskLevelLow, RiskLevelMedium, RiskLevelHigh:
|
||||
default:
|
||||
return ErrRiskLevelInvalid
|
||||
}
|
||||
if _, err := r.GetOrCreateRiskScore(ctx, accountID); err != nil {
|
||||
return err
|
||||
}
|
||||
var updatedAt time.Time
|
||||
err := r.db.QueryRowContext(ctx, riskOverrideSQL, accountID, level, strings.TrimSpace(reason)).Scan(&updatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrRiskAccountNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
_ = updatedAt
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *pgRiskRepository) GetOrCreateRiskScore(ctx context.Context, accountID int64) (*RiskScoreRecord, error) {
|
||||
if accountID <= 0 {
|
||||
return nil, ErrRiskAccountNotFound
|
||||
}
|
||||
settings, err := loadRiskSettings(ctx, r.settingRepo, r.redis)
|
||||
if err != nil {
|
||||
settings = DefaultRiskSettings()
|
||||
}
|
||||
|
||||
record := &RiskScoreRecord{}
|
||||
var riskReasons, featureVector []byte
|
||||
err = r.db.QueryRowContext(
|
||||
ctx, riskScoreRefreshSQL,
|
||||
accountID, settings.MediumThreshold, settings.HighThreshold,
|
||||
).Scan(
|
||||
&record.ID, &record.AccountID,
|
||||
&record.RiskScore, &record.RiskLevel,
|
||||
&riskReasons, &featureVector,
|
||||
&record.ScoredAt, &record.ModelVersion, &record.IdleOverride,
|
||||
&record.CreatedAt, &record.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrRiskAccountNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
record.RiskReasons = riskDecodeJSON(riskReasons)
|
||||
record.FeatureVector = riskDecodeJSON(featureVector)
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func riskNullableInt(v *int) any {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
func riskDecodeJSON(raw []byte) json.RawMessage {
|
||||
if len(raw) == 0 {
|
||||
return json.RawMessage(`{}`)
|
||||
}
|
||||
return json.RawMessage(raw)
|
||||
}
|
||||
247
backend/internal/service/risk_service.go
Normal file
247
backend/internal/service/risk_service.go
Normal file
@ -0,0 +1,247 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const riskSettingsCacheTTL = 5 * time.Minute
|
||||
|
||||
var (
|
||||
ErrRiskOverrideReasonRequired = infraerrors.BadRequest("RISK_OVERRIDE_REASON_REQUIRED", "override reason is required")
|
||||
ErrRiskSettingsInvalid = infraerrors.BadRequest("RISK_SETTINGS_INVALID", "risk settings are invalid")
|
||||
)
|
||||
|
||||
type RiskService struct {
|
||||
repo RiskRepository
|
||||
settingRepo SettingRepository
|
||||
redis *redis.Client
|
||||
}
|
||||
|
||||
func NewRiskService(repo RiskRepository, settingRepo SettingRepository, redisClient *redis.Client) *RiskService {
|
||||
return &RiskService{
|
||||
repo: repo,
|
||||
settingRepo: settingRepo,
|
||||
redis: redisClient,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *RiskService) CollectBehaviorAsync(ctx context.Context, account *Account, usageLog *UsageLog) {
|
||||
if s == nil || s.repo == nil || account == nil || usageLog == nil {
|
||||
return
|
||||
}
|
||||
if !account.IsOAuth() {
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
bg, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
createdAt := usageLog.CreatedAt
|
||||
if createdAt.IsZero() {
|
||||
createdAt = time.Now()
|
||||
}
|
||||
|
||||
delta := RiskBehaviorHourDelta{
|
||||
APICallCount: 1,
|
||||
StreamCount: riskBoolToInt64(usageLog.Stream),
|
||||
TotalInputTokens: int64(usageLog.InputTokens),
|
||||
TotalOutputTokens: int64(usageLog.OutputTokens),
|
||||
TotalDurationMs: riskIntPtrToInt64(usageLog.DurationMs),
|
||||
P50DurationMs: usageLog.DurationMs,
|
||||
}
|
||||
|
||||
if err := s.repo.UpsertBehaviorHour(bg, usageLog.AccountID, createdAt, delta); err != nil {
|
||||
slog.Warn("risk behavior upsert failed", "account_id", usageLog.AccountID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
settings, err := loadRiskSettings(bg, s.settingRepo, s.redis)
|
||||
if err != nil {
|
||||
settings = DefaultRiskSettings()
|
||||
}
|
||||
if settings.Phase == RiskPhaseOff {
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := s.repo.GetOrCreateRiskScore(bg, usageLog.AccountID); err != nil {
|
||||
slog.Warn("risk score refresh failed", "account_id", usageLog.AccountID, "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *RiskService) GetSummary(ctx context.Context) (*RiskSummary, error) {
|
||||
if s == nil || s.repo == nil {
|
||||
return nil, fmt.Errorf("risk service not initialized")
|
||||
}
|
||||
return s.repo.GetRiskSummary(ctx)
|
||||
}
|
||||
|
||||
func (s *RiskService) ListAccounts(ctx context.Context, filter RiskAccountFilter) (*RiskAccountList, error) {
|
||||
if s == nil || s.repo == nil {
|
||||
return nil, fmt.Errorf("risk service not initialized")
|
||||
}
|
||||
if filter.Page <= 0 {
|
||||
filter.Page = 1
|
||||
}
|
||||
if filter.PageSize <= 0 {
|
||||
filter.PageSize = 20
|
||||
}
|
||||
if filter.PageSize > 200 {
|
||||
filter.PageSize = 200
|
||||
}
|
||||
filter.Level = strings.ToUpper(strings.TrimSpace(filter.Level))
|
||||
filter.Platform = strings.TrimSpace(filter.Platform)
|
||||
return s.repo.ListRiskAccounts(ctx, filter)
|
||||
}
|
||||
|
||||
func (s *RiskService) GetAccountDetail(ctx context.Context, accountID int64) (*RiskAccountDetail, error) {
|
||||
if s == nil || s.repo == nil {
|
||||
return nil, fmt.Errorf("risk service not initialized")
|
||||
}
|
||||
if accountID <= 0 {
|
||||
return nil, ErrRiskAccountNotFound
|
||||
}
|
||||
return s.repo.GetRiskAccountDetail(ctx, accountID)
|
||||
}
|
||||
|
||||
func (s *RiskService) OverrideRiskLevel(ctx context.Context, accountID int64, level, reason string) error {
|
||||
if s == nil || s.repo == nil {
|
||||
return fmt.Errorf("risk service not initialized")
|
||||
}
|
||||
if accountID <= 0 {
|
||||
return ErrRiskAccountNotFound
|
||||
}
|
||||
level = strings.ToUpper(strings.TrimSpace(level))
|
||||
reason = strings.TrimSpace(reason)
|
||||
if reason == "" {
|
||||
return ErrRiskOverrideReasonRequired
|
||||
}
|
||||
switch level {
|
||||
case RiskLevelLow, RiskLevelMedium, RiskLevelHigh:
|
||||
default:
|
||||
return ErrRiskLevelInvalid
|
||||
}
|
||||
return s.repo.OverrideRiskLevel(ctx, accountID, level, reason)
|
||||
}
|
||||
|
||||
func (s *RiskService) GetSettings(ctx context.Context) (*RiskSettings, error) {
|
||||
if s == nil || s.settingRepo == nil {
|
||||
return DefaultRiskSettings(), nil
|
||||
}
|
||||
return loadRiskSettings(ctx, s.settingRepo, s.redis)
|
||||
}
|
||||
|
||||
func (s *RiskService) UpdateSettings(ctx context.Context, settings *RiskSettings) (*RiskSettings, error) {
|
||||
if s == nil || s.settingRepo == nil {
|
||||
return nil, fmt.Errorf("risk service not initialized")
|
||||
}
|
||||
normalized, err := normalizeRiskSettings(settings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := json.Marshal(normalized)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.settingRepo.Set(ctx, SettingKeyRiskSettings, string(data)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.redis != nil {
|
||||
_ = s.redis.Del(ctx, riskSettingsCacheKey).Err()
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func loadRiskSettings(ctx context.Context, settingRepo SettingRepository, redisClient *redis.Client) (*RiskSettings, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
if redisClient != nil {
|
||||
if raw, err := redisClient.Get(ctx, riskSettingsCacheKey).Result(); err == nil && strings.TrimSpace(raw) != "" {
|
||||
settings := DefaultRiskSettings()
|
||||
if err := json.Unmarshal([]byte(raw), settings); err == nil {
|
||||
if normalized, err := normalizeRiskSettings(settings); err == nil {
|
||||
return normalized, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
settings := DefaultRiskSettings()
|
||||
if settingRepo != nil {
|
||||
if raw, err := settingRepo.GetValue(ctx, SettingKeyRiskSettings); err == nil && strings.TrimSpace(raw) != "" {
|
||||
if unmarshalErr := json.Unmarshal([]byte(raw), settings); unmarshalErr != nil {
|
||||
slog.Warn("risk settings json invalid; using defaults", "error", unmarshalErr)
|
||||
settings = DefaultRiskSettings()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
normalized, err := normalizeRiskSettings(settings)
|
||||
if err != nil {
|
||||
normalized = DefaultRiskSettings()
|
||||
}
|
||||
|
||||
if redisClient != nil {
|
||||
if data, marshalErr := json.Marshal(normalized); marshalErr == nil {
|
||||
_ = redisClient.Set(ctx, riskSettingsCacheKey, string(data), riskSettingsCacheTTL).Err()
|
||||
}
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func normalizeRiskSettings(settings *RiskSettings) (*RiskSettings, error) {
|
||||
if settings == nil {
|
||||
return DefaultRiskSettings(), nil
|
||||
}
|
||||
out := &RiskSettings{
|
||||
MediumThreshold: settings.MediumThreshold,
|
||||
HighThreshold: settings.HighThreshold,
|
||||
Phase: strings.ToLower(strings.TrimSpace(settings.Phase)),
|
||||
}
|
||||
if out.MediumThreshold == 0 && out.HighThreshold == 0 && out.Phase == "" {
|
||||
return DefaultRiskSettings(), nil
|
||||
}
|
||||
if out.Phase == "" {
|
||||
out.Phase = RiskPhaseObserve
|
||||
}
|
||||
if out.MediumThreshold < 0 || out.MediumThreshold > 1 {
|
||||
return nil, ErrRiskSettingsInvalid.WithCause(fmt.Errorf("medium_threshold must be between 0 and 1"))
|
||||
}
|
||||
if out.HighThreshold < 0 || out.HighThreshold > 1 {
|
||||
return nil, ErrRiskSettingsInvalid.WithCause(fmt.Errorf("high_threshold must be between 0 and 1"))
|
||||
}
|
||||
if out.MediumThreshold >= out.HighThreshold {
|
||||
return nil, ErrRiskSettingsInvalid.WithCause(fmt.Errorf("medium_threshold must be less than high_threshold"))
|
||||
}
|
||||
switch out.Phase {
|
||||
case RiskPhaseOff, RiskPhaseObserve, RiskPhaseEnforce:
|
||||
default:
|
||||
return nil, ErrRiskSettingsInvalid.WithCause(fmt.Errorf("phase must be one of: %s, %s, %s", RiskPhaseOff, RiskPhaseObserve, RiskPhaseEnforce))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func riskBoolToInt64(v bool) int64 {
|
||||
if v {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func riskIntPtrToInt64(v *int) int64 {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
return int64(*v)
|
||||
}
|
||||
@ -63,14 +63,15 @@ func NewTokenRefreshService(
|
||||
|
||||
claudeRefresher := NewClaudeTokenRefresher(oauthService)
|
||||
geminiRefresher := NewGeminiTokenRefresher(geminiOAuthService)
|
||||
agRefresher := NewAntigravityTokenRefresher(antigravityOAuthService)
|
||||
// Antigravity 使用请求路径按需刷新(GetAccessToken 内部处理),不注册后台定时刷新器。
|
||||
// 后台定时刷新会导致 idle 账号每天产生 ~48 次无效 OAuth 请求,触发风控封号。
|
||||
_ = antigravityOAuthService // 保留参数引用,避免编译错误
|
||||
|
||||
// 注册平台特定的刷新器(TokenRefresher 接口)
|
||||
s.refreshers = []TokenRefresher{
|
||||
claudeRefresher,
|
||||
openAIRefresher,
|
||||
geminiRefresher,
|
||||
agRefresher,
|
||||
}
|
||||
|
||||
// 注册对应的 OAuthRefreshExecutor(带 CacheKey 方法)
|
||||
@ -78,7 +79,6 @@ func NewTokenRefreshService(
|
||||
claudeRefresher,
|
||||
openAIRefresher,
|
||||
geminiRefresher,
|
||||
agRefresher,
|
||||
}
|
||||
|
||||
return s
|
||||
|
||||
@ -490,4 +490,6 @@ var ProviderSet = wire.NewSet(
|
||||
ProvideScheduledTestService,
|
||||
ProvideScheduledTestRunnerService,
|
||||
NewGroupCapacityService,
|
||||
NewRiskRepository,
|
||||
NewRiskService,
|
||||
)
|
||||
|
||||
49
backend/migrations/081_create_risk_tables.sql
Normal file
49
backend/migrations/081_create_risk_tables.sql
Normal file
@ -0,0 +1,49 @@
|
||||
-- +migrate Up
|
||||
|
||||
CREATE TABLE IF NOT EXISTS account_behavior_hourly (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
account_id BIGINT NOT NULL,
|
||||
hour_bucket TIMESTAMPTZ NOT NULL,
|
||||
api_call_count BIGINT NOT NULL DEFAULT 0,
|
||||
stream_count BIGINT NOT NULL DEFAULT 0,
|
||||
total_input_tokens BIGINT NOT NULL DEFAULT 0,
|
||||
total_output_tokens BIGINT NOT NULL DEFAULT 0,
|
||||
total_duration_ms BIGINT NOT NULL DEFAULT 0,
|
||||
p50_duration_ms INT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_account_behavior_hourly UNIQUE (account_id, hour_bucket)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_account_behavior_hourly_account_id ON account_behavior_hourly (account_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_account_behavior_hourly_hour_bucket ON account_behavior_hourly (hour_bucket DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS account_risk_scores (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
account_id BIGINT NOT NULL,
|
||||
risk_score DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
risk_level VARCHAR(16) NOT NULL DEFAULT 'LOW',
|
||||
risk_reasons JSONB NOT NULL DEFAULT '{}',
|
||||
feature_vector JSONB NOT NULL DEFAULT '{}',
|
||||
scored_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
model_version INT NOT NULL DEFAULT 1,
|
||||
idle_override BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_account_risk_scores_account_id UNIQUE (account_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_account_risk_scores_risk_level ON account_risk_scores (risk_level);
|
||||
CREATE INDEX IF NOT EXISTS idx_account_risk_scores_risk_score ON account_risk_scores (risk_score DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_account_risk_scores_scored_at ON account_risk_scores (scored_at DESC);
|
||||
|
||||
-- +migrate Down
|
||||
|
||||
DROP INDEX IF EXISTS idx_account_risk_scores_scored_at;
|
||||
DROP INDEX IF EXISTS idx_account_risk_scores_risk_score;
|
||||
DROP INDEX IF EXISTS idx_account_risk_scores_risk_level;
|
||||
DROP TABLE IF EXISTS account_risk_scores;
|
||||
|
||||
DROP INDEX IF EXISTS idx_account_behavior_hourly_hour_bucket;
|
||||
DROP INDEX IF EXISTS idx_account_behavior_hourly_account_id;
|
||||
DROP TABLE IF EXISTS account_behavior_hourly;
|
||||
99
frontend/src/api/admin/risk.ts
Normal file
99
frontend/src/api/admin/risk.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import { apiClient } from '../client'
|
||||
|
||||
export interface RiskSummary {
|
||||
total_monitored: number
|
||||
high_risk_count: number
|
||||
medium_risk_count: number
|
||||
low_risk_count: number
|
||||
blocked_count: number
|
||||
avg_score: number
|
||||
}
|
||||
|
||||
export interface RiskAccountListItem {
|
||||
account_id: number
|
||||
email: string
|
||||
platform: string
|
||||
risk_level: string
|
||||
risk_score: number
|
||||
scored_at: string
|
||||
is_overridden: boolean
|
||||
}
|
||||
|
||||
export interface RiskAccountList {
|
||||
items: RiskAccountListItem[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export interface RiskBehaviorHour {
|
||||
hour_bucket: string
|
||||
request_count: number
|
||||
token_count: number
|
||||
error_count: number
|
||||
}
|
||||
|
||||
export interface RiskAccountDetail {
|
||||
account_id: number
|
||||
email: string
|
||||
platform: string
|
||||
risk_level: string
|
||||
risk_score: number
|
||||
scored_at: string
|
||||
is_overridden: boolean
|
||||
override_reason: string
|
||||
overridden_at: string
|
||||
behavior_24h: RiskBehaviorHour[]
|
||||
}
|
||||
|
||||
export interface RiskSettings {
|
||||
enabled: boolean
|
||||
phase: string
|
||||
medium_threshold: number
|
||||
high_threshold: number
|
||||
}
|
||||
|
||||
export interface RiskAccountFilter {
|
||||
page?: number
|
||||
limit?: number
|
||||
risk_level?: string
|
||||
platform?: string
|
||||
}
|
||||
|
||||
export async function getRiskSummary(): Promise<RiskSummary> {
|
||||
const res = await apiClient.get('/admin/risk/summary')
|
||||
return res.data.data
|
||||
}
|
||||
|
||||
export async function listRiskAccounts(filter: RiskAccountFilter = {}): Promise<RiskAccountList> {
|
||||
const params: Record<string, string> = {}
|
||||
if (filter.page) params['page'] = String(filter.page)
|
||||
if (filter.limit) params['limit'] = String(filter.limit)
|
||||
if (filter.risk_level) params['risk_level'] = filter.risk_level
|
||||
if (filter.platform) params['platform'] = filter.platform
|
||||
const res = await apiClient.get('/admin/risk/accounts', { params })
|
||||
return res.data.data
|
||||
}
|
||||
|
||||
export async function getRiskAccountDetail(id: number): Promise<RiskAccountDetail> {
|
||||
const res = await apiClient.get(`/admin/risk/accounts/${id}`)
|
||||
return res.data.data
|
||||
}
|
||||
|
||||
export async function overrideRiskLevel(
|
||||
id: number,
|
||||
level: string,
|
||||
reason: string
|
||||
): Promise<void> {
|
||||
await apiClient.put(`/admin/risk/accounts/${id}/override`, { level, reason })
|
||||
}
|
||||
|
||||
export async function getRiskSettings(): Promise<RiskSettings> {
|
||||
const res = await apiClient.get('/admin/risk/settings')
|
||||
return res.data.data
|
||||
}
|
||||
|
||||
export async function updateRiskSettings(settings: RiskSettings): Promise<RiskSettings> {
|
||||
const res = await apiClient.put('/admin/risk/settings', settings)
|
||||
return res.data.data
|
||||
}
|
||||
195
frontend/src/components/admin/risk/RiskAccountDrawer.vue
Normal file
195
frontend/src/components/admin/risk/RiskAccountDrawer.vue
Normal file
@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<!-- Overlay backdrop -->
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="open"
|
||||
class="fixed inset-0 bg-black/40 z-40"
|
||||
@click="$emit('close')"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<!-- Drawer panel -->
|
||||
<Transition name="slide">
|
||||
<div
|
||||
v-if="open"
|
||||
class="fixed right-0 top-0 h-full w-full max-w-md bg-white dark:bg-gray-800 shadow-xl z-50 overflow-y-auto"
|
||||
>
|
||||
<div class="p-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Account Risk Detail</h2>
|
||||
<button
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-if="riskStore.loading && !detail">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Loading…</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="detail">
|
||||
<!-- Basic info -->
|
||||
<div class="space-y-2 mb-6">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-500 dark:text-gray-400">Email</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ detail.email }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-500 dark:text-gray-400">Platform</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ detail.platform }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-500 dark:text-gray-400">Risk Level</span>
|
||||
<span :class="levelClass(detail.risk_level)" class="font-semibold capitalize">
|
||||
{{ detail.risk_level }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center text-sm">
|
||||
<span class="text-gray-500 dark:text-gray-400">Risk Score</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">
|
||||
{{ (detail.risk_score * 100).toFixed(1) }}%
|
||||
</span>
|
||||
</div>
|
||||
<!-- Score bar -->
|
||||
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
class="h-2 rounded-full transition-all duration-300"
|
||||
:class="scoreBarClass(detail.risk_score)"
|
||||
:style="{ width: `${(detail.risk_score * 100).toFixed(1)}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Override form -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-4 mb-6">
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white mb-3">Override Risk Level</h3>
|
||||
<div class="space-y-2">
|
||||
<select
|
||||
v-model="overrideLevel"
|
||||
class="w-full rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm text-gray-900 dark:text-white px-3 py-1.5"
|
||||
>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
<input
|
||||
v-model="overrideReason"
|
||||
type="text"
|
||||
placeholder="Reason (required)"
|
||||
class="w-full rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm text-gray-900 dark:text-white px-3 py-1.5"
|
||||
/>
|
||||
<button
|
||||
:disabled="!overrideReason.trim() || overriding"
|
||||
class="w-full rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
@click="onOverride"
|
||||
>
|
||||
{{ overriding ? 'Saving…' : 'Apply Override' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 24h behavior trend -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white mb-2">24h Behavior</h3>
|
||||
<div class="h-20">
|
||||
<RiskTrendChart :hours="detail.behavior_24h ?? []" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRiskStore } from '@/stores/risk'
|
||||
import RiskTrendChart from './RiskTrendChart.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
accountId: number | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'overridden'): void
|
||||
}>()
|
||||
|
||||
const riskStore = useRiskStore()
|
||||
const detail = ref(riskStore.accountDetail)
|
||||
const overrideLevel = ref('low')
|
||||
const overrideReason = ref('')
|
||||
const overriding = ref(false)
|
||||
|
||||
watch(
|
||||
() => props.accountId,
|
||||
async (id) => {
|
||||
if (id != null) {
|
||||
await riskStore.fetchAccountDetail(id)
|
||||
detail.value = riskStore.accountDetail
|
||||
overrideLevel.value = riskStore.accountDetail?.risk_level ?? 'low'
|
||||
overrideReason.value = ''
|
||||
} else {
|
||||
detail.value = null
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(v) => {
|
||||
if (!v) {
|
||||
overrideReason.value = ''
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function levelClass(level: string) {
|
||||
if (level === 'high') return 'text-red-600 dark:text-red-400'
|
||||
if (level === 'medium') return 'text-yellow-600 dark:text-yellow-400'
|
||||
return 'text-green-600 dark:text-green-400'
|
||||
}
|
||||
|
||||
function scoreBarClass(score: number) {
|
||||
if (score >= 0.75) return 'bg-red-500'
|
||||
if (score >= 0.45) return 'bg-yellow-500'
|
||||
return 'bg-green-500'
|
||||
}
|
||||
|
||||
async function onOverride() {
|
||||
if (!props.accountId || !overrideReason.value.trim()) return
|
||||
overriding.value = true
|
||||
const ok = await riskStore.overrideAccount(props.accountId, overrideLevel.value, overrideReason.value.trim())
|
||||
overriding.value = false
|
||||
if (ok) {
|
||||
emit('overridden')
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
.slide-enter-from,
|
||||
.slide-leave-to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
</style>
|
||||
64
frontend/src/components/admin/risk/RiskDistributionChart.vue
Normal file
64
frontend/src/components/admin/risk/RiskDistributionChart.vue
Normal file
@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center">
|
||||
<svg viewBox="0 0 32 32" class="w-32 h-32 -rotate-90">
|
||||
<circle
|
||||
v-for="seg in segments"
|
||||
:key="seg.label"
|
||||
cx="16"
|
||||
cy="16"
|
||||
r="15.9155"
|
||||
fill="none"
|
||||
:stroke="seg.color"
|
||||
stroke-width="3.2"
|
||||
:stroke-dasharray="`${seg.dash} ${100 - seg.dash}`"
|
||||
:stroke-dashoffset="seg.offset"
|
||||
/>
|
||||
</svg>
|
||||
<div class="mt-3 flex flex-col gap-1">
|
||||
<div v-for="seg in segments" :key="seg.label" class="flex items-center gap-2 text-xs">
|
||||
<span class="inline-block w-3 h-3 rounded-full" :style="{ background: seg.color }"></span>
|
||||
<span class="text-gray-600 dark:text-gray-400">{{ seg.label }}</span>
|
||||
<span class="ml-auto font-medium text-gray-900 dark:text-white">{{ seg.count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { RiskSummary } from '@/api/admin/risk'
|
||||
|
||||
const props = defineProps<{
|
||||
summary: RiskSummary | null
|
||||
}>()
|
||||
|
||||
const CIRCUMFERENCE = 100 // stroke-dasharray uses circumference units for r=15.9155
|
||||
|
||||
const segments = computed(() => {
|
||||
const total = props.summary?.total_monitored ?? 0
|
||||
const high = props.summary?.high_risk_count ?? 0
|
||||
const medium = props.summary?.medium_risk_count ?? 0
|
||||
const low = props.summary?.low_risk_count ?? 0
|
||||
|
||||
if (total === 0) {
|
||||
return [{ label: 'No data', color: '#e5e7eb', dash: 100, offset: 0, count: 0 }]
|
||||
}
|
||||
|
||||
const pct = (n: number) => (n / total) * CIRCUMFERENCE
|
||||
|
||||
const highDash = pct(high)
|
||||
const medDash = pct(medium)
|
||||
const lowDash = pct(low)
|
||||
|
||||
// stroke-dashoffset shifts the start position (circle starts at 3 o'clock)
|
||||
const highOffset = 0
|
||||
const medOffset = CIRCUMFERENCE - highDash
|
||||
const lowOffset = CIRCUMFERENCE - highDash - medDash
|
||||
|
||||
return [
|
||||
{ label: 'High', color: '#ef4444', dash: highDash, offset: highOffset, count: high },
|
||||
{ label: 'Medium', color: '#f59e0b', dash: medDash, offset: medOffset, count: medium },
|
||||
{ label: 'Low', color: '#22c55e', dash: lowDash, offset: lowOffset, count: low }
|
||||
]
|
||||
})
|
||||
</script>
|
||||
90
frontend/src/components/admin/risk/RiskRadarChart.vue
Normal file
90
frontend/src/components/admin/risk/RiskRadarChart.vue
Normal file
@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<svg viewBox="-100 -100 200 200" class="w-full h-full">
|
||||
<!-- Grid rings -->
|
||||
<polygon
|
||||
v-for="ring in rings"
|
||||
:key="ring"
|
||||
:points="hexPoints(ring)"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="0.5"
|
||||
class="text-gray-200 dark:text-gray-700"
|
||||
/>
|
||||
|
||||
<!-- Axis lines -->
|
||||
<line
|
||||
v-for="(axis, i) in axes"
|
||||
:key="axis"
|
||||
x1="0"
|
||||
y1="0"
|
||||
:x2="axisPoint(i, 80).x"
|
||||
:y2="axisPoint(i, 80).y"
|
||||
stroke="currentColor"
|
||||
stroke-width="0.5"
|
||||
class="text-gray-300 dark:text-gray-600"
|
||||
/>
|
||||
|
||||
<!-- Data polygon -->
|
||||
<polygon
|
||||
v-if="dataPoints.length > 0"
|
||||
:points="dataPoints"
|
||||
fill="rgba(59,130,246,0.2)"
|
||||
stroke="#3b82f6"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
|
||||
<!-- Axis labels -->
|
||||
<text
|
||||
v-for="(axis, i) in axes"
|
||||
:key="axis + '-label'"
|
||||
:x="axisPoint(i, 95).x"
|
||||
:y="axisPoint(i, 95).y"
|
||||
text-anchor="middle"
|
||||
dominant-baseline="middle"
|
||||
font-size="9"
|
||||
fill="currentColor"
|
||||
class="text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{{ axis }}
|
||||
</text>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
// values: [req_per_min, tokens_per_day, error_rate, avg_latency_ms, burst_ratio, stream_ratio]
|
||||
// Each value should be normalized 0-1
|
||||
values?: number[]
|
||||
}>()
|
||||
|
||||
const axes = ['RPM', 'Tokens', 'Errors', 'Latency', 'Burst', 'Stream']
|
||||
const rings = [20, 40, 60, 80]
|
||||
const N = axes.length
|
||||
|
||||
function angle(i: number) {
|
||||
return (Math.PI * 2 * i) / N - Math.PI / 2
|
||||
}
|
||||
|
||||
function axisPoint(i: number, r: number) {
|
||||
return { x: r * Math.cos(angle(i)), y: r * Math.sin(angle(i)) }
|
||||
}
|
||||
|
||||
function hexPoints(r: number) {
|
||||
return Array.from({ length: N }, (_, i) => {
|
||||
const p = axisPoint(i, r)
|
||||
return `${p.x},${p.y}`
|
||||
}).join(' ')
|
||||
}
|
||||
|
||||
const dataPoints = computed(() => {
|
||||
const vals = props.values ?? []
|
||||
if (vals.length === 0) return ''
|
||||
return Array.from({ length: N }, (_, i) => {
|
||||
const v = Math.max(0, Math.min(1, vals[i] ?? 0))
|
||||
const p = axisPoint(i, v * 80)
|
||||
return `${p.x},${p.y}`
|
||||
}).join(' ')
|
||||
})
|
||||
</script>
|
||||
36
frontend/src/components/admin/risk/RiskSummaryCards.vue
Normal file
36
frontend/src/components/admin/risk/RiskSummaryCards.vue
Normal file
@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">Total Monitored</p>
|
||||
<p class="mt-1 text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
{{ summary?.total_monitored ?? '—' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/20 p-4">
|
||||
<p class="text-xs text-red-600 dark:text-red-400 uppercase tracking-wide">High Risk</p>
|
||||
<p class="mt-1 text-2xl font-semibold text-red-700 dark:text-red-300">
|
||||
{{ summary?.high_risk_count ?? '—' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-yellow-200 dark:border-yellow-800 bg-yellow-50 dark:bg-yellow-900/20 p-4">
|
||||
<p class="text-xs text-yellow-600 dark:text-yellow-400 uppercase tracking-wide">Medium Risk</p>
|
||||
<p class="mt-1 text-2xl font-semibold text-yellow-700 dark:text-yellow-300">
|
||||
{{ summary?.medium_risk_count ?? '—' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">Blocked</p>
|
||||
<p class="mt-1 text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
{{ summary?.blocked_count ?? '—' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { RiskSummary } from '@/api/admin/risk'
|
||||
|
||||
defineProps<{
|
||||
summary: RiskSummary | null
|
||||
}>()
|
||||
</script>
|
||||
101
frontend/src/components/admin/risk/RiskSystemStatusCard.vue
Normal file
101
frontend/src/components/admin/risk/RiskSystemStatusCard.vue
Normal file
@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4">
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white mb-3">Risk System Settings</h3>
|
||||
<form class="space-y-3" @submit.prevent="onSave">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Phase</label>
|
||||
<select
|
||||
v-model="form.phase"
|
||||
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm text-gray-900 dark:text-white px-3 py-1.5 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="off">Off</option>
|
||||
<option value="observe">Observe</option>
|
||||
<option value="enforce">Enforce</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
Medium Threshold ({{ form.medium_threshold }})
|
||||
</label>
|
||||
<input
|
||||
v-model.number="form.medium_threshold"
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="0.9"
|
||||
step="0.05"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
High Threshold ({{ form.high_threshold }})
|
||||
</label>
|
||||
<input
|
||||
v-model.number="form.high_threshold"
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="0.99"
|
||||
step="0.05"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="risk-enabled"
|
||||
v-model="form.enabled"
|
||||
type="checkbox"
|
||||
class="rounded border-gray-300 text-blue-600"
|
||||
/>
|
||||
<label for="risk-enabled" class="text-xs text-gray-600 dark:text-gray-400">Enabled</label>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="saving"
|
||||
class="w-full rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{{ saving ? 'Saving…' : 'Save Settings' }}
|
||||
</button>
|
||||
<p v-if="saved" class="text-xs text-green-600 dark:text-green-400 text-center">Saved</p>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRiskStore } from '@/stores/risk'
|
||||
import type { RiskSettings } from '@/api/admin/risk'
|
||||
|
||||
const props = defineProps<{
|
||||
settings: RiskSettings | null
|
||||
}>()
|
||||
|
||||
const riskStore = useRiskStore()
|
||||
const saving = ref(false)
|
||||
const saved = ref(false)
|
||||
|
||||
const form = ref<RiskSettings>({
|
||||
enabled: true,
|
||||
phase: 'observe',
|
||||
medium_threshold: 0.45,
|
||||
high_threshold: 0.75
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.settings,
|
||||
(s) => {
|
||||
if (s) form.value = { ...s }
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
async function onSave() {
|
||||
saving.value = true
|
||||
saved.value = false
|
||||
const ok = await riskStore.saveSettings(form.value)
|
||||
saving.value = false
|
||||
if (ok) {
|
||||
saved.value = true
|
||||
setTimeout(() => { saved.value = false }, 2000)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
62
frontend/src/components/admin/risk/RiskTrendChart.vue
Normal file
62
frontend/src/components/admin/risk/RiskTrendChart.vue
Normal file
@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<svg viewBox="0 0 240 80" class="w-full h-full" preserveAspectRatio="none">
|
||||
<!-- Baseline -->
|
||||
<line x1="0" y1="75" x2="240" y2="75" stroke="currentColor" stroke-width="0.5" class="text-gray-200 dark:text-gray-700" />
|
||||
|
||||
<!-- Line -->
|
||||
<polyline
|
||||
v-if="linePoints"
|
||||
:points="linePoints"
|
||||
fill="none"
|
||||
stroke="#3b82f6"
|
||||
stroke-width="1.5"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
|
||||
<!-- Fill area -->
|
||||
<polygon
|
||||
v-if="fillPoints"
|
||||
:points="fillPoints"
|
||||
fill="rgba(59,130,246,0.1)"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { RiskBehaviorHour } from '@/api/admin/risk'
|
||||
|
||||
const props = defineProps<{
|
||||
hours: RiskBehaviorHour[]
|
||||
}>()
|
||||
|
||||
const linePoints = computed(() => {
|
||||
if (!props.hours || props.hours.length < 2) return ''
|
||||
const counts = props.hours.map((h) => h.request_count)
|
||||
const maxVal = Math.max(...counts, 1)
|
||||
const step = 240 / (counts.length - 1)
|
||||
return counts
|
||||
.map((v, i) => {
|
||||
const x = i * step
|
||||
const y = 75 - (v / maxVal) * 65
|
||||
return `${x},${y}`
|
||||
})
|
||||
.join(' ')
|
||||
})
|
||||
|
||||
const fillPoints = computed(() => {
|
||||
if (!props.hours || props.hours.length < 2) return ''
|
||||
const counts = props.hours.map((h) => h.request_count)
|
||||
const maxVal = Math.max(...counts, 1)
|
||||
const step = 240 / (counts.length - 1)
|
||||
const top = counts
|
||||
.map((v, i) => {
|
||||
const x = i * step
|
||||
const y = 75 - (v / maxVal) * 65
|
||||
return `${x},${y}`
|
||||
})
|
||||
.join(' ')
|
||||
const lastX = (counts.length - 1) * step
|
||||
return `${top} ${lastX},75 0,75`
|
||||
})
|
||||
</script>
|
||||
@ -482,6 +482,22 @@ const ChevronDoubleRightIcon = {
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
const ShieldExclamationIcon = {
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M12 9v3.75m0-10.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.75c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.57-.598-3.75h-.152c-3.196 0-6.1-1.249-8.25-3.286zm0 13.036h.008v.008H12v-.008z'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
// User navigation items (for regular users)
|
||||
const userNavItems = computed((): NavItem[] => {
|
||||
const items: NavItem[] = [
|
||||
@ -574,7 +590,8 @@ const adminNavItems = computed((): NavItem[] => {
|
||||
{ path: '/admin/proxies', label: t('nav.proxies'), icon: ServerIcon },
|
||||
{ path: '/admin/redeem', label: t('nav.redeemCodes'), icon: TicketIcon, hideInSimpleMode: true },
|
||||
{ path: '/admin/promo-codes', label: t('nav.promoCodes'), icon: GiftIcon, hideInSimpleMode: true },
|
||||
{ path: '/admin/usage', label: t('nav.usage'), icon: ChartIcon }
|
||||
{ path: '/admin/usage', label: t('nav.usage'), icon: ChartIcon },
|
||||
{ path: '/admin/risk', label: 'Risk Control', icon: ShieldExclamationIcon }
|
||||
]
|
||||
|
||||
// 简单模式下,在系统设置前插入 API密钥
|
||||
|
||||
@ -375,6 +375,17 @@ const routes: RouteRecordRaw[] = [
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
path: '/admin/risk',
|
||||
name: 'AdminRiskControl',
|
||||
component: () => import('@/views/admin/RiskControlView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresAdmin: true,
|
||||
title: 'Risk Control'
|
||||
}
|
||||
},
|
||||
|
||||
// ==================== 404 Not Found ====================
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
|
||||
117
frontend/src/stores/risk.ts
Normal file
117
frontend/src/stores/risk.ts
Normal file
@ -0,0 +1,117 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
getRiskSummary,
|
||||
listRiskAccounts,
|
||||
getRiskAccountDetail,
|
||||
getRiskSettings,
|
||||
updateRiskSettings,
|
||||
overrideRiskLevel
|
||||
} from '@/api/admin/risk'
|
||||
import type {
|
||||
RiskSummary,
|
||||
RiskAccountList,
|
||||
RiskAccountDetail,
|
||||
RiskSettings,
|
||||
RiskAccountFilter
|
||||
} from '@/api/admin/risk'
|
||||
|
||||
export const useRiskStore = defineStore('risk', () => {
|
||||
const summary = ref<RiskSummary | null>(null)
|
||||
const accounts = ref<RiskAccountList | null>(null)
|
||||
const accountDetail = ref<RiskAccountDetail | null>(null)
|
||||
const settings = ref<RiskSettings | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function fetchSummary() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
summary.value = await getRiskSummary()
|
||||
} catch (e: any) {
|
||||
error.value = e?.message ?? 'Failed to load summary'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAccounts(filter: RiskAccountFilter = {}) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
accounts.value = await listRiskAccounts(filter)
|
||||
} catch (e: any) {
|
||||
error.value = e?.message ?? 'Failed to load accounts'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAccountDetail(id: number) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
accountDetail.value = await getRiskAccountDetail(id)
|
||||
} catch (e: any) {
|
||||
error.value = e?.message ?? 'Failed to load account detail'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSettings() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
settings.value = await getRiskSettings()
|
||||
} catch (e: any) {
|
||||
error.value = e?.message ?? 'Failed to load settings'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings(updated: RiskSettings) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
settings.value = await updateRiskSettings(updated)
|
||||
return true
|
||||
} catch (e: any) {
|
||||
error.value = e?.message ?? 'Failed to save settings'
|
||||
return false
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function overrideAccount(id: number, level: string, reason: string) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await overrideRiskLevel(id, level, reason)
|
||||
return true
|
||||
} catch (e: any) {
|
||||
error.value = e?.message ?? 'Failed to override risk level'
|
||||
return false
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
summary,
|
||||
accounts,
|
||||
accountDetail,
|
||||
settings,
|
||||
loading,
|
||||
error,
|
||||
fetchSummary,
|
||||
fetchAccounts,
|
||||
fetchAccountDetail,
|
||||
fetchSettings,
|
||||
saveSettings,
|
||||
overrideAccount
|
||||
}
|
||||
})
|
||||
198
frontend/src/views/admin/RiskControlView.vue
Normal file
198
frontend/src/views/admin/RiskControlView.vue
Normal file
@ -0,0 +1,198 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="space-y-6">
|
||||
<!-- Summary cards -->
|
||||
<RiskSummaryCards :summary="riskStore.summary" />
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<!-- Accounts table (2/3 width) -->
|
||||
<div class="lg:col-span-2 space-y-4">
|
||||
<!-- Filters -->
|
||||
<div class="card p-4 flex flex-wrap items-center gap-3">
|
||||
<select
|
||||
v-model="filter.risk_level"
|
||||
class="rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm text-gray-900 dark:text-white px-3 py-1.5"
|
||||
@change="applyFilter"
|
||||
>
|
||||
<option value="">All Levels</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
<select
|
||||
v-model="filter.platform"
|
||||
class="rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm text-gray-900 dark:text-white px-3 py-1.5"
|
||||
@change="applyFilter"
|
||||
>
|
||||
<option value="">All Platforms</option>
|
||||
<option value="claude">Claude</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="gemini">Gemini</option>
|
||||
<option value="antigravity">Antigravity</option>
|
||||
</select>
|
||||
<button
|
||||
class="ml-auto rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
|
||||
@click="applyFilter"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="card overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Email</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Platform</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Level</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Score</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tr
|
||||
v-for="item in riskStore.accounts?.items ?? []"
|
||||
:key="item.account_id"
|
||||
class="hover:bg-gray-50 dark:hover:bg-gray-700/30 cursor-pointer"
|
||||
@click="openDetail(item.account_id)"
|
||||
>
|
||||
<td class="px-4 py-3 text-gray-900 dark:text-white truncate max-w-[200px]">
|
||||
{{ item.email }}
|
||||
<span v-if="item.is_overridden" class="ml-1 text-xs text-blue-500">(overridden)</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-600 dark:text-gray-400 capitalize">{{ item.platform }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span :class="levelBadgeClass(item.risk_level)" class="rounded-full px-2 py-0.5 text-xs font-medium capitalize">
|
||||
{{ item.risk_level }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-700 dark:text-gray-300">
|
||||
{{ (item.risk_score * 100).toFixed(1) }}%
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<button class="text-blue-600 hover:text-blue-700 text-xs">Detail →</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!riskStore.accounts?.items?.length">
|
||||
<td colspan="5" class="px-4 py-8 text-center text-gray-400 dark:text-gray-500 text-sm">
|
||||
{{ riskStore.loading ? 'Loading…' : 'No accounts found' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div
|
||||
v-if="(riskStore.accounts?.total ?? 0) > filter.limit"
|
||||
class="border-t border-gray-200 dark:border-gray-700 px-4 py-3 flex items-center justify-between"
|
||||
>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ riskStore.accounts?.total ?? 0 }} total
|
||||
</span>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
:disabled="filter.page <= 1"
|
||||
class="rounded border border-gray-300 dark:border-gray-600 px-2 py-1 text-xs disabled:opacity-40"
|
||||
@click="changePage(filter.page - 1)"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<button
|
||||
:disabled="(filter.page * filter.limit) >= (riskStore.accounts?.total ?? 0)"
|
||||
class="rounded border border-gray-300 dark:border-gray-600 px-2 py-1 text-xs disabled:opacity-40"
|
||||
@click="changePage(filter.page + 1)"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar widgets (1/3 width) -->
|
||||
<div class="space-y-4">
|
||||
<RiskSystemStatusCard :settings="riskStore.settings" />
|
||||
|
||||
<div class="card p-4">
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white mb-3">Risk Distribution</h3>
|
||||
<RiskDistributionChart :summary="riskStore.summary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Account detail drawer -->
|
||||
<RiskAccountDrawer
|
||||
:open="drawerOpen"
|
||||
:account-id="selectedAccountId"
|
||||
@close="drawerOpen = false"
|
||||
@overridden="onOverridden"
|
||||
/>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRiskStore } from '@/stores/risk'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import RiskSummaryCards from '@/components/admin/risk/RiskSummaryCards.vue'
|
||||
import RiskDistributionChart from '@/components/admin/risk/RiskDistributionChart.vue'
|
||||
import RiskSystemStatusCard from '@/components/admin/risk/RiskSystemStatusCard.vue'
|
||||
import RiskAccountDrawer from '@/components/admin/risk/RiskAccountDrawer.vue'
|
||||
|
||||
const riskStore = useRiskStore()
|
||||
|
||||
const drawerOpen = ref(false)
|
||||
const selectedAccountId = ref<number | null>(null)
|
||||
|
||||
const filter = reactive({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
risk_level: '',
|
||||
platform: ''
|
||||
})
|
||||
|
||||
function levelBadgeClass(level: string) {
|
||||
if (level === 'high') return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||
if (level === 'medium') return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
|
||||
return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
}
|
||||
|
||||
function openDetail(id: number) {
|
||||
selectedAccountId.value = id
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
async function applyFilter() {
|
||||
filter.page = 1
|
||||
await riskStore.fetchAccounts({
|
||||
page: filter.page,
|
||||
limit: filter.limit,
|
||||
risk_level: filter.risk_level || undefined,
|
||||
platform: filter.platform || undefined
|
||||
})
|
||||
}
|
||||
|
||||
async function changePage(page: number) {
|
||||
filter.page = page
|
||||
await riskStore.fetchAccounts({
|
||||
page: filter.page,
|
||||
limit: filter.limit,
|
||||
risk_level: filter.risk_level || undefined,
|
||||
platform: filter.platform || undefined
|
||||
})
|
||||
}
|
||||
|
||||
async function onOverridden() {
|
||||
await Promise.all([riskStore.fetchSummary(), applyFilter()])
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
riskStore.fetchSummary(),
|
||||
riskStore.fetchAccounts({ page: 1, limit: 20 }),
|
||||
riskStore.fetchSettings()
|
||||
])
|
||||
})
|
||||
</script>
|
||||
Loading…
x
Reference in New Issue
Block a user