feat(risk): 风控数据管道与风控中心
Some checks failed
CI / test (push) Failing after 1m31s
CI / golangci-lint (push) Failing after 3s
Security Scan / backend-security (push) Failing after 3s
Security Scan / frontend-security (push) Failing after 2s

- 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:
win 2026-03-28 03:07:17 +08:00
parent 85ed193ff0
commit f25dd04e0b
29 changed files with 2102 additions and 19 deletions

View File

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

View 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)
}

View File

@ -30,6 +30,7 @@ type AdminHandlers struct {
TLSFingerprintProfile *admin.TLSFingerprintProfileHandler
APIKey *admin.AdminAPIKeyHandler
ScheduledTest *admin.ScheduledTestHandler
Risk *admin.RiskHandler
}
// Handlers contains all HTTP handlers

View File

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

View File

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

View File

@ -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 内容")
})
}
}

View File

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

View File

@ -11,7 +11,7 @@ import (
)
const (
antigravityTokenRefreshSkew = 3 * time.Minute
antigravityTokenRefreshSkew = 5 * time.Minute
antigravityTokenCacheSkew = 5 * time.Minute
antigravityBackfillCooldown = 5 * time.Minute
// antigravityRequestRefreshTimeout 请求路径上 token 刷新的最大等待时间。

View File

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

View File

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

View File

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

View File

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

View 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"`
}

View 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)
}

View 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)
}

View File

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

View File

@ -490,4 +490,6 @@ var ProviderSet = wire.NewSet(
ProvideScheduledTestService,
ProvideScheduledTestRunnerService,
NewGroupCapacityService,
NewRiskRepository,
NewRiskService,
)

View 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;

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

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

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

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

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

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

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

View File

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

View File

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

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