feat(risk-control): add content moderation audit
This commit is contained in:
parent
a1106e8167
commit
fff4a300c6
@ -230,14 +230,18 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
channelMonitorRequestTemplateRepository := repository.NewChannelMonitorRequestTemplateRepository(client, db)
|
channelMonitorRequestTemplateRepository := repository.NewChannelMonitorRequestTemplateRepository(client, db)
|
||||||
channelMonitorRequestTemplateService := service.NewChannelMonitorRequestTemplateService(channelMonitorRequestTemplateRepository)
|
channelMonitorRequestTemplateService := service.NewChannelMonitorRequestTemplateService(channelMonitorRequestTemplateRepository)
|
||||||
channelMonitorRequestTemplateHandler := admin.NewChannelMonitorRequestTemplateHandler(channelMonitorRequestTemplateService)
|
channelMonitorRequestTemplateHandler := admin.NewChannelMonitorRequestTemplateHandler(channelMonitorRequestTemplateService)
|
||||||
|
contentModerationRepository := repository.NewContentModerationRepository(db)
|
||||||
|
contentModerationHashCache := repository.NewContentModerationHashCache(redisClient)
|
||||||
|
contentModerationService := service.NewContentModerationService(settingRepository, contentModerationRepository, contentModerationHashCache, groupRepository, userRepository, apiKeyAuthCacheInvalidator, emailService)
|
||||||
|
contentModerationHandler := admin.NewContentModerationHandler(contentModerationService)
|
||||||
paymentHandler := admin.NewPaymentHandler(paymentService, paymentConfigService)
|
paymentHandler := admin.NewPaymentHandler(paymentService, paymentConfigService)
|
||||||
affiliateHandler := admin.NewAffiliateHandler(affiliateService, adminService)
|
affiliateHandler := admin.NewAffiliateHandler(affiliateService, adminService)
|
||||||
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, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, paymentHandler, affiliateHandler)
|
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, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, paymentHandler, affiliateHandler)
|
||||||
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
|
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
|
||||||
userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient)
|
userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient)
|
||||||
userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig)
|
userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig)
|
||||||
gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, userMessageQueueService, configConfig, settingService)
|
gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, userMessageQueueService, configConfig, settingService)
|
||||||
openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, configConfig)
|
openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, configConfig)
|
||||||
handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo)
|
handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo)
|
||||||
totpHandler := handler.NewTotpHandler(totpService)
|
totpHandler := handler.NewTotpHandler(totpService)
|
||||||
handlerPaymentHandler := handler.NewPaymentHandler(paymentService, paymentConfigService, channelService)
|
handlerPaymentHandler := handler.NewPaymentHandler(paymentService, paymentConfigService, channelService)
|
||||||
|
|||||||
234
backend/internal/handler/admin/content_moderation_handler.go
Normal file
234
backend/internal/handler/admin/content_moderation_handler.go
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ContentModerationHandler struct {
|
||||||
|
service *service.ContentModerationService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewContentModerationHandler(svc *service.ContentModerationService) *ContentModerationHandler {
|
||||||
|
return &ContentModerationHandler{service: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
type contentModerationConfigRequest struct {
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
|
Mode *string `json:"mode"`
|
||||||
|
BaseURL *string `json:"base_url"`
|
||||||
|
Model *string `json:"model"`
|
||||||
|
APIKey *string `json:"api_key"`
|
||||||
|
APIKeys *[]string `json:"api_keys"`
|
||||||
|
ClearAPIKey bool `json:"clear_api_key"`
|
||||||
|
TimeoutMS *int `json:"timeout_ms"`
|
||||||
|
SampleRate *int `json:"sample_rate"`
|
||||||
|
AllGroups *bool `json:"all_groups"`
|
||||||
|
GroupIDs *[]int64 `json:"group_ids"`
|
||||||
|
RecordNonHits *bool `json:"record_non_hits"`
|
||||||
|
WorkerCount *int `json:"worker_count"`
|
||||||
|
QueueSize *int `json:"queue_size"`
|
||||||
|
BlockStatus *int `json:"block_status"`
|
||||||
|
BlockMessage *string `json:"block_message"`
|
||||||
|
EmailOnHit *bool `json:"email_on_hit"`
|
||||||
|
AutoBanEnabled *bool `json:"auto_ban_enabled"`
|
||||||
|
BanThreshold *int `json:"ban_threshold"`
|
||||||
|
ViolationWindowHours *int `json:"violation_window_hours"`
|
||||||
|
RetryCount *int `json:"retry_count"`
|
||||||
|
HitRetentionDays *int `json:"hit_retention_days"`
|
||||||
|
NonHitRetentionDays *int `json:"non_hit_retention_days"`
|
||||||
|
PreHashCheckEnabled *bool `json:"pre_hash_check_enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type contentModerationAPIKeyTestRequest struct {
|
||||||
|
APIKeys []string `json:"api_keys"`
|
||||||
|
BaseURL string `json:"base_url"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
TimeoutMS int `json:"timeout_ms"`
|
||||||
|
Prompt string `json:"prompt"`
|
||||||
|
Images []string `json:"images"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type contentModerationHashRequest struct {
|
||||||
|
InputHash string `json:"input_hash"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ContentModerationHandler) GetConfig(c *gin.Context) {
|
||||||
|
cfg, err := h.service.GetConfig(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
response.ErrorFrom(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Success(c, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ContentModerationHandler) UpdateConfig(c *gin.Context) {
|
||||||
|
var req contentModerationConfigRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg, err := h.service.UpdateConfig(c.Request.Context(), service.UpdateContentModerationConfigInput{
|
||||||
|
Enabled: req.Enabled,
|
||||||
|
Mode: req.Mode,
|
||||||
|
BaseURL: req.BaseURL,
|
||||||
|
Model: req.Model,
|
||||||
|
APIKey: req.APIKey,
|
||||||
|
APIKeys: req.APIKeys,
|
||||||
|
ClearAPIKey: req.ClearAPIKey,
|
||||||
|
TimeoutMS: req.TimeoutMS,
|
||||||
|
SampleRate: req.SampleRate,
|
||||||
|
AllGroups: req.AllGroups,
|
||||||
|
GroupIDs: req.GroupIDs,
|
||||||
|
RecordNonHits: req.RecordNonHits,
|
||||||
|
WorkerCount: req.WorkerCount,
|
||||||
|
QueueSize: req.QueueSize,
|
||||||
|
BlockStatus: req.BlockStatus,
|
||||||
|
BlockMessage: req.BlockMessage,
|
||||||
|
EmailOnHit: req.EmailOnHit,
|
||||||
|
AutoBanEnabled: req.AutoBanEnabled,
|
||||||
|
BanThreshold: req.BanThreshold,
|
||||||
|
ViolationWindowHours: req.ViolationWindowHours,
|
||||||
|
RetryCount: req.RetryCount,
|
||||||
|
HitRetentionDays: req.HitRetentionDays,
|
||||||
|
NonHitRetentionDays: req.NonHitRetentionDays,
|
||||||
|
PreHashCheckEnabled: req.PreHashCheckEnabled,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
response.ErrorFrom(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Success(c, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ContentModerationHandler) TestAPIKeys(c *gin.Context) {
|
||||||
|
var req contentModerationAPIKeyTestRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.service.TestAPIKeys(c.Request.Context(), service.TestContentModerationAPIKeysInput{
|
||||||
|
APIKeys: req.APIKeys,
|
||||||
|
BaseURL: req.BaseURL,
|
||||||
|
Model: req.Model,
|
||||||
|
TimeoutMS: req.TimeoutMS,
|
||||||
|
Prompt: req.Prompt,
|
||||||
|
Images: req.Images,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
response.ErrorFrom(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Success(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ContentModerationHandler) GetStatus(c *gin.Context) {
|
||||||
|
status, err := h.service.GetStatus(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
response.ErrorFrom(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Success(c, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ContentModerationHandler) ListLogs(c *gin.Context) {
|
||||||
|
page, pageSize := response.ParsePagination(c)
|
||||||
|
filter := service.ContentModerationLogFilter{
|
||||||
|
Pagination: pagination.PaginationParams{
|
||||||
|
Page: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
SortOrder: pagination.SortOrderDesc,
|
||||||
|
},
|
||||||
|
Result: c.Query("result"),
|
||||||
|
Endpoint: c.Query("endpoint"),
|
||||||
|
Search: c.Query("search"),
|
||||||
|
}
|
||||||
|
if raw := strings.TrimSpace(c.Query("group_id")); raw != "" {
|
||||||
|
groupID, err := strconv.ParseInt(raw, 10, 64)
|
||||||
|
if err != nil || groupID <= 0 {
|
||||||
|
response.BadRequest(c, "Invalid group_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
filter.GroupID = &groupID
|
||||||
|
}
|
||||||
|
if raw := strings.TrimSpace(c.Query("from")); raw != "" {
|
||||||
|
t, _, err := parseContentModerationDate(raw)
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, "Invalid from")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
filter.From = &t
|
||||||
|
}
|
||||||
|
if raw := strings.TrimSpace(c.Query("to")); raw != "" {
|
||||||
|
t, dateOnly, err := parseContentModerationDate(raw)
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, "Invalid to")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if dateOnly {
|
||||||
|
t = t.Add(24*time.Hour - time.Nanosecond)
|
||||||
|
}
|
||||||
|
filter.To = &t
|
||||||
|
}
|
||||||
|
items, pageResult, err := h.service.ListLogs(c.Request.Context(), filter)
|
||||||
|
if err != nil {
|
||||||
|
response.ErrorFrom(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Paginated(c, items, pageResult.Total, pageResult.Page, pageResult.PageSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ContentModerationHandler) UnbanUser(c *gin.Context) {
|
||||||
|
userID, err := strconv.ParseInt(strings.TrimSpace(c.Param("user_id")), 10, 64)
|
||||||
|
if err != nil || userID <= 0 {
|
||||||
|
response.BadRequest(c, "Invalid user_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.service.UnbanUser(c.Request.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
response.ErrorFrom(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Success(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ContentModerationHandler) DeleteFlaggedHash(c *gin.Context) {
|
||||||
|
var req contentModerationHashRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.service.DeleteFlaggedInputHash(c.Request.Context(), req.InputHash)
|
||||||
|
if err != nil {
|
||||||
|
response.ErrorFrom(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Success(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ContentModerationHandler) ClearFlaggedHashes(c *gin.Context) {
|
||||||
|
result, err := h.service.ClearFlaggedInputHashes(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
response.ErrorFrom(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Success(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseContentModerationDate(raw string) (time.Time, bool, error) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return time.Time{}, false, nil
|
||||||
|
}
|
||||||
|
if t, err := time.Parse(time.RFC3339, raw); err == nil {
|
||||||
|
return t, false, nil
|
||||||
|
}
|
||||||
|
t, err := time.Parse("2006-01-02", raw)
|
||||||
|
return t, err == nil, err
|
||||||
|
}
|
||||||
@ -185,6 +185,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
|||||||
CustomEndpoints: dto.ParseCustomEndpoints(settings.CustomEndpoints),
|
CustomEndpoints: dto.ParseCustomEndpoints(settings.CustomEndpoints),
|
||||||
DefaultConcurrency: settings.DefaultConcurrency,
|
DefaultConcurrency: settings.DefaultConcurrency,
|
||||||
DefaultBalance: settings.DefaultBalance,
|
DefaultBalance: settings.DefaultBalance,
|
||||||
|
RiskControlEnabled: settings.RiskControlEnabled,
|
||||||
AffiliateRebateRate: settings.AffiliateRebateRate,
|
AffiliateRebateRate: settings.AffiliateRebateRate,
|
||||||
AffiliateRebateFreezeHours: settings.AffiliateRebateFreezeHours,
|
AffiliateRebateFreezeHours: settings.AffiliateRebateFreezeHours,
|
||||||
AffiliateRebateDurationDays: settings.AffiliateRebateDurationDays,
|
AffiliateRebateDurationDays: settings.AffiliateRebateDurationDays,
|
||||||
@ -497,6 +498,9 @@ type UpdateSettingsRequest struct {
|
|||||||
// Affiliate (邀请返利) feature switch
|
// Affiliate (邀请返利) feature switch
|
||||||
AffiliateEnabled *bool `json:"affiliate_enabled"`
|
AffiliateEnabled *bool `json:"affiliate_enabled"`
|
||||||
|
|
||||||
|
// 风控中心功能开关
|
||||||
|
RiskControlEnabled *bool `json:"risk_control_enabled"`
|
||||||
|
|
||||||
// OpenAI fast/flex policy (optional, only updated when provided)
|
// OpenAI fast/flex policy (optional, only updated when provided)
|
||||||
OpenAIFastPolicySettings *dto.OpenAIFastPolicySettings `json:"openai_fast_policy_settings,omitempty"`
|
OpenAIFastPolicySettings *dto.OpenAIFastPolicySettings `json:"openai_fast_policy_settings,omitempty"`
|
||||||
}
|
}
|
||||||
@ -1365,6 +1369,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
return previousSettings.AffiliateEnabled
|
return previousSettings.AffiliateEnabled
|
||||||
}(),
|
}(),
|
||||||
|
RiskControlEnabled: func() bool {
|
||||||
|
if req.RiskControlEnabled != nil {
|
||||||
|
return *req.RiskControlEnabled
|
||||||
|
}
|
||||||
|
return previousSettings.RiskControlEnabled
|
||||||
|
}(),
|
||||||
}
|
}
|
||||||
|
|
||||||
authSourceDefaults := &service.AuthSourceDefaultSettings{
|
authSourceDefaults := &service.AuthSourceDefaultSettings{
|
||||||
@ -1616,6 +1626,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
|||||||
AvailableChannelsEnabled: updatedSettings.AvailableChannelsEnabled,
|
AvailableChannelsEnabled: updatedSettings.AvailableChannelsEnabled,
|
||||||
|
|
||||||
AffiliateEnabled: updatedSettings.AffiliateEnabled,
|
AffiliateEnabled: updatedSettings.AffiliateEnabled,
|
||||||
|
|
||||||
|
RiskControlEnabled: updatedSettings.RiskControlEnabled,
|
||||||
}
|
}
|
||||||
if fastPolicy, err := h.settingService.GetOpenAIFastPolicySettings(c.Request.Context()); err != nil {
|
if fastPolicy, err := h.settingService.GetOpenAIFastPolicySettings(c.Request.Context()); err != nil {
|
||||||
slog.Error("openai_fast_policy_settings_get_failed", "error", err)
|
slog.Error("openai_fast_policy_settings_get_failed", "error", err)
|
||||||
@ -2004,6 +2016,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
|
|||||||
if before.AffiliateEnabled != after.AffiliateEnabled {
|
if before.AffiliateEnabled != after.AffiliateEnabled {
|
||||||
changed = append(changed, "affiliate_enabled")
|
changed = append(changed, "affiliate_enabled")
|
||||||
}
|
}
|
||||||
|
if before.RiskControlEnabled != after.RiskControlEnabled {
|
||||||
|
changed = append(changed, "risk_control_enabled")
|
||||||
|
}
|
||||||
changed = appendAuthSourceDefaultChanges(changed, beforeAuthSourceDefaults, afterAuthSourceDefaults)
|
changed = appendAuthSourceDefaultChanges(changed, beforeAuthSourceDefaults, afterAuthSourceDefaults)
|
||||||
return changed
|
return changed
|
||||||
}
|
}
|
||||||
|
|||||||
130
backend/internal/handler/content_moderation_helper.go
Normal file
130
backend/internal/handler/content_moderation_helper.go
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
||||||
|
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *GatewayHandler) checkContentModeration(c *gin.Context, reqLog *zap.Logger, apiKey *service.APIKey, subject middleware2.AuthSubject, protocol string, model string, body []byte) *service.ContentModerationDecision {
|
||||||
|
if h == nil || h.contentModerationService == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return runContentModeration(c, reqLog, h.contentModerationService, apiKey, subject, protocol, model, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func contentModerationStatus(decision *service.ContentModerationDecision) int {
|
||||||
|
if decision == nil || decision.StatusCode < 400 || decision.StatusCode > 599 {
|
||||||
|
return http.StatusForbidden
|
||||||
|
}
|
||||||
|
return decision.StatusCode
|
||||||
|
}
|
||||||
|
|
||||||
|
func contentModerationErrorCode(decision *service.ContentModerationDecision) string {
|
||||||
|
return "content_policy_violation"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *OpenAIGatewayHandler) checkContentModeration(c *gin.Context, reqLog *zap.Logger, apiKey *service.APIKey, subject middleware2.AuthSubject, protocol string, model string, body []byte) *service.ContentModerationDecision {
|
||||||
|
if h == nil || h.contentModerationService == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return runContentModeration(c, reqLog, h.contentModerationService, apiKey, subject, protocol, model, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runContentModeration(c *gin.Context, reqLog *zap.Logger, svc *service.ContentModerationService, apiKey *service.APIKey, subject middleware2.AuthSubject, protocol string, model string, body []byte) *service.ContentModerationDecision {
|
||||||
|
if svc == nil || c == nil || c.Request == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
input := buildContentModerationInput(c, apiKey, subject, protocol, model, body)
|
||||||
|
if reqLog != nil {
|
||||||
|
reqLog.Info("content_moderation.gateway_check_start",
|
||||||
|
zap.String("request_id", input.RequestID),
|
||||||
|
zap.Int64("user_id", input.UserID),
|
||||||
|
zap.Int64("api_key_id", input.APIKeyID),
|
||||||
|
zap.String("api_key_name", input.APIKeyName),
|
||||||
|
zap.Int64p("group_id", input.GroupID),
|
||||||
|
zap.String("group_name", input.GroupName),
|
||||||
|
zap.String("endpoint", input.Endpoint),
|
||||||
|
zap.String("provider", input.Provider),
|
||||||
|
zap.String("protocol", input.Protocol),
|
||||||
|
zap.String("model", input.Model),
|
||||||
|
zap.Int("body_bytes", len(body)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
decision, err := svc.Check(c.Request.Context(), input)
|
||||||
|
if err != nil {
|
||||||
|
if reqLog != nil {
|
||||||
|
reqLog.Warn("content_moderation.check_failed", zap.Error(err))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if reqLog != nil && decision != nil {
|
||||||
|
reqLog.Info("content_moderation.gateway_check_done",
|
||||||
|
zap.String("request_id", input.RequestID),
|
||||||
|
zap.Bool("allowed", decision.Allowed),
|
||||||
|
zap.Bool("blocked", decision.Blocked),
|
||||||
|
zap.Bool("flagged", decision.Flagged),
|
||||||
|
zap.String("action", decision.Action),
|
||||||
|
zap.Int("status_code", decision.StatusCode),
|
||||||
|
zap.String("highest_category", decision.HighestCategory),
|
||||||
|
zap.Float64("highest_score", decision.HighestScore),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return decision
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildContentModerationInput(c *gin.Context, apiKey *service.APIKey, subject middleware2.AuthSubject, protocol string, model string, body []byte) service.ContentModerationCheckInput {
|
||||||
|
input := service.ContentModerationCheckInput{
|
||||||
|
RequestID: contentModerationRequestID(c.Request.Context()),
|
||||||
|
UserID: subject.UserID,
|
||||||
|
Endpoint: GetInboundEndpoint(c),
|
||||||
|
Provider: contentModerationProvider(apiKey),
|
||||||
|
Model: strings.TrimSpace(model),
|
||||||
|
Protocol: protocol,
|
||||||
|
Body: body,
|
||||||
|
}
|
||||||
|
if forcedPlatform, ok := middleware2.GetForcePlatformFromContext(c); ok {
|
||||||
|
input.Provider = strings.TrimSpace(forcedPlatform)
|
||||||
|
}
|
||||||
|
if apiKey != nil {
|
||||||
|
input.APIKeyID = apiKey.ID
|
||||||
|
input.APIKeyName = apiKey.Name
|
||||||
|
if apiKey.User != nil {
|
||||||
|
input.UserEmail = apiKey.User.Email
|
||||||
|
}
|
||||||
|
if apiKey.GroupID != nil {
|
||||||
|
groupID := *apiKey.GroupID
|
||||||
|
input.GroupID = &groupID
|
||||||
|
}
|
||||||
|
if apiKey.Group != nil {
|
||||||
|
input.GroupName = apiKey.Group.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if input.Endpoint == "" && c.Request != nil && c.Request.URL != nil {
|
||||||
|
input.Endpoint = c.Request.URL.Path
|
||||||
|
}
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
|
func contentModerationProvider(apiKey *service.APIKey) string {
|
||||||
|
if apiKey == nil || apiKey.Group == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(apiKey.Group.Platform)
|
||||||
|
}
|
||||||
|
|
||||||
|
func contentModerationRequestID(ctx context.Context) string {
|
||||||
|
if ctx == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if requestID, ok := ctx.Value(ctxkey.RequestID).(string); ok {
|
||||||
|
return strings.TrimSpace(requestID)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@ -197,6 +197,9 @@ type SystemSettings struct {
|
|||||||
// Available Channels feature switch (user-facing aggregate view)
|
// Available Channels feature switch (user-facing aggregate view)
|
||||||
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
|
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
|
||||||
|
|
||||||
|
// 风控中心功能开关
|
||||||
|
RiskControlEnabled bool `json:"risk_control_enabled"`
|
||||||
|
|
||||||
// Affiliate (邀请返利) feature switch
|
// Affiliate (邀请返利) feature switch
|
||||||
AffiliateEnabled bool `json:"affiliate_enabled"`
|
AffiliateEnabled bool `json:"affiliate_enabled"`
|
||||||
|
|
||||||
@ -256,6 +259,8 @@ type PublicSettings struct {
|
|||||||
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
|
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
|
||||||
|
|
||||||
AffiliateEnabled bool `json:"affiliate_enabled"`
|
AffiliateEnabled bool `json:"affiliate_enabled"`
|
||||||
|
|
||||||
|
RiskControlEnabled bool `json:"risk_control_enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// OverloadCooldownSettings 529过载冷却配置 DTO
|
// OverloadCooldownSettings 529过载冷却配置 DTO
|
||||||
|
|||||||
@ -45,6 +45,7 @@ type GatewayHandler struct {
|
|||||||
apiKeyService *service.APIKeyService
|
apiKeyService *service.APIKeyService
|
||||||
usageRecordWorkerPool *service.UsageRecordWorkerPool
|
usageRecordWorkerPool *service.UsageRecordWorkerPool
|
||||||
errorPassthroughService *service.ErrorPassthroughService
|
errorPassthroughService *service.ErrorPassthroughService
|
||||||
|
contentModerationService *service.ContentModerationService
|
||||||
concurrencyHelper *ConcurrencyHelper
|
concurrencyHelper *ConcurrencyHelper
|
||||||
userMsgQueueHelper *UserMsgQueueHelper
|
userMsgQueueHelper *UserMsgQueueHelper
|
||||||
maxAccountSwitches int
|
maxAccountSwitches int
|
||||||
@ -65,6 +66,7 @@ func NewGatewayHandler(
|
|||||||
apiKeyService *service.APIKeyService,
|
apiKeyService *service.APIKeyService,
|
||||||
usageRecordWorkerPool *service.UsageRecordWorkerPool,
|
usageRecordWorkerPool *service.UsageRecordWorkerPool,
|
||||||
errorPassthroughService *service.ErrorPassthroughService,
|
errorPassthroughService *service.ErrorPassthroughService,
|
||||||
|
contentModerationService *service.ContentModerationService,
|
||||||
userMsgQueueService *service.UserMessageQueueService,
|
userMsgQueueService *service.UserMessageQueueService,
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
settingService *service.SettingService,
|
settingService *service.SettingService,
|
||||||
@ -98,6 +100,7 @@ func NewGatewayHandler(
|
|||||||
apiKeyService: apiKeyService,
|
apiKeyService: apiKeyService,
|
||||||
usageRecordWorkerPool: usageRecordWorkerPool,
|
usageRecordWorkerPool: usageRecordWorkerPool,
|
||||||
errorPassthroughService: errorPassthroughService,
|
errorPassthroughService: errorPassthroughService,
|
||||||
|
contentModerationService: contentModerationService,
|
||||||
concurrencyHelper: NewConcurrencyHelper(concurrencyService, SSEPingFormatClaude, pingInterval),
|
concurrencyHelper: NewConcurrencyHelper(concurrencyService, SSEPingFormatClaude, pingInterval),
|
||||||
userMsgQueueHelper: umqHelper,
|
userMsgQueueHelper: umqHelper,
|
||||||
maxAccountSwitches: maxAccountSwitches,
|
maxAccountSwitches: maxAccountSwitches,
|
||||||
@ -189,6 +192,11 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if decision := h.checkContentModeration(c, reqLog, apiKey, subject, service.ContentModerationProtocolAnthropicMessages, reqModel, body); decision != nil && decision.Blocked {
|
||||||
|
h.errorResponse(c, contentModerationStatus(decision), contentModerationErrorCode(decision), decision.Message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Track if we've started streaming (for error handling)
|
// Track if we've started streaming (for error handling)
|
||||||
streamStarted := false
|
streamStarted := false
|
||||||
|
|
||||||
|
|||||||
@ -91,6 +91,11 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if decision := h.checkContentModeration(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIChat, reqModel, body); decision != nil && decision.Blocked {
|
||||||
|
h.chatCompletionsErrorResponse(c, contentModerationStatus(decision), contentModerationErrorCode(decision), decision.Message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Error passthrough binding
|
// Error passthrough binding
|
||||||
if h.errorPassthroughService != nil {
|
if h.errorPassthroughService != nil {
|
||||||
service.BindErrorPassthroughService(c, h.errorPassthroughService)
|
service.BindErrorPassthroughService(c, h.errorPassthroughService)
|
||||||
|
|||||||
@ -96,6 +96,11 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if decision := h.checkContentModeration(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIResponses, reqModel, body); decision != nil && decision.Blocked {
|
||||||
|
h.responsesErrorResponse(c, contentModerationStatus(decision), contentModerationErrorCode(decision), decision.Message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Error passthrough binding
|
// Error passthrough binding
|
||||||
if h.errorPassthroughService != nil {
|
if h.errorPassthroughService != nil {
|
||||||
service.BindErrorPassthroughService(c, h.errorPassthroughService)
|
service.BindErrorPassthroughService(c, h.errorPassthroughService)
|
||||||
|
|||||||
@ -185,6 +185,11 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
|
|||||||
setOpsRequestContext(c, modelName, stream, body)
|
setOpsRequestContext(c, modelName, stream, body)
|
||||||
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(stream, false)))
|
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(stream, false)))
|
||||||
|
|
||||||
|
if decision := h.checkContentModeration(c, reqLog, apiKey, authSubject, service.ContentModerationProtocolGemini, modelName, body); decision != nil && decision.Blocked {
|
||||||
|
googleError(c, contentModerationStatus(decision), decision.Message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 解析渠道级模型映射
|
// 解析渠道级模型映射
|
||||||
channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, modelName)
|
channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, modelName)
|
||||||
reqModel := modelName // 保存映射前的原始模型名
|
reqModel := modelName // 保存映射前的原始模型名
|
||||||
|
|||||||
@ -33,6 +33,7 @@ type AdminHandlers struct {
|
|||||||
Channel *admin.ChannelHandler
|
Channel *admin.ChannelHandler
|
||||||
ChannelMonitor *admin.ChannelMonitorHandler
|
ChannelMonitor *admin.ChannelMonitorHandler
|
||||||
ChannelMonitorTemplate *admin.ChannelMonitorRequestTemplateHandler
|
ChannelMonitorTemplate *admin.ChannelMonitorRequestTemplateHandler
|
||||||
|
ContentModeration *admin.ContentModerationHandler
|
||||||
Payment *admin.PaymentHandler
|
Payment *admin.PaymentHandler
|
||||||
Affiliate *admin.AffiliateHandler
|
Affiliate *admin.AffiliateHandler
|
||||||
}
|
}
|
||||||
|
|||||||
@ -81,6 +81,11 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
|
|||||||
setOpsRequestContext(c, reqModel, reqStream, body)
|
setOpsRequestContext(c, reqModel, reqStream, body)
|
||||||
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false)))
|
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false)))
|
||||||
|
|
||||||
|
if decision := h.checkContentModeration(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIChat, reqModel, body); decision != nil && decision.Blocked {
|
||||||
|
h.errorResponse(c, contentModerationStatus(decision), contentModerationErrorCode(decision), decision.Message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 解析渠道级模型映射
|
// 解析渠道级模型映射
|
||||||
channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel)
|
channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel)
|
||||||
|
|
||||||
|
|||||||
@ -27,15 +27,16 @@ import (
|
|||||||
|
|
||||||
// OpenAIGatewayHandler handles OpenAI API gateway requests
|
// OpenAIGatewayHandler handles OpenAI API gateway requests
|
||||||
type OpenAIGatewayHandler struct {
|
type OpenAIGatewayHandler struct {
|
||||||
gatewayService *service.OpenAIGatewayService
|
gatewayService *service.OpenAIGatewayService
|
||||||
billingCacheService *service.BillingCacheService
|
billingCacheService *service.BillingCacheService
|
||||||
apiKeyService *service.APIKeyService
|
apiKeyService *service.APIKeyService
|
||||||
usageRecordWorkerPool *service.UsageRecordWorkerPool
|
usageRecordWorkerPool *service.UsageRecordWorkerPool
|
||||||
errorPassthroughService *service.ErrorPassthroughService
|
errorPassthroughService *service.ErrorPassthroughService
|
||||||
concurrencyHelper *ConcurrencyHelper
|
contentModerationService *service.ContentModerationService
|
||||||
imageLimiter *imageConcurrencyLimiter
|
concurrencyHelper *ConcurrencyHelper
|
||||||
maxAccountSwitches int
|
imageLimiter *imageConcurrencyLimiter
|
||||||
cfg *config.Config
|
maxAccountSwitches int
|
||||||
|
cfg *config.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveOpenAIMessagesDispatchMappedModel(apiKey *service.APIKey, requestedModel string) string {
|
func resolveOpenAIMessagesDispatchMappedModel(apiKey *service.APIKey, requestedModel string) string {
|
||||||
@ -53,6 +54,7 @@ func NewOpenAIGatewayHandler(
|
|||||||
apiKeyService *service.APIKeyService,
|
apiKeyService *service.APIKeyService,
|
||||||
usageRecordWorkerPool *service.UsageRecordWorkerPool,
|
usageRecordWorkerPool *service.UsageRecordWorkerPool,
|
||||||
errorPassthroughService *service.ErrorPassthroughService,
|
errorPassthroughService *service.ErrorPassthroughService,
|
||||||
|
contentModerationService *service.ContentModerationService,
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
) *OpenAIGatewayHandler {
|
) *OpenAIGatewayHandler {
|
||||||
pingInterval := time.Duration(0)
|
pingInterval := time.Duration(0)
|
||||||
@ -64,15 +66,16 @@ func NewOpenAIGatewayHandler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return &OpenAIGatewayHandler{
|
return &OpenAIGatewayHandler{
|
||||||
gatewayService: gatewayService,
|
gatewayService: gatewayService,
|
||||||
billingCacheService: billingCacheService,
|
billingCacheService: billingCacheService,
|
||||||
apiKeyService: apiKeyService,
|
apiKeyService: apiKeyService,
|
||||||
usageRecordWorkerPool: usageRecordWorkerPool,
|
usageRecordWorkerPool: usageRecordWorkerPool,
|
||||||
errorPassthroughService: errorPassthroughService,
|
errorPassthroughService: errorPassthroughService,
|
||||||
concurrencyHelper: NewConcurrencyHelper(concurrencyService, SSEPingFormatComment, pingInterval),
|
contentModerationService: contentModerationService,
|
||||||
imageLimiter: &imageConcurrencyLimiter{},
|
concurrencyHelper: NewConcurrencyHelper(concurrencyService, SSEPingFormatComment, pingInterval),
|
||||||
maxAccountSwitches: maxAccountSwitches,
|
imageLimiter: &imageConcurrencyLimiter{},
|
||||||
cfg: cfg,
|
maxAccountSwitches: maxAccountSwitches,
|
||||||
|
cfg: cfg,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -189,6 +192,11 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
|
|||||||
setOpsRequestContext(c, reqModel, reqStream, body)
|
setOpsRequestContext(c, reqModel, reqStream, body)
|
||||||
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false)))
|
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false)))
|
||||||
|
|
||||||
|
if decision := h.checkContentModeration(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIResponses, reqModel, body); decision != nil && decision.Blocked {
|
||||||
|
h.errorResponse(c, contentModerationStatus(decision), contentModerationErrorCode(decision), decision.Message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
imageIntent := service.IsImageGenerationIntent("/v1/responses", reqModel, body)
|
imageIntent := service.IsImageGenerationIntent("/v1/responses", reqModel, body)
|
||||||
if imageIntent && !service.GroupAllowsImageGeneration(apiKey.Group) {
|
if imageIntent && !service.GroupAllowsImageGeneration(apiKey.Group) {
|
||||||
h.errorResponse(c, http.StatusForbidden, "permission_error", service.ImageGenerationPermissionMessage())
|
h.errorResponse(c, http.StatusForbidden, "permission_error", service.ImageGenerationPermissionMessage())
|
||||||
@ -599,6 +607,11 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
|
|||||||
setOpsRequestContext(c, reqModel, reqStream, body)
|
setOpsRequestContext(c, reqModel, reqStream, body)
|
||||||
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false)))
|
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false)))
|
||||||
|
|
||||||
|
if decision := h.checkContentModeration(c, reqLog, apiKey, subject, service.ContentModerationProtocolAnthropicMessages, reqModel, body); decision != nil && decision.Blocked {
|
||||||
|
h.anthropicErrorResponse(c, contentModerationStatus(decision), contentModerationErrorCode(decision), decision.Message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 解析渠道级模型映射
|
// 解析渠道级模型映射
|
||||||
channelMappingMsg, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel)
|
channelMappingMsg, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel)
|
||||||
|
|
||||||
@ -1153,6 +1166,12 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
|||||||
setOpsRequestContext(c, reqModel, true, firstMessage)
|
setOpsRequestContext(c, reqModel, true, firstMessage)
|
||||||
setOpsEndpointContext(c, "", int16(service.RequestTypeWSV2))
|
setOpsEndpointContext(c, "", int16(service.RequestTypeWSV2))
|
||||||
|
|
||||||
|
if decision := h.checkContentModeration(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIResponses, reqModel, firstMessage); decision != nil && decision.Blocked {
|
||||||
|
writeContentModerationWSError(ctx, wsConn, decision)
|
||||||
|
closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, decision.Message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if service.IsImageGenerationIntent("/v1/responses", reqModel, firstMessage) && !service.GroupAllowsImageGeneration(apiKey.Group) {
|
if service.IsImageGenerationIntent("/v1/responses", reqModel, firstMessage) && !service.GroupAllowsImageGeneration(apiKey.Group) {
|
||||||
closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, service.ImageGenerationPermissionMessage())
|
closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, service.ImageGenerationPermissionMessage())
|
||||||
return
|
return
|
||||||
@ -1268,6 +1287,26 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
|||||||
|
|
||||||
hooks := &service.OpenAIWSIngressHooks{
|
hooks := &service.OpenAIWSIngressHooks{
|
||||||
InitialRequestModel: reqModel,
|
InitialRequestModel: reqModel,
|
||||||
|
BeforeRequest: func(turn int, payload []byte, originalModel string) error {
|
||||||
|
if turn == 1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !gjson.ValidBytes(payload) {
|
||||||
|
return service.NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", errors.New("invalid json"))
|
||||||
|
}
|
||||||
|
model := strings.TrimSpace(originalModel)
|
||||||
|
if model == "" {
|
||||||
|
model = strings.TrimSpace(gjson.GetBytes(payload, "model").String())
|
||||||
|
}
|
||||||
|
if model == "" {
|
||||||
|
model = reqModel
|
||||||
|
}
|
||||||
|
if decision := h.checkContentModeration(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIResponses, model, payload); decision != nil && decision.Blocked {
|
||||||
|
writeContentModerationWSError(ctx, wsConn, decision)
|
||||||
|
return service.NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, decision.Message, nil)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
BeforeTurn: func(turn int) error {
|
BeforeTurn: func(turn int) error {
|
||||||
if turn == 1 {
|
if turn == 1 {
|
||||||
return nil
|
return nil
|
||||||
@ -1712,6 +1751,34 @@ func closeOpenAIClientWS(conn *coderws.Conn, status coderws.StatusCode, reason s
|
|||||||
_ = conn.CloseNow()
|
_ = conn.CloseNow()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func writeContentModerationWSError(ctx context.Context, conn *coderws.Conn, decision *service.ContentModerationDecision) {
|
||||||
|
if conn == nil || decision == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
message := strings.TrimSpace(decision.Message)
|
||||||
|
if message == "" {
|
||||||
|
message = "content moderation blocked this request"
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(gin.H{
|
||||||
|
"event_id": "evt_content_moderation_blocked",
|
||||||
|
"type": "error",
|
||||||
|
"error": gin.H{
|
||||||
|
"type": "invalid_request_error",
|
||||||
|
"code": contentModerationErrorCode(decision),
|
||||||
|
"message": message,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
payload = []byte(`{"event_id":"evt_content_moderation_blocked","type":"error","error":{"type":"invalid_request_error","code":"content_policy_violation","message":"content moderation blocked this request"}}`)
|
||||||
|
}
|
||||||
|
writeCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_ = conn.Write(writeCtx, coderws.MessageText, payload)
|
||||||
|
}
|
||||||
|
|
||||||
func summarizeWSCloseErrorForLog(err error) (string, string) {
|
func summarizeWSCloseErrorForLog(err error) (string, string) {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return "-", "-"
|
return "-", "-"
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil"
|
pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
coderws "github.com/coder/websocket"
|
coderws "github.com/coder/websocket"
|
||||||
@ -646,6 +647,180 @@ func TestOpenAIResponsesWebSocket_PreviousResponseIDKindLoggedBeforeAcquireFailu
|
|||||||
require.Contains(t, strings.ToLower(closeErr.Reason), "failed to acquire user concurrency slot")
|
require.Contains(t, strings.ToLower(closeErr.Reason), "failed to acquire user concurrency slot")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type contentModerationHandlerSettingRepo struct {
|
||||||
|
values map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationHandlerSettingRepo) Get(ctx context.Context, key string) (*service.Setting, error) {
|
||||||
|
if value, ok := r.values[key]; ok {
|
||||||
|
return &service.Setting{Key: key, Value: value}, nil
|
||||||
|
}
|
||||||
|
return nil, service.ErrSettingNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationHandlerSettingRepo) GetValue(ctx context.Context, key string) (string, error) {
|
||||||
|
if value, ok := r.values[key]; ok {
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
return "", service.ErrSettingNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationHandlerSettingRepo) Set(ctx context.Context, key, value string) error {
|
||||||
|
if r.values == nil {
|
||||||
|
r.values = map[string]string{}
|
||||||
|
}
|
||||||
|
r.values[key] = value
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationHandlerSettingRepo) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) {
|
||||||
|
out := map[string]string{}
|
||||||
|
for _, key := range keys {
|
||||||
|
if value, ok := r.values[key]; ok {
|
||||||
|
out[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationHandlerSettingRepo) SetMultiple(ctx context.Context, settings map[string]string) error {
|
||||||
|
if r.values == nil {
|
||||||
|
r.values = map[string]string{}
|
||||||
|
}
|
||||||
|
for key, value := range settings {
|
||||||
|
r.values[key] = value
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationHandlerSettingRepo) GetAll(ctx context.Context) (map[string]string, error) {
|
||||||
|
out := make(map[string]string, len(r.values))
|
||||||
|
for key, value := range r.values {
|
||||||
|
out[key] = value
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationHandlerSettingRepo) Delete(ctx context.Context, key string) error {
|
||||||
|
delete(r.values, key)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type contentModerationHandlerTestRepo struct {
|
||||||
|
logs []service.ContentModerationLog
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationHandlerTestRepo) CreateLog(ctx context.Context, log *service.ContentModerationLog) error {
|
||||||
|
if log != nil {
|
||||||
|
r.logs = append(r.logs, *log)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationHandlerTestRepo) ListLogs(ctx context.Context, filter service.ContentModerationLogFilter) ([]service.ContentModerationLog, *pagination.PaginationResult, error) {
|
||||||
|
return nil, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationHandlerTestRepo) CountFlaggedByUserSince(ctx context.Context, userID int64, since time.Time) (int, error) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationHandlerTestRepo) CleanupExpiredLogs(ctx context.Context, hitBefore time.Time, nonHitBefore time.Time) (*service.ContentModerationCleanupResult, error) {
|
||||||
|
return &service.ContentModerationCleanupResult{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAIResponsesWebSocket_ContentModerationBlocksFirstFrame(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
moderationServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
require.Equal(t, "/v1/moderations", r.URL.Path)
|
||||||
|
_, _ = w.Write([]byte(`{"results":[{"category_scores":{"sexual":0.9}}]}`))
|
||||||
|
}))
|
||||||
|
defer moderationServer.Close()
|
||||||
|
|
||||||
|
cfg := &service.ContentModerationConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Mode: service.ContentModerationModePreBlock,
|
||||||
|
BaseURL: moderationServer.URL,
|
||||||
|
Model: "omni-moderation-latest",
|
||||||
|
APIKeys: []string{"sk-test"},
|
||||||
|
SampleRate: 100,
|
||||||
|
AllGroups: true,
|
||||||
|
BlockMessage: "内容审计测试阻断",
|
||||||
|
}
|
||||||
|
rawCfg, err := json.Marshal(cfg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
repo := &contentModerationHandlerTestRepo{}
|
||||||
|
settingRepo := &contentModerationHandlerSettingRepo{values: map[string]string{
|
||||||
|
service.SettingKeyRiskControlEnabled: "true",
|
||||||
|
service.SettingKeyContentModerationConfig: string(rawCfg),
|
||||||
|
}}
|
||||||
|
moderationSvc := service.NewContentModerationService(
|
||||||
|
settingRepo,
|
||||||
|
repo,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
decision, err := moderationSvc.Check(context.Background(), service.ContentModerationCheckInput{
|
||||||
|
UserID: 1,
|
||||||
|
Endpoint: "/v1/responses",
|
||||||
|
Provider: "openai",
|
||||||
|
Model: "gpt-5.5",
|
||||||
|
Protocol: service.ContentModerationProtocolOpenAIResponses,
|
||||||
|
Body: []byte(`{"model":"gpt-5.5","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"bad prompt"}]}]}`),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, decision.Blocked)
|
||||||
|
repo.logs = nil
|
||||||
|
h := &OpenAIGatewayHandler{
|
||||||
|
gatewayService: &service.OpenAIGatewayService{},
|
||||||
|
billingCacheService: &service.BillingCacheService{},
|
||||||
|
apiKeyService: &service.APIKeyService{},
|
||||||
|
contentModerationService: moderationSvc,
|
||||||
|
concurrencyHelper: NewConcurrencyHelper(service.NewConcurrencyService(&concurrencyCacheMock{}), SSEPingFormatNone, time.Second),
|
||||||
|
}
|
||||||
|
wsServer := newOpenAIWSHandlerTestServer(t, h, middleware.AuthSubject{UserID: 1, Concurrency: 1})
|
||||||
|
defer wsServer.Close()
|
||||||
|
|
||||||
|
dialCtx, cancelDial := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
clientConn, _, err := coderws.Dial(dialCtx, "ws"+strings.TrimPrefix(wsServer.URL, "http")+"/openai/v1/responses", nil)
|
||||||
|
cancelDial()
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() {
|
||||||
|
_ = clientConn.CloseNow()
|
||||||
|
}()
|
||||||
|
|
||||||
|
writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{
|
||||||
|
"type":"response.create",
|
||||||
|
"model":"gpt-5.5",
|
||||||
|
"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"bad prompt"}]}]
|
||||||
|
}`))
|
||||||
|
cancelWrite()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
_, payload, readErr := clientConn.Read(readCtx)
|
||||||
|
cancelRead()
|
||||||
|
if readErr == nil {
|
||||||
|
require.Contains(t, string(payload), "content_policy_violation")
|
||||||
|
require.Contains(t, string(payload), "内容审计测试阻断")
|
||||||
|
} else {
|
||||||
|
var closeErr coderws.CloseError
|
||||||
|
require.ErrorAs(t, readErr, &closeErr)
|
||||||
|
require.Equal(t, coderws.StatusPolicyViolation, closeErr.Code)
|
||||||
|
require.Contains(t, closeErr.Reason, "内容审计测试阻断")
|
||||||
|
}
|
||||||
|
require.Len(t, repo.logs, 1)
|
||||||
|
require.True(t, repo.logs[0].Flagged)
|
||||||
|
require.Equal(t, service.ContentModerationActionBlock, repo.logs[0].Action)
|
||||||
|
require.Equal(t, "bad prompt", repo.logs[0].InputExcerpt)
|
||||||
|
}
|
||||||
|
|
||||||
func TestOpenAIResponsesWebSocket_PassthroughUsageLogPersistsUserAgentAndReasoningEffort(t *testing.T) {
|
func TestOpenAIResponsesWebSocket_PassthroughUsageLogPersistsUserAgentAndReasoningEffort(t *testing.T) {
|
||||||
got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
|
got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
|
||||||
firstPayload: `{"type":"response.create","model":"gpt-5.4","stream":false,"reasoning":{"effort":"HIGH"}}`,
|
firstPayload: `{"type":"response.create","model":"gpt-5.4","stream":false,"reasoning":{"effort":"HIGH"}}`,
|
||||||
|
|||||||
@ -85,6 +85,10 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) {
|
|||||||
h.errorResponse(c, http.StatusForbidden, "permission_error", service.ImageGenerationPermissionMessage())
|
h.errorResponse(c, http.StatusForbidden, "permission_error", service.ImageGenerationPermissionMessage())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if decision := h.checkContentModeration(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIImages, parsed.Model, parsed.ModerationBody()); decision != nil && decision.Blocked {
|
||||||
|
h.errorResponse(c, contentModerationStatus(decision), contentModerationErrorCode(decision), decision.Message)
|
||||||
|
return
|
||||||
|
}
|
||||||
imageReleaseFunc, acquired := h.acquireImageGenerationSlot(c, streamStarted)
|
imageReleaseFunc, acquired := h.acquireImageGenerationSlot(c, streamStarted)
|
||||||
if !acquired {
|
if !acquired {
|
||||||
return
|
return
|
||||||
|
|||||||
@ -77,5 +77,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
|
|||||||
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
|
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
|
||||||
|
|
||||||
AffiliateEnabled: settings.AffiliateEnabled,
|
AffiliateEnabled: settings.AffiliateEnabled,
|
||||||
|
|
||||||
|
RiskControlEnabled: settings.RiskControlEnabled,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -36,6 +36,7 @@ func ProvideAdminHandlers(
|
|||||||
channelHandler *admin.ChannelHandler,
|
channelHandler *admin.ChannelHandler,
|
||||||
channelMonitorHandler *admin.ChannelMonitorHandler,
|
channelMonitorHandler *admin.ChannelMonitorHandler,
|
||||||
channelMonitorTemplateHandler *admin.ChannelMonitorRequestTemplateHandler,
|
channelMonitorTemplateHandler *admin.ChannelMonitorRequestTemplateHandler,
|
||||||
|
contentModerationHandler *admin.ContentModerationHandler,
|
||||||
paymentHandler *admin.PaymentHandler,
|
paymentHandler *admin.PaymentHandler,
|
||||||
affiliateHandler *admin.AffiliateHandler,
|
affiliateHandler *admin.AffiliateHandler,
|
||||||
) *AdminHandlers {
|
) *AdminHandlers {
|
||||||
@ -67,6 +68,7 @@ func ProvideAdminHandlers(
|
|||||||
Channel: channelHandler,
|
Channel: channelHandler,
|
||||||
ChannelMonitor: channelMonitorHandler,
|
ChannelMonitor: channelMonitorHandler,
|
||||||
ChannelMonitorTemplate: channelMonitorTemplateHandler,
|
ChannelMonitorTemplate: channelMonitorTemplateHandler,
|
||||||
|
ContentModeration: contentModerationHandler,
|
||||||
Payment: paymentHandler,
|
Payment: paymentHandler,
|
||||||
Affiliate: affiliateHandler,
|
Affiliate: affiliateHandler,
|
||||||
}
|
}
|
||||||
@ -170,6 +172,7 @@ var ProviderSet = wire.NewSet(
|
|||||||
admin.NewChannelHandler,
|
admin.NewChannelHandler,
|
||||||
admin.NewChannelMonitorHandler,
|
admin.NewChannelMonitorHandler,
|
||||||
admin.NewChannelMonitorRequestTemplateHandler,
|
admin.NewChannelMonitorRequestTemplateHandler,
|
||||||
|
admin.NewContentModerationHandler,
|
||||||
admin.NewPaymentHandler,
|
admin.NewPaymentHandler,
|
||||||
admin.NewAffiliateHandler,
|
admin.NewAffiliateHandler,
|
||||||
|
|
||||||
|
|||||||
@ -125,6 +125,7 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se
|
|||||||
apikey.FieldID,
|
apikey.FieldID,
|
||||||
apikey.FieldUserID,
|
apikey.FieldUserID,
|
||||||
apikey.FieldGroupID,
|
apikey.FieldGroupID,
|
||||||
|
apikey.FieldName,
|
||||||
apikey.FieldStatus,
|
apikey.FieldStatus,
|
||||||
apikey.FieldIPWhitelist,
|
apikey.FieldIPWhitelist,
|
||||||
apikey.FieldIPBlacklist,
|
apikey.FieldIPBlacklist,
|
||||||
|
|||||||
@ -69,6 +69,7 @@ func TestAPIKeyRepository_GetByKeyForAuth_PreservesMessagesDispatchModelConfig_S
|
|||||||
|
|
||||||
got, err := repo.GetByKeyForAuth(ctx, key.Key)
|
got, err := repo.GetByKeyForAuth(ctx, key.Key)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, key.Name, got.Name)
|
||||||
require.NotNil(t, got.Group)
|
require.NotNil(t, got.Group)
|
||||||
require.Equal(t, group.MessagesDispatchModelConfig, got.Group.MessagesDispatchModelConfig)
|
require.Equal(t, group.MessagesDispatchModelConfig, got.Group.MessagesDispatchModelConfig)
|
||||||
}
|
}
|
||||||
|
|||||||
71
backend/internal/repository/content_moderation_hash_cache.go
Normal file
71
backend/internal/repository/content_moderation_hash_cache.go
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
const contentModerationFlaggedHashSetKey = "content_moderation:flagged_hashes"
|
||||||
|
|
||||||
|
type contentModerationHashCache struct {
|
||||||
|
rdb *redis.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewContentModerationHashCache(rdb *redis.Client) service.ContentModerationHashCache {
|
||||||
|
return &contentModerationHashCache{rdb: rdb}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *contentModerationHashCache) RecordFlaggedInputHash(ctx context.Context, inputHash string) error {
|
||||||
|
inputHash = strings.TrimSpace(inputHash)
|
||||||
|
if c == nil || c.rdb == nil || inputHash == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return c.rdb.SAdd(ctx, contentModerationFlaggedHashSetKey, inputHash).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *contentModerationHashCache) HasFlaggedInputHash(ctx context.Context, inputHash string) (bool, error) {
|
||||||
|
inputHash = strings.TrimSpace(inputHash)
|
||||||
|
if c == nil || c.rdb == nil || inputHash == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return c.rdb.SIsMember(ctx, contentModerationFlaggedHashSetKey, inputHash).Result()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *contentModerationHashCache) DeleteFlaggedInputHash(ctx context.Context, inputHash string) (bool, error) {
|
||||||
|
inputHash = strings.TrimSpace(inputHash)
|
||||||
|
if c == nil || c.rdb == nil || inputHash == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
deleted, err := c.rdb.SRem(ctx, contentModerationFlaggedHashSetKey, inputHash).Result()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return deleted > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *contentModerationHashCache) ClearFlaggedInputHashes(ctx context.Context) (int64, error) {
|
||||||
|
if c == nil || c.rdb == nil {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
deleted, err := c.rdb.SCard(ctx, contentModerationFlaggedHashSetKey).Result()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if deleted == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
if err := c.rdb.Del(ctx, contentModerationFlaggedHashSetKey).Err(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return deleted, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *contentModerationHashCache) CountFlaggedInputHashes(ctx context.Context) (int64, error) {
|
||||||
|
if c == nil || c.rdb == nil {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return c.rdb.SCard(ctx, contentModerationFlaggedHashSetKey).Result()
|
||||||
|
}
|
||||||
274
backend/internal/repository/content_moderation_repo.go
Normal file
274
backend/internal/repository/content_moderation_repo.go
Normal file
@ -0,0 +1,274 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type contentModerationRepository struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewContentModerationRepository(db *sql.DB) service.ContentModerationRepository {
|
||||||
|
return &contentModerationRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationRepository) CreateLog(ctx context.Context, log *service.ContentModerationLog) error {
|
||||||
|
if log == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
categoryScores, err := json.Marshal(log.CategoryScores)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal moderation category scores: %w", err)
|
||||||
|
}
|
||||||
|
thresholdSnapshot, err := json.Marshal(log.ThresholdSnapshot)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal moderation thresholds: %w", err)
|
||||||
|
}
|
||||||
|
var userID any
|
||||||
|
if log.UserID != nil {
|
||||||
|
userID = *log.UserID
|
||||||
|
}
|
||||||
|
var apiKeyID any
|
||||||
|
if log.APIKeyID != nil {
|
||||||
|
apiKeyID = *log.APIKeyID
|
||||||
|
}
|
||||||
|
var groupID any
|
||||||
|
if log.GroupID != nil {
|
||||||
|
groupID = *log.GroupID
|
||||||
|
}
|
||||||
|
var latency any
|
||||||
|
if log.UpstreamLatencyMS != nil {
|
||||||
|
latency = *log.UpstreamLatencyMS
|
||||||
|
}
|
||||||
|
err = r.db.QueryRowContext(ctx, `
|
||||||
|
INSERT INTO content_moderation_logs (
|
||||||
|
request_id, user_id, user_email, api_key_id, api_key_name, group_id, group_name,
|
||||||
|
endpoint, provider, model, mode, action, flagged, highest_category, highest_score,
|
||||||
|
category_scores, threshold_snapshot, input_excerpt, upstream_latency_ms, error,
|
||||||
|
violation_count, auto_banned, email_sent, queue_delay_ms
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5, $6, $7,
|
||||||
|
$8, $9, $10, $11, $12, $13, $14, $15,
|
||||||
|
$16::jsonb, $17::jsonb, $18, $19, $20,
|
||||||
|
$21, $22, $23, $24
|
||||||
|
) RETURNING id, created_at`,
|
||||||
|
log.RequestID, userID, log.UserEmail, apiKeyID, log.APIKeyName, groupID, log.GroupName,
|
||||||
|
log.Endpoint, log.Provider, log.Model, log.Mode, log.Action, log.Flagged, log.HighestCategory, log.HighestScore,
|
||||||
|
string(categoryScores), string(thresholdSnapshot), log.InputExcerpt, latency, log.Error,
|
||||||
|
log.ViolationCount, log.AutoBanned, log.EmailSent, nullableIntPtr(log.QueueDelayMS),
|
||||||
|
).Scan(&log.ID, &log.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("insert content moderation log: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationRepository) ListLogs(ctx context.Context, filter service.ContentModerationLogFilter) ([]service.ContentModerationLog, *pagination.PaginationResult, error) {
|
||||||
|
where, args := buildContentModerationLogWhere(filter)
|
||||||
|
whereSQL := "WHERE " + strings.Join(where, " AND ")
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err := r.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM content_moderation_logs l "+whereSQL, args...).Scan(&total); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("count content moderation logs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
params := filter.Pagination
|
||||||
|
if params.Page <= 0 {
|
||||||
|
params.Page = 1
|
||||||
|
}
|
||||||
|
if params.PageSize <= 0 {
|
||||||
|
params.PageSize = 20
|
||||||
|
}
|
||||||
|
if params.PageSize > 100 {
|
||||||
|
params.PageSize = 100
|
||||||
|
}
|
||||||
|
queryArgs := append([]any{}, args...)
|
||||||
|
queryArgs = append(queryArgs, params.Limit(), params.Offset())
|
||||||
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
|
SELECT
|
||||||
|
l.id, l.request_id, l.user_id, l.user_email, l.api_key_id, l.api_key_name, l.group_id, l.group_name,
|
||||||
|
l.endpoint, l.provider, l.model, l.mode, l.action, l.flagged, l.highest_category, l.highest_score,
|
||||||
|
l.category_scores, l.threshold_snapshot, l.input_excerpt, l.upstream_latency_ms, l.error,
|
||||||
|
l.violation_count, l.auto_banned, l.email_sent, COALESCE(u.status, ''), l.queue_delay_ms, l.created_at
|
||||||
|
FROM content_moderation_logs l
|
||||||
|
LEFT JOIN users u ON u.id = l.user_id `+whereSQL+`
|
||||||
|
ORDER BY l.created_at DESC, l.id DESC
|
||||||
|
LIMIT $`+fmt.Sprint(len(queryArgs)-1)+` OFFSET $`+fmt.Sprint(len(queryArgs)),
|
||||||
|
queryArgs...,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("list content moderation logs: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
items := make([]service.ContentModerationLog, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var item service.ContentModerationLog
|
||||||
|
var userID, apiKeyID, groupID, latency, queueDelay sql.NullInt64
|
||||||
|
var scoresRaw, thresholdsRaw []byte
|
||||||
|
if err := rows.Scan(
|
||||||
|
&item.ID,
|
||||||
|
&item.RequestID,
|
||||||
|
&userID,
|
||||||
|
&item.UserEmail,
|
||||||
|
&apiKeyID,
|
||||||
|
&item.APIKeyName,
|
||||||
|
&groupID,
|
||||||
|
&item.GroupName,
|
||||||
|
&item.Endpoint,
|
||||||
|
&item.Provider,
|
||||||
|
&item.Model,
|
||||||
|
&item.Mode,
|
||||||
|
&item.Action,
|
||||||
|
&item.Flagged,
|
||||||
|
&item.HighestCategory,
|
||||||
|
&item.HighestScore,
|
||||||
|
&scoresRaw,
|
||||||
|
&thresholdsRaw,
|
||||||
|
&item.InputExcerpt,
|
||||||
|
&latency,
|
||||||
|
&item.Error,
|
||||||
|
&item.ViolationCount,
|
||||||
|
&item.AutoBanned,
|
||||||
|
&item.EmailSent,
|
||||||
|
&item.UserStatus,
|
||||||
|
&queueDelay,
|
||||||
|
&item.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("scan content moderation log: %w", err)
|
||||||
|
}
|
||||||
|
if userID.Valid {
|
||||||
|
v := userID.Int64
|
||||||
|
item.UserID = &v
|
||||||
|
}
|
||||||
|
if apiKeyID.Valid {
|
||||||
|
v := apiKeyID.Int64
|
||||||
|
item.APIKeyID = &v
|
||||||
|
}
|
||||||
|
if groupID.Valid {
|
||||||
|
v := groupID.Int64
|
||||||
|
item.GroupID = &v
|
||||||
|
}
|
||||||
|
if latency.Valid {
|
||||||
|
v := int(latency.Int64)
|
||||||
|
item.UpstreamLatencyMS = &v
|
||||||
|
}
|
||||||
|
if queueDelay.Valid {
|
||||||
|
v := int(queueDelay.Int64)
|
||||||
|
item.QueueDelayMS = &v
|
||||||
|
}
|
||||||
|
item.CategoryScores = map[string]float64{}
|
||||||
|
_ = json.Unmarshal(scoresRaw, &item.CategoryScores)
|
||||||
|
item.ThresholdSnapshot = map[string]float64{}
|
||||||
|
_ = json.Unmarshal(thresholdsRaw, &item.ThresholdSnapshot)
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("iterate content moderation logs: %w", err)
|
||||||
|
}
|
||||||
|
return items, paginationResultFromTotal(total, params), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationRepository) CountFlaggedByUserSince(ctx context.Context, userID int64, since time.Time) (int, error) {
|
||||||
|
if userID <= 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
var count int
|
||||||
|
err := r.db.QueryRowContext(ctx, `
|
||||||
|
WITH last_auto_ban AS (
|
||||||
|
SELECT MAX(created_at) AS at
|
||||||
|
FROM content_moderation_logs
|
||||||
|
WHERE user_id = $1 AND auto_banned = TRUE
|
||||||
|
)
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM content_moderation_logs
|
||||||
|
WHERE user_id = $1
|
||||||
|
AND flagged = TRUE
|
||||||
|
AND created_at >= $2
|
||||||
|
AND created_at > COALESCE((SELECT at FROM last_auto_ban), '-infinity'::timestamptz)
|
||||||
|
`, userID, since).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("count user content moderation flagged logs: %w", err)
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationRepository) CleanupExpiredLogs(ctx context.Context, hitBefore time.Time, nonHitBefore time.Time) (*service.ContentModerationCleanupResult, error) {
|
||||||
|
result := &service.ContentModerationCleanupResult{FinishedAt: time.Now()}
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
hitExec, err := r.db.ExecContext(ctx, `
|
||||||
|
DELETE FROM content_moderation_logs
|
||||||
|
WHERE flagged = TRUE AND created_at < $1
|
||||||
|
`, hitBefore)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("delete expired hit content moderation logs: %w", err)
|
||||||
|
}
|
||||||
|
result.DeletedHit, _ = hitExec.RowsAffected()
|
||||||
|
|
||||||
|
nonHitExec, err := r.db.ExecContext(ctx, `
|
||||||
|
DELETE FROM content_moderation_logs
|
||||||
|
WHERE flagged = FALSE AND created_at < $1
|
||||||
|
`, nonHitBefore)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("delete expired non-hit content moderation logs: %w", err)
|
||||||
|
}
|
||||||
|
result.DeletedNonHit, _ = nonHitExec.RowsAffected()
|
||||||
|
|
||||||
|
result.FinishedAt = time.Now()
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullableIntPtr(value *int) any {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return *value
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildContentModerationLogWhere(filter service.ContentModerationLogFilter) ([]string, []any) {
|
||||||
|
where := []string{"l.id IS NOT NULL"}
|
||||||
|
args := make([]any, 0)
|
||||||
|
add := func(expr string, value any) {
|
||||||
|
args = append(args, value)
|
||||||
|
where = append(where, fmt.Sprintf(expr, len(args)))
|
||||||
|
}
|
||||||
|
switch strings.ToLower(strings.TrimSpace(filter.Result)) {
|
||||||
|
case "hit", "flagged":
|
||||||
|
where = append(where, "l.flagged = TRUE")
|
||||||
|
case "blocked", "block":
|
||||||
|
where = append(where, "l.action = 'block'")
|
||||||
|
case "pass", "allow":
|
||||||
|
where = append(where, "l.flagged = FALSE AND l.error = ''")
|
||||||
|
case "error":
|
||||||
|
where = append(where, "l.error <> ''")
|
||||||
|
}
|
||||||
|
if filter.GroupID != nil {
|
||||||
|
add("l.group_id = $%d", *filter.GroupID)
|
||||||
|
}
|
||||||
|
if endpoint := strings.TrimSpace(filter.Endpoint); endpoint != "" {
|
||||||
|
add("l.endpoint = $%d", endpoint)
|
||||||
|
}
|
||||||
|
if search := strings.TrimSpace(filter.Search); search != "" {
|
||||||
|
like := "%" + search + "%"
|
||||||
|
args = append(args, like, like, like, like, like)
|
||||||
|
idx := len(args) - 4
|
||||||
|
where = append(where, fmt.Sprintf("(l.request_id ILIKE $%d OR l.user_email ILIKE $%d OR l.api_key_name ILIKE $%d OR l.model ILIKE $%d OR l.input_excerpt ILIKE $%d)", idx, idx+1, idx+2, idx+3, idx+4))
|
||||||
|
}
|
||||||
|
if filter.From != nil && !filter.From.IsZero() {
|
||||||
|
add("l.created_at >= $%d", *filter.From)
|
||||||
|
}
|
||||||
|
if filter.To != nil && !filter.To.IsZero() {
|
||||||
|
add("l.created_at <= $%d", *filter.To)
|
||||||
|
}
|
||||||
|
return where, args
|
||||||
|
}
|
||||||
@ -91,6 +91,7 @@ var ProviderSet = wire.NewSet(
|
|||||||
NewChannelRepository,
|
NewChannelRepository,
|
||||||
NewChannelMonitorRepository,
|
NewChannelMonitorRepository,
|
||||||
NewChannelMonitorRequestTemplateRepository,
|
NewChannelMonitorRequestTemplateRepository,
|
||||||
|
NewContentModerationRepository,
|
||||||
NewAffiliateRepository,
|
NewAffiliateRepository,
|
||||||
|
|
||||||
// Cache implementations
|
// Cache implementations
|
||||||
@ -119,6 +120,7 @@ var ProviderSet = wire.NewSet(
|
|||||||
NewRefreshTokenCache,
|
NewRefreshTokenCache,
|
||||||
NewErrorPassthroughCache,
|
NewErrorPassthroughCache,
|
||||||
NewTLSFingerprintProfileCache,
|
NewTLSFingerprintProfileCache,
|
||||||
|
NewContentModerationHashCache,
|
||||||
|
|
||||||
// Encryptors
|
// Encryptors
|
||||||
NewAESEncryptor,
|
NewAESEncryptor,
|
||||||
|
|||||||
@ -792,6 +792,7 @@ func TestAPIContracts(t *testing.T) {
|
|||||||
"channel_monitor_enabled": true,
|
"channel_monitor_enabled": true,
|
||||||
"channel_monitor_default_interval_seconds": 60,
|
"channel_monitor_default_interval_seconds": 60,
|
||||||
"available_channels_enabled": false,
|
"available_channels_enabled": false,
|
||||||
|
"risk_control_enabled": false,
|
||||||
"affiliate_enabled": false,
|
"affiliate_enabled": false,
|
||||||
"wechat_connect_enabled": false,
|
"wechat_connect_enabled": false,
|
||||||
"wechat_connect_app_id": "",
|
"wechat_connect_app_id": "",
|
||||||
@ -983,6 +984,7 @@ func TestAPIContracts(t *testing.T) {
|
|||||||
"channel_monitor_enabled": true,
|
"channel_monitor_enabled": true,
|
||||||
"channel_monitor_default_interval_seconds": 60,
|
"channel_monitor_default_interval_seconds": 60,
|
||||||
"available_channels_enabled": false,
|
"available_channels_enabled": false,
|
||||||
|
"risk_control_enabled": false,
|
||||||
"affiliate_enabled": false,
|
"affiliate_enabled": false,
|
||||||
"wechat_connect_enabled": true,
|
"wechat_connect_enabled": true,
|
||||||
"wechat_connect_app_id": "wx-open-config",
|
"wechat_connect_app_id": "wx-open-config",
|
||||||
|
|||||||
@ -92,11 +92,28 @@ func RegisterAdminRoutes(
|
|||||||
// 渠道监控
|
// 渠道监控
|
||||||
registerChannelMonitorRoutes(admin, h)
|
registerChannelMonitorRoutes(admin, h)
|
||||||
|
|
||||||
|
// 风控中心
|
||||||
|
registerContentModerationRoutes(admin, h)
|
||||||
|
|
||||||
// 邀请返利(专属用户管理)
|
// 邀请返利(专属用户管理)
|
||||||
registerAffiliateRoutes(admin, h)
|
registerAffiliateRoutes(admin, h)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func registerContentModerationRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||||
|
risk := admin.Group("/risk-control")
|
||||||
|
{
|
||||||
|
risk.GET("/config", h.Admin.ContentModeration.GetConfig)
|
||||||
|
risk.PUT("/config", h.Admin.ContentModeration.UpdateConfig)
|
||||||
|
risk.POST("/api-keys/test", h.Admin.ContentModeration.TestAPIKeys)
|
||||||
|
risk.GET("/status", h.Admin.ContentModeration.GetStatus)
|
||||||
|
risk.GET("/logs", h.Admin.ContentModeration.ListLogs)
|
||||||
|
risk.POST("/users/:user_id/unban", h.Admin.ContentModeration.UnbanUser)
|
||||||
|
risk.DELETE("/hashes", h.Admin.ContentModeration.DeleteFlaggedHash)
|
||||||
|
risk.DELETE("/hashes/all", h.Admin.ContentModeration.ClearFlaggedHashes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func registerAdminAPIKeyRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
func registerAdminAPIKeyRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||||
apiKeys := admin.Group("/api-keys")
|
apiKeys := admin.Group("/api-keys")
|
||||||
{
|
{
|
||||||
|
|||||||
@ -8,6 +8,7 @@ type APIKeyAuthSnapshot struct {
|
|||||||
APIKeyID int64 `json:"api_key_id"`
|
APIKeyID int64 `json:"api_key_id"`
|
||||||
UserID int64 `json:"user_id"`
|
UserID int64 `json:"user_id"`
|
||||||
GroupID *int64 `json:"group_id,omitempty"`
|
GroupID *int64 `json:"group_id,omitempty"`
|
||||||
|
Name string `json:"name"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
IPWhitelist []string `json:"ip_whitelist,omitempty"`
|
IPWhitelist []string `json:"ip_whitelist,omitempty"`
|
||||||
IPBlacklist []string `json:"ip_blacklist,omitempty"`
|
IPBlacklist []string `json:"ip_blacklist,omitempty"`
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import (
|
|||||||
"github.com/dgraph-io/ristretto"
|
"github.com/dgraph-io/ristretto"
|
||||||
)
|
)
|
||||||
|
|
||||||
const apiKeyAuthSnapshotVersion = 8 // v8: added group image generation controls
|
const apiKeyAuthSnapshotVersion = 9 // v9: added API Key name for audit logs
|
||||||
|
|
||||||
type apiKeyAuthCacheConfig struct {
|
type apiKeyAuthCacheConfig struct {
|
||||||
l1Size int
|
l1Size int
|
||||||
@ -210,6 +210,7 @@ func (s *APIKeyService) snapshotFromAPIKey(ctx context.Context, apiKey *APIKey)
|
|||||||
APIKeyID: apiKey.ID,
|
APIKeyID: apiKey.ID,
|
||||||
UserID: apiKey.UserID,
|
UserID: apiKey.UserID,
|
||||||
GroupID: apiKey.GroupID,
|
GroupID: apiKey.GroupID,
|
||||||
|
Name: apiKey.Name,
|
||||||
Status: apiKey.Status,
|
Status: apiKey.Status,
|
||||||
IPWhitelist: apiKey.IPWhitelist,
|
IPWhitelist: apiKey.IPWhitelist,
|
||||||
IPBlacklist: apiKey.IPBlacklist,
|
IPBlacklist: apiKey.IPBlacklist,
|
||||||
@ -286,6 +287,7 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho
|
|||||||
UserID: snapshot.UserID,
|
UserID: snapshot.UserID,
|
||||||
GroupID: snapshot.GroupID,
|
GroupID: snapshot.GroupID,
|
||||||
Key: key,
|
Key: key,
|
||||||
|
Name: snapshot.Name,
|
||||||
Status: snapshot.Status,
|
Status: snapshot.Status,
|
||||||
IPWhitelist: snapshot.IPWhitelist,
|
IPWhitelist: snapshot.IPWhitelist,
|
||||||
IPBlacklist: snapshot.IPBlacklist,
|
IPBlacklist: snapshot.IPBlacklist,
|
||||||
|
|||||||
@ -235,6 +235,7 @@ func TestAPIKeyService_SnapshotRoundTrip_PreservesMessagesDispatchModelConfig(t
|
|||||||
UserID: 2,
|
UserID: 2,
|
||||||
GroupID: &groupID,
|
GroupID: &groupID,
|
||||||
Key: "k-roundtrip",
|
Key: "k-roundtrip",
|
||||||
|
Name: "Audit Key",
|
||||||
Status: StatusActive,
|
Status: StatusActive,
|
||||||
User: &User{
|
User: &User{
|
||||||
ID: 2,
|
ID: 2,
|
||||||
@ -267,6 +268,7 @@ func TestAPIKeyService_SnapshotRoundTrip_PreservesMessagesDispatchModelConfig(t
|
|||||||
roundTrip := svc.snapshotToAPIKey(apiKey.Key, snapshot)
|
roundTrip := svc.snapshotToAPIKey(apiKey.Key, snapshot)
|
||||||
|
|
||||||
require.NotNil(t, roundTrip)
|
require.NotNil(t, roundTrip)
|
||||||
|
require.Equal(t, apiKey.Name, roundTrip.Name)
|
||||||
require.NotNil(t, roundTrip.Group)
|
require.NotNil(t, roundTrip.Group)
|
||||||
require.Equal(t, apiKey.Group.MessagesDispatchModelConfig, roundTrip.Group.MessagesDispatchModelConfig)
|
require.Equal(t, apiKey.Group.MessagesDispatchModelConfig, roundTrip.Group.MessagesDispatchModelConfig)
|
||||||
}
|
}
|
||||||
|
|||||||
1982
backend/internal/service/content_moderation.go
Normal file
1982
backend/internal/service/content_moderation.go
Normal file
File diff suppressed because it is too large
Load Diff
117
backend/internal/service/content_moderation_email.go
Normal file
117
backend/internal/service/content_moderation_email.go
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func buildContentModerationViolationEmailBody(siteName string, log *ContentModerationLog, cfg *ContentModerationConfig) string {
|
||||||
|
if log == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
userName := strings.TrimSpace(log.UserEmail)
|
||||||
|
if userName == "" && log.UserID != nil {
|
||||||
|
userName = fmt.Sprintf("UID %d", *log.UserID)
|
||||||
|
}
|
||||||
|
threshold := cfg.BanThreshold
|
||||||
|
if threshold <= 0 {
|
||||||
|
threshold = defaultContentModerationBanThreshold
|
||||||
|
}
|
||||||
|
statusBlock := ""
|
||||||
|
if log.AutoBanned {
|
||||||
|
statusBlock = `<div style="margin-top:24px;padding:18px 20px;border-radius:10px;background:#ff3b30;color:#fff;font-size:18px;font-weight:700;text-align:center;line-height:1.6;">账户当前处于封禁状态,所有 API 请求将被拒绝</div>`
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(`<!doctype html>
|
||||||
|
<html>
|
||||||
|
<body style="margin:0;padding:0;background:#f5f6fb;color:#222;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;">
|
||||||
|
<div style="max-width:680px;margin:0 auto;padding:32px 20px;">
|
||||||
|
<div style="height:8px;background:#ef4444;border-radius:14px 14px 0 0;"></div>
|
||||||
|
<div style="background:#fff;border-radius:0 0 14px 14px;padding:40px 48px;box-shadow:0 8px 28px rgba(15,23,42,.08);">
|
||||||
|
<div style="letter-spacing:4px;color:#999;font-size:14px;text-transform:uppercase;">Risk Control / 风控提醒</div>
|
||||||
|
<h1 style="margin:20px 0 28px;font-size:30px;line-height:1.25;">账户触发内容审计规则</h1>
|
||||||
|
<p style="font-size:17px;line-height:1.9;margin:0 0 24px;">尊敬的用户 <strong>%s</strong>,您的 API 请求在内容审计中触发平台风控策略。详情如下。</p>
|
||||||
|
<div style="background:#fff1f2;border:1px solid #fecdd3;border-radius:12px;padding:22px 28px;margin:28px 0;">
|
||||||
|
<h2 style="margin:0 0 18px;color:#b91c1c;font-size:18px;">触发详情</h2>
|
||||||
|
<table style="width:100%%;border-collapse:collapse;font-size:16px;">
|
||||||
|
<tr><td style="padding:12px 0;color:#888;border-bottom:1px solid #fee2e2;">触发时间</td><td style="padding:12px 0;border-bottom:1px solid #fee2e2;">%s</td></tr>
|
||||||
|
<tr><td style="padding:12px 0;color:#888;border-bottom:1px solid #fee2e2;">触发来源</td><td style="padding:12px 0;border-bottom:1px solid #fee2e2;">内容审核</td></tr>
|
||||||
|
<tr><td style="padding:12px 0;color:#888;border-bottom:1px solid #fee2e2;">所属分组</td><td style="padding:12px 0;border-bottom:1px solid #fee2e2;">%s</td></tr>
|
||||||
|
<tr><td style="padding:12px 0;color:#888;border-bottom:1px solid #fee2e2;">命中类别</td><td style="padding:12px 0;border-bottom:1px solid #fee2e2;">%s / %.3f</td></tr>
|
||||||
|
<tr><td style="padding:12px 0;color:#888;">累计触发次数</td><td style="padding:12px 0;color:#dc2626;font-weight:700;">%d 次(阈值 %d)</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
%s
|
||||||
|
<p style="font-size:14px;line-height:1.8;color:#777;margin-top:28px;">此邮件由 %s 自动发送,请勿回复。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`,
|
||||||
|
html.EscapeString(userName),
|
||||||
|
html.EscapeString(time.Now().Format("2006-01-02 15:04:05")),
|
||||||
|
html.EscapeString(defaultContentModerationString(log.GroupName, "-")),
|
||||||
|
html.EscapeString(defaultContentModerationString(log.HighestCategory, "-")),
|
||||||
|
log.HighestScore,
|
||||||
|
log.ViolationCount,
|
||||||
|
threshold,
|
||||||
|
statusBlock,
|
||||||
|
html.EscapeString(siteName),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildContentModerationAccountDisabledEmailBody(siteName string, log *ContentModerationLog, cfg *ContentModerationConfig) string {
|
||||||
|
if log == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
userName := strings.TrimSpace(log.UserEmail)
|
||||||
|
if userName == "" && log.UserID != nil {
|
||||||
|
userName = fmt.Sprintf("UID %d", *log.UserID)
|
||||||
|
}
|
||||||
|
threshold := cfg.BanThreshold
|
||||||
|
if threshold <= 0 {
|
||||||
|
threshold = defaultContentModerationBanThreshold
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(`<!doctype html>
|
||||||
|
<html>
|
||||||
|
<body style="margin:0;padding:0;background:#f5f6fb;color:#222;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;">
|
||||||
|
<div style="max-width:680px;margin:0 auto;padding:32px 20px;">
|
||||||
|
<div style="height:8px;background:#ef4444;border-radius:14px 14px 0 0;"></div>
|
||||||
|
<div style="background:#fff;border-radius:0 0 14px 14px;padding:40px 48px;box-shadow:0 8px 28px rgba(15,23,42,.08);">
|
||||||
|
<div style="letter-spacing:4px;color:#999;font-size:14px;text-transform:uppercase;">Risk Control / 账户封禁</div>
|
||||||
|
<h1 style="margin:20px 0 28px;font-size:30px;line-height:1.25;">账户已被自动禁用</h1>
|
||||||
|
<p style="font-size:17px;line-height:1.9;margin:0 0 24px;">尊敬的用户 <strong>%s</strong>,您的账户在计数周期内多次触发平台风控策略,系统已自动禁用该账户。详情如下。</p>
|
||||||
|
<div style="background:#fff1f2;border:1px solid #fecdd3;border-radius:12px;padding:22px 28px;margin:28px 0;">
|
||||||
|
<h2 style="margin:0 0 18px;color:#b91c1c;font-size:18px;">封禁详情</h2>
|
||||||
|
<table style="width:100%%;border-collapse:collapse;font-size:16px;">
|
||||||
|
<tr><td style="padding:12px 0;color:#888;border-bottom:1px solid #fee2e2;">封禁时间</td><td style="padding:12px 0;border-bottom:1px solid #fee2e2;">%s</td></tr>
|
||||||
|
<tr><td style="padding:12px 0;color:#888;border-bottom:1px solid #fee2e2;">触发来源</td><td style="padding:12px 0;border-bottom:1px solid #fee2e2;">内容审核</td></tr>
|
||||||
|
<tr><td style="padding:12px 0;color:#888;border-bottom:1px solid #fee2e2;">所属分组</td><td style="padding:12px 0;border-bottom:1px solid #fee2e2;">%s</td></tr>
|
||||||
|
<tr><td style="padding:12px 0;color:#888;border-bottom:1px solid #fee2e2;">命中类别</td><td style="padding:12px 0;border-bottom:1px solid #fee2e2;">%s / %.3f</td></tr>
|
||||||
|
<tr><td style="padding:12px 0;color:#888;">累计触发次数</td><td style="padding:12px 0;color:#dc2626;font-weight:700;">%d 次(阈值 %d)</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:24px;padding:18px 20px;border-radius:10px;background:#ff3b30;color:#fff;font-size:18px;font-weight:700;text-align:center;line-height:1.6;">账户当前处于封禁状态,所有 API 请求将被拒绝</div>
|
||||||
|
<p style="font-size:15px;line-height:1.8;color:#666;margin-top:24px;">如需申诉或恢复账号,请联系平台管理员处理。</p>
|
||||||
|
<p style="font-size:14px;line-height:1.8;color:#777;margin-top:28px;">此邮件由 %s 自动发送,请勿回复。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`,
|
||||||
|
html.EscapeString(userName),
|
||||||
|
html.EscapeString(time.Now().Format("2006-01-02 15:04:05")),
|
||||||
|
html.EscapeString(defaultContentModerationString(log.GroupName, "-")),
|
||||||
|
html.EscapeString(defaultContentModerationString(log.HighestCategory, "-")),
|
||||||
|
log.HighestScore,
|
||||||
|
log.ViolationCount,
|
||||||
|
threshold,
|
||||||
|
html.EscapeString(siteName),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultContentModerationString(value string, fallback string) string {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(value)
|
||||||
|
}
|
||||||
307
backend/internal/service/content_moderation_input.go
Normal file
307
backend/internal/service/content_moderation_input.go
Normal file
@ -0,0 +1,307 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ExtractContentModerationText(protocol string, body []byte) string {
|
||||||
|
return ExtractContentModerationInput(protocol, body).Text
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExtractContentModerationInput(protocol string, body []byte) ContentModerationInput {
|
||||||
|
if len(body) == 0 || !gjson.ValidBytes(body) {
|
||||||
|
return ContentModerationInput{}
|
||||||
|
}
|
||||||
|
var parts []string
|
||||||
|
var images []string
|
||||||
|
switch protocol {
|
||||||
|
case ContentModerationProtocolAnthropicMessages:
|
||||||
|
collectLastAnthropicUserMessage(gjson.GetBytes(body, "messages"), &parts, &images)
|
||||||
|
case ContentModerationProtocolOpenAIChat:
|
||||||
|
collectLastRoleMessage(gjson.GetBytes(body, "messages"), "user", &parts, &images)
|
||||||
|
case ContentModerationProtocolOpenAIResponses:
|
||||||
|
collectLastResponsesInput(gjson.GetBytes(body, "input"), &parts, &images)
|
||||||
|
case ContentModerationProtocolGemini:
|
||||||
|
collectLastGeminiContent(gjson.GetBytes(body, "contents"), &parts, &images)
|
||||||
|
case ContentModerationProtocolOpenAIImages:
|
||||||
|
addModerationText(&parts, gjson.GetBytes(body, "prompt").String())
|
||||||
|
collectContentValue(gjson.GetBytes(body, "images"), &parts, &images)
|
||||||
|
default:
|
||||||
|
collectLastResponsesInput(gjson.GetBytes(body, "input"), &parts, &images)
|
||||||
|
collectLastRoleMessage(gjson.GetBytes(body, "messages"), "user", &parts, &images)
|
||||||
|
collectLastGeminiContent(gjson.GetBytes(body, "contents"), &parts, &images)
|
||||||
|
}
|
||||||
|
out := ContentModerationInput{
|
||||||
|
Text: normalizeContentModerationText(strings.Join(parts, "\n")),
|
||||||
|
Images: normalizeModerationImages(images),
|
||||||
|
}
|
||||||
|
out.Normalize()
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectLastRoleMessage(messages gjson.Result, role string, parts *[]string, images *[]string) {
|
||||||
|
if !messages.IsArray() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var lastParts []string
|
||||||
|
var lastImages []string
|
||||||
|
messages.ForEach(func(_, msg gjson.Result) bool {
|
||||||
|
if strings.ToLower(strings.TrimSpace(msg.Get("role").String())) == role {
|
||||||
|
var candidate []string
|
||||||
|
var candidateImages []string
|
||||||
|
collectContentValue(msg.Get("content"), &candidate, &candidateImages)
|
||||||
|
if normalizeContentModerationText(strings.Join(candidate, "\n")) != "" || len(candidateImages) > 0 {
|
||||||
|
lastParts = candidate
|
||||||
|
lastImages = candidateImages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
*parts = append(*parts, lastParts...)
|
||||||
|
*images = append(*images, lastImages...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectLastAnthropicUserMessage(messages gjson.Result, parts *[]string, images *[]string) {
|
||||||
|
if !messages.IsArray() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var lastParts []string
|
||||||
|
var lastImages []string
|
||||||
|
messages.ForEach(func(_, msg gjson.Result) bool {
|
||||||
|
if strings.ToLower(strings.TrimSpace(msg.Get("role").String())) == "user" {
|
||||||
|
var candidate []string
|
||||||
|
var candidateImages []string
|
||||||
|
collectAnthropicUserContentValue(msg.Get("content"), &candidate, &candidateImages)
|
||||||
|
if normalizeContentModerationText(strings.Join(candidate, "\n")) != "" || len(candidateImages) > 0 {
|
||||||
|
lastParts = candidate
|
||||||
|
lastImages = candidateImages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
*parts = append(*parts, lastParts...)
|
||||||
|
*images = append(*images, lastImages...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectAnthropicUserContentValue(value gjson.Result, parts *[]string, images *[]string) {
|
||||||
|
switch {
|
||||||
|
case !value.Exists():
|
||||||
|
return
|
||||||
|
case value.Type == gjson.String:
|
||||||
|
if !isAnthropicSystemReminderText(value.String()) {
|
||||||
|
addModerationText(parts, value.String())
|
||||||
|
}
|
||||||
|
case value.IsArray():
|
||||||
|
value.ForEach(func(_, item gjson.Result) bool {
|
||||||
|
collectAnthropicUserContentValue(item, parts, images)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
case value.IsObject():
|
||||||
|
typ := strings.ToLower(strings.TrimSpace(value.Get("type").String()))
|
||||||
|
switch typ {
|
||||||
|
case "", "text", "input_text", "message":
|
||||||
|
if value.Get("text").Exists() && !isAnthropicSystemReminderText(value.Get("text").String()) {
|
||||||
|
addModerationText(parts, value.Get("text").String())
|
||||||
|
}
|
||||||
|
if value.Get("content").Exists() {
|
||||||
|
collectAnthropicUserContentValue(value.Get("content"), parts, images)
|
||||||
|
}
|
||||||
|
case "image_url", "input_image", "image":
|
||||||
|
collectContentValue(value, parts, images)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAnthropicSystemReminderText(text string) bool {
|
||||||
|
return strings.HasPrefix(strings.TrimSpace(text), "<system-reminder>")
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectLastResponsesInput(input gjson.Result, parts *[]string, images *[]string) {
|
||||||
|
switch {
|
||||||
|
case !input.Exists():
|
||||||
|
return
|
||||||
|
case input.Type == gjson.String:
|
||||||
|
addModerationText(parts, input.String())
|
||||||
|
case input.IsArray():
|
||||||
|
var last gjson.Result
|
||||||
|
input.ForEach(func(_, item gjson.Result) bool {
|
||||||
|
if isResponsesUserTextItem(item) {
|
||||||
|
last = item
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if last.Exists() {
|
||||||
|
collectContentValue(last.Get("content"), parts, images)
|
||||||
|
if last.Get("type").String() == "input_text" || last.Get("text").Exists() {
|
||||||
|
collectContentValue(last, parts, images)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case input.IsObject():
|
||||||
|
if isResponsesUserTextItem(input) {
|
||||||
|
collectContentValue(input.Get("content"), parts, images)
|
||||||
|
if input.Get("type").String() == "input_text" || input.Get("text").Exists() {
|
||||||
|
collectContentValue(input, parts, images)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isResponsesUserTextItem(item gjson.Result) bool {
|
||||||
|
role := strings.ToLower(strings.TrimSpace(item.Get("role").String()))
|
||||||
|
if role == "user" {
|
||||||
|
return responseItemHasModerationText(item)
|
||||||
|
}
|
||||||
|
if role != "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return responseItemHasModerationText(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func responseItemHasModerationText(item gjson.Result) bool {
|
||||||
|
var parts []string
|
||||||
|
var images []string
|
||||||
|
collectContentValue(item.Get("content"), &parts, &images)
|
||||||
|
if item.Get("type").String() == "input_text" || item.Get("text").Exists() {
|
||||||
|
collectContentValue(item, &parts, &images)
|
||||||
|
}
|
||||||
|
return normalizeContentModerationText(strings.Join(parts, "\n")) != "" || len(images) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectLastGeminiContent(contents gjson.Result, parts *[]string, images *[]string) {
|
||||||
|
if !contents.IsArray() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var lastParts []string
|
||||||
|
var lastImages []string
|
||||||
|
contents.ForEach(func(_, content gjson.Result) bool {
|
||||||
|
role := strings.ToLower(strings.TrimSpace(content.Get("role").String()))
|
||||||
|
if role == "" || role == "user" {
|
||||||
|
var candidate []string
|
||||||
|
var candidateImages []string
|
||||||
|
if arr := content.Get("parts"); arr.IsArray() {
|
||||||
|
arr.ForEach(func(_, part gjson.Result) bool {
|
||||||
|
addModerationText(&candidate, part.Get("text").String())
|
||||||
|
addGeminiModerationImage(&candidateImages, part)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if normalizeContentModerationText(strings.Join(candidate, "\n")) != "" || len(candidateImages) > 0 {
|
||||||
|
lastParts = candidate
|
||||||
|
lastImages = candidateImages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
*parts = append(*parts, lastParts...)
|
||||||
|
*images = append(*images, lastImages...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectContentValue(value gjson.Result, parts *[]string, images *[]string) {
|
||||||
|
switch {
|
||||||
|
case !value.Exists():
|
||||||
|
return
|
||||||
|
case value.Type == gjson.String:
|
||||||
|
addModerationText(parts, value.String())
|
||||||
|
case value.IsArray():
|
||||||
|
value.ForEach(func(_, item gjson.Result) bool {
|
||||||
|
collectContentValue(item, parts, images)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
case value.IsObject():
|
||||||
|
typ := strings.ToLower(strings.TrimSpace(value.Get("type").String()))
|
||||||
|
addModerationImage(images, value.Get("image_url.url").String())
|
||||||
|
addModerationImage(images, value.Get("image_url").String())
|
||||||
|
addModerationImage(images, value.Get("url").String())
|
||||||
|
addModerationImageData(images, value.Get("source.media_type").String(), value.Get("source.data").String())
|
||||||
|
addModerationImageData(images, value.Get("source.mediaType").String(), value.Get("source.data").String())
|
||||||
|
addModerationImageData(images, value.Get("media_type").String(), value.Get("data").String())
|
||||||
|
addModerationImageData(images, value.Get("mime_type").String(), value.Get("data").String())
|
||||||
|
addModerationImageData(images, value.Get("mimeType").String(), value.Get("data").String())
|
||||||
|
addModerationImage(images, value.Get("source.data").String())
|
||||||
|
addModerationImage(images, value.Get("data").String())
|
||||||
|
addModerationImage(images, value.Get("base64").String())
|
||||||
|
switch typ {
|
||||||
|
case "", "text", "input_text", "message":
|
||||||
|
if value.Get("text").Exists() {
|
||||||
|
addModerationText(parts, value.Get("text").String())
|
||||||
|
}
|
||||||
|
if value.Get("content").Exists() {
|
||||||
|
collectContentValue(value.Get("content"), parts, images)
|
||||||
|
}
|
||||||
|
case "image_url", "input_image", "image":
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func addGeminiModerationImage(images *[]string, part gjson.Result) {
|
||||||
|
if inlineData := part.Get("inline_data"); inlineData.IsObject() {
|
||||||
|
mimeType := strings.TrimSpace(inlineData.Get("mime_type").String())
|
||||||
|
data := strings.TrimSpace(inlineData.Get("data").String())
|
||||||
|
if mimeType != "" && data != "" {
|
||||||
|
addModerationImage(images, fmt.Sprintf("data:%s;base64,%s", mimeType, data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if inlineData := part.Get("inlineData"); inlineData.IsObject() {
|
||||||
|
mimeType := strings.TrimSpace(inlineData.Get("mimeType").String())
|
||||||
|
data := strings.TrimSpace(inlineData.Get("data").String())
|
||||||
|
if mimeType != "" && data != "" {
|
||||||
|
addModerationImage(images, fmt.Sprintf("data:%s;base64,%s", mimeType, data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addModerationImage(images, part.Get("file_data.file_uri").String())
|
||||||
|
addModerationImage(images, part.Get("fileData.fileUri").String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func addModerationImageData(images *[]string, mimeType string, data string) {
|
||||||
|
mimeType = strings.TrimSpace(mimeType)
|
||||||
|
data = strings.TrimSpace(data)
|
||||||
|
if mimeType == "" || data == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
addModerationImage(images, fmt.Sprintf("data:%s;base64,%s", mimeType, data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func addModerationImage(images *[]string, image string) {
|
||||||
|
image = strings.TrimSpace(image)
|
||||||
|
if image == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(image, "data:") || strings.HasPrefix(image, "http://") || strings.HasPrefix(image, "https://") {
|
||||||
|
*images = append(*images, image)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeModerationImages(images []string) []string {
|
||||||
|
out := make([]string, 0, len(images))
|
||||||
|
seen := make(map[string]struct{}, len(images))
|
||||||
|
for _, image := range images {
|
||||||
|
image = strings.TrimSpace(image)
|
||||||
|
if image == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[image]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[image] = struct{}{}
|
||||||
|
out = append(out, image)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func addModerationText(parts *[]string, text string) {
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
if text == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.Contains(text, "<system-reminder>") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
*parts = append(*parts, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeContentModerationText(text string) string {
|
||||||
|
return strings.Join(strings.Fields(strings.TrimSpace(text)), " ")
|
||||||
|
}
|
||||||
36
backend/internal/service/content_moderation_redact.go
Normal file
36
backend/internal/service/content_moderation_redact.go
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var contentModerationSecretPatterns = []*regexp.Regexp{
|
||||||
|
regexp.MustCompile(`(?i)\b((?:api[_-]?key|apikey|access[_-]?token|refresh[_-]?token|id[_-]?token|session[_-]?token|token|session|cookie|set[_-]?cookie|authorization|bearer|password|passwd|pwd|secret|client[_-]?secret|private[_-]?key)\s*[:=]\s*)(["']?)[^"'\s,;,。;、]{6,}`),
|
||||||
|
regexp.MustCompile(`(?i)\b(Bearer\s+)[A-Za-z0-9._~+/=-]{12,}`),
|
||||||
|
regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b`),
|
||||||
|
regexp.MustCompile(`(?i)\b(?:sk|sk-proj|sk-ant|sess|rk|pk|ak|api|key|token|secret)[_-][A-Za-z0-9._~+/=-]{12,}\b`),
|
||||||
|
regexp.MustCompile(`\b[0-9a-fA-F]{32,}\b`),
|
||||||
|
regexp.MustCompile(`\b[A-Za-z0-9_-]{48,}\b`),
|
||||||
|
regexp.MustCompile(`\b[A-Za-z0-9+/]{48,}={0,2}\b`),
|
||||||
|
regexp.MustCompile(`\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b`),
|
||||||
|
}
|
||||||
|
|
||||||
|
func redactContentModerationSecrets(text string) string {
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
if text == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
out := text
|
||||||
|
for idx, pattern := range contentModerationSecretPatterns {
|
||||||
|
switch idx {
|
||||||
|
case 0:
|
||||||
|
out = pattern.ReplaceAllString(out, `${1}${2}[已脱敏]`)
|
||||||
|
case 1:
|
||||||
|
out = pattern.ReplaceAllString(out, `${1}[已脱敏]`)
|
||||||
|
default:
|
||||||
|
out = pattern.ReplaceAllString(out, `[已脱敏]`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
811
backend/internal/service/content_moderation_test.go
Normal file
811
backend/internal/service/content_moderation_test.go
Normal file
@ -0,0 +1,811 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type contentModerationTestSettingRepo struct {
|
||||||
|
values map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationTestSettingRepo) Get(ctx context.Context, key string) (*Setting, error) {
|
||||||
|
if value, ok := r.values[key]; ok {
|
||||||
|
return &Setting{Key: key, Value: value}, nil
|
||||||
|
}
|
||||||
|
return nil, ErrSettingNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationTestSettingRepo) GetValue(ctx context.Context, key string) (string, error) {
|
||||||
|
if value, ok := r.values[key]; ok {
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
return "", ErrSettingNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationTestSettingRepo) Set(ctx context.Context, key, value string) error {
|
||||||
|
if r.values == nil {
|
||||||
|
r.values = map[string]string{}
|
||||||
|
}
|
||||||
|
r.values[key] = value
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationTestSettingRepo) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) {
|
||||||
|
out := map[string]string{}
|
||||||
|
for _, key := range keys {
|
||||||
|
if value, ok := r.values[key]; ok {
|
||||||
|
out[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationTestSettingRepo) SetMultiple(ctx context.Context, settings map[string]string) error {
|
||||||
|
if r.values == nil {
|
||||||
|
r.values = map[string]string{}
|
||||||
|
}
|
||||||
|
for key, value := range settings {
|
||||||
|
r.values[key] = value
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationTestSettingRepo) GetAll(ctx context.Context) (map[string]string, error) {
|
||||||
|
out := make(map[string]string, len(r.values))
|
||||||
|
for key, value := range r.values {
|
||||||
|
out[key] = value
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationTestSettingRepo) Delete(ctx context.Context, key string) error {
|
||||||
|
delete(r.values, key)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type contentModerationTestRepo struct {
|
||||||
|
logs []ContentModerationLog
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationTestRepo) CreateLog(ctx context.Context, log *ContentModerationLog) error {
|
||||||
|
if log != nil {
|
||||||
|
r.logs = append(r.logs, *log)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationTestRepo) ListLogs(ctx context.Context, filter ContentModerationLogFilter) ([]ContentModerationLog, *pagination.PaginationResult, error) {
|
||||||
|
return nil, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationTestRepo) CountFlaggedByUserSince(ctx context.Context, userID int64, since time.Time) (int, error) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationTestRepo) CleanupExpiredLogs(ctx context.Context, hitBefore time.Time, nonHitBefore time.Time) (*ContentModerationCleanupResult, error) {
|
||||||
|
return &ContentModerationCleanupResult{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type contentModerationTestHashCache struct {
|
||||||
|
hashes map[string]struct{}
|
||||||
|
recorded []string
|
||||||
|
checked []string
|
||||||
|
deleted []string
|
||||||
|
hasResult bool
|
||||||
|
hasResultUsed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type contentModerationTestUserRepo struct {
|
||||||
|
user *User
|
||||||
|
updated []User
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationTestUserRepo) Create(ctx context.Context, user *User) error {
|
||||||
|
panic("unexpected Create call")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationTestUserRepo) GetByID(ctx context.Context, id int64) (*User, error) {
|
||||||
|
if r.user == nil {
|
||||||
|
return nil, ErrUserNotFound
|
||||||
|
}
|
||||||
|
clone := *r.user
|
||||||
|
return &clone, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationTestUserRepo) GetByEmail(ctx context.Context, email string) (*User, error) {
|
||||||
|
panic("unexpected GetByEmail call")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationTestUserRepo) GetFirstAdmin(ctx context.Context) (*User, error) {
|
||||||
|
panic("unexpected GetFirstAdmin call")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationTestUserRepo) Update(ctx context.Context, user *User) error {
|
||||||
|
if user == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
clone := *user
|
||||||
|
r.updated = append(r.updated, clone)
|
||||||
|
r.user = &clone
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationTestUserRepo) Delete(ctx context.Context, id int64) error {
|
||||||
|
panic("unexpected Delete call")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationTestUserRepo) GetUserAvatar(ctx context.Context, userID int64) (*UserAvatar, error) {
|
||||||
|
panic("unexpected GetUserAvatar call")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationTestUserRepo) UpsertUserAvatar(ctx context.Context, userID int64, input UpsertUserAvatarInput) (*UserAvatar, error) {
|
||||||
|
panic("unexpected UpsertUserAvatar call")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationTestUserRepo) DeleteUserAvatar(ctx context.Context, userID int64) error {
|
||||||
|
panic("unexpected DeleteUserAvatar call")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationTestUserRepo) List(ctx context.Context, params pagination.PaginationParams) ([]User, *pagination.PaginationResult, error) {
|
||||||
|
panic("unexpected List call")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationTestUserRepo) ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters UserListFilters) ([]User, *pagination.PaginationResult, error) {
|
||||||
|
panic("unexpected ListWithFilters call")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationTestUserRepo) GetLatestUsedAtByUserIDs(ctx context.Context, userIDs []int64) (map[int64]*time.Time, error) {
|
||||||
|
panic("unexpected GetLatestUsedAtByUserIDs call")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationTestUserRepo) GetLatestUsedAtByUserID(ctx context.Context, userID int64) (*time.Time, error) {
|
||||||
|
panic("unexpected GetLatestUsedAtByUserID call")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationTestUserRepo) UpdateUserLastActiveAt(ctx context.Context, userID int64, activeAt time.Time) error {
|
||||||
|
panic("unexpected UpdateUserLastActiveAt call")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationTestUserRepo) UpdateBalance(ctx context.Context, id int64, amount float64) error {
|
||||||
|
panic("unexpected UpdateBalance call")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationTestUserRepo) DeductBalance(ctx context.Context, id int64, amount float64) error {
|
||||||
|
panic("unexpected DeductBalance call")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationTestUserRepo) UpdateConcurrency(ctx context.Context, id int64, amount int) error {
|
||||||
|
panic("unexpected UpdateConcurrency call")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationTestUserRepo) ExistsByEmail(ctx context.Context, email string) (bool, error) {
|
||||||
|
panic("unexpected ExistsByEmail call")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationTestUserRepo) RemoveGroupFromAllowedGroups(ctx context.Context, groupID int64) (int64, error) {
|
||||||
|
panic("unexpected RemoveGroupFromAllowedGroups call")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationTestUserRepo) AddGroupToAllowedGroups(ctx context.Context, userID int64, groupID int64) error {
|
||||||
|
panic("unexpected AddGroupToAllowedGroups call")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationTestUserRepo) RemoveGroupFromUserAllowedGroups(ctx context.Context, userID int64, groupID int64) error {
|
||||||
|
panic("unexpected RemoveGroupFromUserAllowedGroups call")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationTestUserRepo) ListUserAuthIdentities(ctx context.Context, userID int64) ([]UserAuthIdentityRecord, error) {
|
||||||
|
panic("unexpected ListUserAuthIdentities call")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationTestUserRepo) UnbindUserAuthProvider(ctx context.Context, userID int64, provider string) error {
|
||||||
|
panic("unexpected UnbindUserAuthProvider call")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationTestUserRepo) UpdateTotpSecret(ctx context.Context, userID int64, encryptedSecret *string) error {
|
||||||
|
panic("unexpected UpdateTotpSecret call")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationTestUserRepo) EnableTotp(ctx context.Context, userID int64) error {
|
||||||
|
panic("unexpected EnableTotp call")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *contentModerationTestUserRepo) DisableTotp(ctx context.Context, userID int64) error {
|
||||||
|
panic("unexpected DisableTotp call")
|
||||||
|
}
|
||||||
|
|
||||||
|
type contentModerationTestAuthCacheInvalidator struct {
|
||||||
|
userIDs []int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *contentModerationTestAuthCacheInvalidator) InvalidateAuthCacheByKey(ctx context.Context, key string) {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *contentModerationTestAuthCacheInvalidator) InvalidateAuthCacheByUserID(ctx context.Context, userID int64) {
|
||||||
|
i.userIDs = append(i.userIDs, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *contentModerationTestAuthCacheInvalidator) InvalidateAuthCacheByGroupID(ctx context.Context, groupID int64) {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *contentModerationTestHashCache) RecordFlaggedInputHash(ctx context.Context, inputHash string) error {
|
||||||
|
if c.hashes == nil {
|
||||||
|
c.hashes = map[string]struct{}{}
|
||||||
|
}
|
||||||
|
c.hashes[inputHash] = struct{}{}
|
||||||
|
c.recorded = append(c.recorded, inputHash)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *contentModerationTestHashCache) HasFlaggedInputHash(ctx context.Context, inputHash string) (bool, error) {
|
||||||
|
c.checked = append(c.checked, inputHash)
|
||||||
|
if c.hasResultUsed {
|
||||||
|
return c.hasResult, nil
|
||||||
|
}
|
||||||
|
_, ok := c.hashes[inputHash]
|
||||||
|
return ok, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *contentModerationTestHashCache) DeleteFlaggedInputHash(ctx context.Context, inputHash string) (bool, error) {
|
||||||
|
c.deleted = append(c.deleted, inputHash)
|
||||||
|
if c.hashes == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if _, ok := c.hashes[inputHash]; !ok {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
delete(c.hashes, inputHash)
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *contentModerationTestHashCache) ClearFlaggedInputHashes(ctx context.Context) (int64, error) {
|
||||||
|
deleted := int64(len(c.hashes))
|
||||||
|
c.hashes = map[string]struct{}{}
|
||||||
|
return deleted, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *contentModerationTestHashCache) CountFlaggedInputHashes(ctx context.Context) (int64, error) {
|
||||||
|
return int64(len(c.hashes)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildContentModerationLog_RedactsInputExcerpt(t *testing.T) {
|
||||||
|
svc := &ContentModerationService{}
|
||||||
|
cfg := defaultContentModerationConfig()
|
||||||
|
input := ContentModerationCheckInput{
|
||||||
|
RequestID: "req-1",
|
||||||
|
Endpoint: "/v1/chat/completions",
|
||||||
|
Provider: "openai",
|
||||||
|
}
|
||||||
|
|
||||||
|
log := svc.buildLog(input, cfg, ContentModerationActionAllow, true, "sexual", 0.8, map[string]float64{"sexual": 0.8}, "hello sk-proj-1234567890abcdef", nil, nil, "")
|
||||||
|
|
||||||
|
require.NotContains(t, log.InputExcerpt, "sk-proj-1234567890abcdef")
|
||||||
|
require.Contains(t, log.InputExcerpt, "[已脱敏]")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedactContentModerationSecrets_LongHexAndTokens(t *testing.T) {
|
||||||
|
input := "你哈市多大事cf5bbdc4cd508f3aaf0d2070d529d4a4ac29099f8ecc357f696df28e1df91554 token=abc123456789xyz Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signaturepart"
|
||||||
|
|
||||||
|
out := redactContentModerationSecrets(input)
|
||||||
|
|
||||||
|
require.NotContains(t, out, "cf5bbdc4cd508f3aaf0d2070d529d4a4ac29099f8ecc357f696df28e1df91554")
|
||||||
|
require.NotContains(t, out, "abc123456789xyz")
|
||||||
|
require.NotContains(t, out, "eyJhbGciOiJIUzI1NiJ9")
|
||||||
|
require.Contains(t, out, "[已脱敏]")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContentModerationConfigNormalize_NonHitRetentionMaxThreeDays(t *testing.T) {
|
||||||
|
cfg := defaultContentModerationConfig()
|
||||||
|
cfg.NonHitRetentionDays = 30
|
||||||
|
|
||||||
|
cfg.normalize()
|
||||||
|
|
||||||
|
require.Equal(t, 3, cfg.NonHitRetentionDays)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractContentModerationInput_AnthropicImageSourceOnlyParticipatesInMemory(t *testing.T) {
|
||||||
|
body := []byte(`{
|
||||||
|
"messages": [
|
||||||
|
{"role":"user","content":"old"},
|
||||||
|
{"role":"assistant","content":"ok"},
|
||||||
|
{"role":"user","content":[
|
||||||
|
{"type":"text","text":"检查这张图"},
|
||||||
|
{"type":"image","source":{"type":"base64","media_type":"image/png","data":"aGVsbG8="}}
|
||||||
|
]}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
input := ExtractContentModerationInput(ContentModerationProtocolAnthropicMessages, body)
|
||||||
|
require.Equal(t, "检查这张图", input.Text)
|
||||||
|
require.Equal(t, []string{"data:image/png;base64,aGVsbG8="}, input.Images)
|
||||||
|
|
||||||
|
log := (&ContentModerationService{}).buildLog(ContentModerationCheckInput{}, defaultContentModerationConfig(), ContentModerationActionAllow, false, "", 0, nil, input.ExcerptText(), nil, nil, "")
|
||||||
|
require.Equal(t, "检查这张图", log.InputExcerpt)
|
||||||
|
require.NotContains(t, log.InputExcerpt, "aGVsbG8=")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractContentModerationInput_AnthropicKeepsEphemeralUserTextAndSkipsSystemReminders(t *testing.T) {
|
||||||
|
body := []byte(`{
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "<system-reminder>工具说明</system-reminder>"},
|
||||||
|
{"type": "text", "text": "<system-reminder>Ainder>\n\n"},
|
||||||
|
{"type": "text", "text": "hid", "cache_control": {"type": "ephemeral"}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
input := ExtractContentModerationInput(ContentModerationProtocolAnthropicMessages, body)
|
||||||
|
|
||||||
|
require.Equal(t, "hid", input.Text)
|
||||||
|
require.Empty(t, input.Images)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractContentModerationInput_OpenAIChatUsesLastUserMessage(t *testing.T) {
|
||||||
|
body := []byte(`{
|
||||||
|
"model":"gpt-5.5",
|
||||||
|
"messages":[
|
||||||
|
{"role":"system","content":"system prompt"},
|
||||||
|
{"role":"user","content":"old user"},
|
||||||
|
{"role":"assistant","content":"ok"},
|
||||||
|
{"role":"user","content":[{"type":"text","text":"latest user"},{"type":"image_url","image_url":{"url":"https://example.com/a.png"}}]}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
input := ExtractContentModerationInput(ContentModerationProtocolOpenAIChat, body)
|
||||||
|
|
||||||
|
require.Equal(t, "latest user", input.Text)
|
||||||
|
require.Equal(t, []string{"https://example.com/a.png"}, input.Images)
|
||||||
|
require.NotContains(t, input.Text, "old user")
|
||||||
|
require.NotContains(t, input.Text, "system prompt")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractContentModerationInput_OpenAIImagesIncludesPromptAndImages(t *testing.T) {
|
||||||
|
body := []byte(`{
|
||||||
|
"prompt":"replace background",
|
||||||
|
"images":[
|
||||||
|
{"image_url":"https://example.com/source.png"},
|
||||||
|
{"image_url":"data:image/png;base64,aGVsbG8="}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
input := ExtractContentModerationInput(ContentModerationProtocolOpenAIImages, body)
|
||||||
|
|
||||||
|
require.Equal(t, "replace background", input.Text)
|
||||||
|
require.Equal(t, []string{"https://example.com/source.png", "data:image/png;base64,aGVsbG8="}, input.Images)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractContentModerationInput_OpenAIResponsesCodexPayloadUsesLastUserMessage(t *testing.T) {
|
||||||
|
body := []byte(`{
|
||||||
|
"model":"gpt-5.5",
|
||||||
|
"instructions":"instructions.....",
|
||||||
|
"input":[
|
||||||
|
{"type":"message","role":"developer","content":[{"type":"input_text","text":"developer permissions sk-proj-1234567890abcdef"}]},
|
||||||
|
{"type":"message","role":"user","content":[{"type":"input_text","text":"first user prompt"}]},
|
||||||
|
{"type":"message","role":"user","content":[{"type":"input_text","text":"last user prompt"}]}
|
||||||
|
],
|
||||||
|
"prompt_cache_key":"cache-key"
|
||||||
|
}`)
|
||||||
|
|
||||||
|
input := ExtractContentModerationInput(ContentModerationProtocolOpenAIResponses, body)
|
||||||
|
|
||||||
|
require.Equal(t, "last user prompt", input.Text)
|
||||||
|
require.Empty(t, input.Images)
|
||||||
|
require.NotContains(t, input.Text, "developer permissions")
|
||||||
|
require.NotContains(t, input.Text, "first user prompt")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContentModerationCheck_OpenAIResponsesRecordsNonHitForCodexPayload(t *testing.T) {
|
||||||
|
var moderationRequest moderationAPIRequest
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
require.Equal(t, "/v1/moderations", r.URL.Path)
|
||||||
|
require.NoError(t, json.NewDecoder(r.Body).Decode(&moderationRequest))
|
||||||
|
_ = json.NewEncoder(w).Encode(moderationAPIResponse{
|
||||||
|
Results: []moderationAPIResult{{
|
||||||
|
CategoryScores: map[string]float64{"sexual": 0.01},
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
cfg := defaultContentModerationConfig()
|
||||||
|
cfg.Enabled = true
|
||||||
|
cfg.Mode = ContentModerationModePreBlock
|
||||||
|
cfg.BaseURL = server.URL
|
||||||
|
cfg.APIKeys = []string{"sk-test"}
|
||||||
|
cfg.RecordNonHits = true
|
||||||
|
rawCfg, err := json.Marshal(cfg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
repo := &contentModerationTestRepo{}
|
||||||
|
svc := NewContentModerationService(
|
||||||
|
&contentModerationTestSettingRepo{values: map[string]string{
|
||||||
|
SettingKeyRiskControlEnabled: "true",
|
||||||
|
SettingKeyContentModerationConfig: string(rawCfg),
|
||||||
|
}},
|
||||||
|
repo,
|
||||||
|
&contentModerationTestHashCache{},
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
body := []byte(`{
|
||||||
|
"model":"gpt-5.5",
|
||||||
|
"input":[
|
||||||
|
{"type":"message","role":"developer","content":[{"type":"input_text","text":"developer instructions should not be audited"}]},
|
||||||
|
{"type":"message","role":"user","content":[{"type":"input_text","text":"first user prompt"}]},
|
||||||
|
{"type":"message","role":"user","content":[{"type":"input_text","text":"last user prompt"}]}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
decision, err := svc.Check(context.Background(), ContentModerationCheckInput{
|
||||||
|
UserID: 1001,
|
||||||
|
Endpoint: "/responses",
|
||||||
|
Provider: "openai",
|
||||||
|
Model: "gpt-5.5",
|
||||||
|
Protocol: ContentModerationProtocolOpenAIResponses,
|
||||||
|
Body: body,
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, decision.Blocked)
|
||||||
|
require.Len(t, repo.logs, 1)
|
||||||
|
require.False(t, repo.logs[0].Flagged)
|
||||||
|
require.Equal(t, ContentModerationActionAllow, repo.logs[0].Action)
|
||||||
|
require.Equal(t, "/responses", repo.logs[0].Endpoint)
|
||||||
|
require.Equal(t, "last user prompt", repo.logs[0].InputExcerpt)
|
||||||
|
require.Equal(t, "last user prompt", moderationRequest.Input)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContentModerationCheck_PreBlockBlocksCodexResponsesLatestUserInput(t *testing.T) {
|
||||||
|
var moderationRequest moderationAPIRequest
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
require.Equal(t, "/v1/moderations", r.URL.Path)
|
||||||
|
require.NoError(t, json.NewDecoder(r.Body).Decode(&moderationRequest))
|
||||||
|
_ = json.NewEncoder(w).Encode(moderationAPIResponse{
|
||||||
|
Results: []moderationAPIResult{{
|
||||||
|
CategoryScores: map[string]float64{"sexual": 0.9},
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
cfg := defaultContentModerationConfig()
|
||||||
|
cfg.Enabled = true
|
||||||
|
cfg.Mode = ContentModerationModePreBlock
|
||||||
|
cfg.BaseURL = server.URL
|
||||||
|
cfg.APIKeys = []string{"sk-test"}
|
||||||
|
cfg.BlockStatus = http.StatusUnavailableForLegalReasons
|
||||||
|
cfg.BlockMessage = "内容审计测试阻断"
|
||||||
|
rawCfg, err := json.Marshal(cfg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
repo := &contentModerationTestRepo{}
|
||||||
|
svc := NewContentModerationService(
|
||||||
|
&contentModerationTestSettingRepo{values: map[string]string{
|
||||||
|
SettingKeyRiskControlEnabled: "true",
|
||||||
|
SettingKeyContentModerationConfig: string(rawCfg),
|
||||||
|
}},
|
||||||
|
repo,
|
||||||
|
&contentModerationTestHashCache{},
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
body := []byte(`{
|
||||||
|
"model":"gpt-5.5",
|
||||||
|
"instructions":"instructions.....",
|
||||||
|
"input":[
|
||||||
|
{"type":"message","role":"developer","content":[{"type":"input_text","text":"developer instructions should not be audited"}]},
|
||||||
|
{"type":"message","role":"user","content":[{"type":"input_text","text":"environment context"}]},
|
||||||
|
{"type":"message","role":"user","content":[{"type":"input_text","text":"latest blocked prompt"}]}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
decision, err := svc.Check(context.Background(), ContentModerationCheckInput{
|
||||||
|
UserID: 1001,
|
||||||
|
Endpoint: "/responses",
|
||||||
|
Provider: "openai",
|
||||||
|
Model: "gpt-5.5",
|
||||||
|
Protocol: ContentModerationProtocolOpenAIResponses,
|
||||||
|
Body: body,
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, decision.Blocked)
|
||||||
|
require.Equal(t, ContentModerationActionBlock, decision.Action)
|
||||||
|
require.Equal(t, http.StatusUnavailableForLegalReasons, decision.StatusCode)
|
||||||
|
require.Equal(t, "内容审计测试阻断", decision.Message)
|
||||||
|
require.Len(t, repo.logs, 1)
|
||||||
|
require.True(t, repo.logs[0].Flagged)
|
||||||
|
require.Equal(t, ContentModerationActionBlock, repo.logs[0].Action)
|
||||||
|
require.Equal(t, ContentModerationModePreBlock, repo.logs[0].Mode)
|
||||||
|
require.Equal(t, "latest blocked prompt", repo.logs[0].InputExcerpt)
|
||||||
|
require.Equal(t, "latest blocked prompt", moderationRequest.Input)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildContentModerationTestAuditResult_UsesConfiguredThresholdsOnly(t *testing.T) {
|
||||||
|
result := buildContentModerationTestAuditResult(&moderationAPIResult{
|
||||||
|
Flagged: true,
|
||||||
|
CategoryScores: map[string]float64{
|
||||||
|
"harassment": 0.65,
|
||||||
|
},
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
require.NotNil(t, result)
|
||||||
|
require.False(t, result.Flagged)
|
||||||
|
require.Equal(t, "harassment", result.HighestCategory)
|
||||||
|
require.Equal(t, 0.65, result.HighestScore)
|
||||||
|
require.Equal(t, 0.65, result.CompositeScore)
|
||||||
|
require.Equal(t, 0.98, result.Thresholds["harassment"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContentModerationCheck_PreHashUsesRedisHashCache(t *testing.T) {
|
||||||
|
cfg := defaultContentModerationConfig()
|
||||||
|
cfg.Enabled = true
|
||||||
|
cfg.PreHashCheckEnabled = true
|
||||||
|
cfg.APIKeys = []string{"sk-test"}
|
||||||
|
cfg.BlockStatus = http.StatusConflict
|
||||||
|
cfg.BlockMessage = "命中历史风险输入"
|
||||||
|
rawCfg, err := json.Marshal(cfg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
hashCache := &contentModerationTestHashCache{hashes: map[string]struct{}{}}
|
||||||
|
content := ContentModerationInput{Text: "blocked prompt"}
|
||||||
|
content.Normalize()
|
||||||
|
hashCache.hashes[content.Hash()] = struct{}{}
|
||||||
|
|
||||||
|
svc := NewContentModerationService(
|
||||||
|
&contentModerationTestSettingRepo{values: map[string]string{
|
||||||
|
SettingKeyRiskControlEnabled: "true",
|
||||||
|
SettingKeyContentModerationConfig: string(rawCfg),
|
||||||
|
}},
|
||||||
|
&contentModerationTestRepo{},
|
||||||
|
hashCache,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
decision, err := svc.Check(context.Background(), ContentModerationCheckInput{
|
||||||
|
Protocol: ContentModerationProtocolOpenAIChat,
|
||||||
|
Body: []byte(`{"messages":[{"role":"user","content":"blocked prompt"}]}`),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, decision.Blocked)
|
||||||
|
require.Equal(t, ContentModerationActionHashBlock, decision.Action)
|
||||||
|
require.Equal(t, http.StatusConflict, decision.StatusCode)
|
||||||
|
require.Equal(t, content.Hash(), decision.InputHash)
|
||||||
|
require.Contains(t, decision.Message, "命中历史风险输入")
|
||||||
|
require.Contains(t, decision.Message, content.Hash())
|
||||||
|
require.Len(t, hashCache.checked, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContentModerationCheck_PreBlockFlaggedWritesRedisHashCache(t *testing.T) {
|
||||||
|
requestCount := 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requestCount++
|
||||||
|
_ = json.NewEncoder(w).Encode(moderationAPIResponse{
|
||||||
|
Results: []moderationAPIResult{{
|
||||||
|
CategoryScores: map[string]float64{"sexual": 0.9},
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
cfg := defaultContentModerationConfig()
|
||||||
|
cfg.Enabled = true
|
||||||
|
cfg.Mode = ContentModerationModePreBlock
|
||||||
|
cfg.PreHashCheckEnabled = true
|
||||||
|
cfg.BaseURL = server.URL
|
||||||
|
cfg.APIKeys = []string{"sk-test"}
|
||||||
|
cfg.BlockStatus = http.StatusConflict
|
||||||
|
cfg.BlockMessage = "命中风险输入"
|
||||||
|
rawCfg, err := json.Marshal(cfg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
repo := &contentModerationTestRepo{}
|
||||||
|
hashCache := &contentModerationTestHashCache{}
|
||||||
|
svc := NewContentModerationService(
|
||||||
|
&contentModerationTestSettingRepo{values: map[string]string{
|
||||||
|
SettingKeyRiskControlEnabled: "true",
|
||||||
|
SettingKeyContentModerationConfig: string(rawCfg),
|
||||||
|
}},
|
||||||
|
repo,
|
||||||
|
hashCache,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
body := []byte(`{"messages":[{"role":"user","content":"repeat blocked prompt"}]}`)
|
||||||
|
decision, err := svc.Check(context.Background(), ContentModerationCheckInput{
|
||||||
|
Protocol: ContentModerationProtocolOpenAIChat,
|
||||||
|
Body: body,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, decision.Blocked)
|
||||||
|
require.Equal(t, ContentModerationActionBlock, decision.Action)
|
||||||
|
require.Equal(t, 1, requestCount)
|
||||||
|
require.Len(t, hashCache.recorded, 1)
|
||||||
|
require.Len(t, repo.logs, 1)
|
||||||
|
|
||||||
|
decision, err = svc.Check(context.Background(), ContentModerationCheckInput{
|
||||||
|
Protocol: ContentModerationProtocolOpenAIChat,
|
||||||
|
Body: body,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, decision.Blocked)
|
||||||
|
require.Equal(t, ContentModerationActionHashBlock, decision.Action)
|
||||||
|
require.Equal(t, hashCache.recorded[0], decision.InputHash)
|
||||||
|
require.Equal(t, 1, requestCount)
|
||||||
|
require.Len(t, repo.logs, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContentModerationDeleteFlaggedInputHash_NormalizesAndDeletes(t *testing.T) {
|
||||||
|
existingHash := strings.Repeat("a", 64)
|
||||||
|
hashCache := &contentModerationTestHashCache{hashes: map[string]struct{}{
|
||||||
|
existingHash: {},
|
||||||
|
}}
|
||||||
|
svc := &ContentModerationService{hashCache: hashCache}
|
||||||
|
|
||||||
|
result, err := svc.DeleteFlaggedInputHash(context.Background(), strings.ToUpper(existingHash))
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, existingHash, result.InputHash)
|
||||||
|
require.True(t, result.Deleted)
|
||||||
|
require.NotContains(t, hashCache.hashes, existingHash)
|
||||||
|
require.Equal(t, []string{existingHash}, hashCache.deleted)
|
||||||
|
|
||||||
|
result, err = svc.DeleteFlaggedInputHash(context.Background(), existingHash)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, existingHash, result.InputHash)
|
||||||
|
require.False(t, result.Deleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContentModerationClearFlaggedInputHashesAndStatusCount(t *testing.T) {
|
||||||
|
cfg := defaultContentModerationConfig()
|
||||||
|
cfg.Enabled = true
|
||||||
|
rawCfg, err := json.Marshal(cfg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
hashCache := &contentModerationTestHashCache{hashes: map[string]struct{}{
|
||||||
|
strings.Repeat("a", 64): {},
|
||||||
|
strings.Repeat("b", 64): {},
|
||||||
|
}}
|
||||||
|
svc := &ContentModerationService{
|
||||||
|
settingRepo: &contentModerationTestSettingRepo{values: map[string]string{
|
||||||
|
SettingKeyRiskControlEnabled: "true",
|
||||||
|
SettingKeyContentModerationConfig: string(rawCfg),
|
||||||
|
}},
|
||||||
|
hashCache: hashCache,
|
||||||
|
keyHealth: make(map[string]*contentModerationKeyHealth),
|
||||||
|
}
|
||||||
|
|
||||||
|
status, err := svc.GetStatus(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, int64(2), status.FlaggedHashCount)
|
||||||
|
|
||||||
|
result, err := svc.ClearFlaggedInputHashes(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, int64(2), result.Deleted)
|
||||||
|
|
||||||
|
status, err = svc.GetStatus(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, int64(0), status.FlaggedHashCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContentModerationCheck_AsyncFlaggedWritesRedisHashCache(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_ = json.NewEncoder(w).Encode(moderationAPIResponse{
|
||||||
|
Results: []moderationAPIResult{{
|
||||||
|
CategoryScores: map[string]float64{"sexual": 0.9},
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
cfg := defaultContentModerationConfig()
|
||||||
|
cfg.Enabled = true
|
||||||
|
cfg.Mode = ContentModerationModeObserve
|
||||||
|
cfg.BaseURL = server.URL
|
||||||
|
cfg.APIKeys = []string{"sk-test"}
|
||||||
|
rawCfg, err := json.Marshal(cfg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
repo := &contentModerationTestRepo{}
|
||||||
|
hashCache := &contentModerationTestHashCache{}
|
||||||
|
svc := NewContentModerationService(
|
||||||
|
&contentModerationTestSettingRepo{values: map[string]string{
|
||||||
|
SettingKeyRiskControlEnabled: "true",
|
||||||
|
SettingKeyContentModerationConfig: string(rawCfg),
|
||||||
|
}},
|
||||||
|
repo,
|
||||||
|
hashCache,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
decision := svc.checkSync(context.Background(), ContentModerationCheckInput{
|
||||||
|
Protocol: ContentModerationProtocolOpenAIChat,
|
||||||
|
Body: []byte(`{"messages":[{"role":"user","content":"bad prompt"}]}`),
|
||||||
|
}, cfg, ContentModerationInput{Text: "bad prompt"}, strings.Repeat("b", 64), contentModerationIntPtr(25), false)
|
||||||
|
|
||||||
|
require.False(t, decision.Blocked)
|
||||||
|
require.Len(t, hashCache.recorded, 1)
|
||||||
|
require.Len(t, repo.logs, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildContentModerationAccountDisabledEmailBody_ContainsBanDetails(t *testing.T) {
|
||||||
|
userID := int64(1001)
|
||||||
|
cfg := defaultContentModerationConfig()
|
||||||
|
cfg.BanThreshold = 10
|
||||||
|
body := buildContentModerationAccountDisabledEmailBody("Sub2API <Admin>", &ContentModerationLog{
|
||||||
|
UserID: &userID,
|
||||||
|
UserEmail: "user@example.com",
|
||||||
|
GroupName: "vip_2",
|
||||||
|
HighestCategory: "sexual",
|
||||||
|
HighestScore: 0.926,
|
||||||
|
ViolationCount: 10,
|
||||||
|
}, cfg)
|
||||||
|
|
||||||
|
require.Contains(t, body, "账户已被自动禁用")
|
||||||
|
require.Contains(t, body, "封禁详情")
|
||||||
|
require.Contains(t, body, "账户当前处于封禁状态,所有 API 请求将被拒绝")
|
||||||
|
require.Contains(t, body, "10 次(阈值 10)")
|
||||||
|
require.Contains(t, body, "sexual / 0.926")
|
||||||
|
require.Contains(t, body, "Sub2API <Admin>")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContentModerationUnbanUser_ActivatesUserAndInvalidatesAuthCache(t *testing.T) {
|
||||||
|
userRepo := &contentModerationTestUserRepo{user: &User{ID: 1001, Email: "user@example.com", Status: StatusDisabled}}
|
||||||
|
invalidator := &contentModerationTestAuthCacheInvalidator{}
|
||||||
|
repo := &contentModerationTestRepo{}
|
||||||
|
svc := NewContentModerationService(nil, repo, nil, nil, userRepo, invalidator, nil)
|
||||||
|
|
||||||
|
result, err := svc.UnbanUser(context.Background(), 1001)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, int64(1001), result.UserID)
|
||||||
|
require.Equal(t, StatusActive, result.Status)
|
||||||
|
require.Len(t, userRepo.updated, 1)
|
||||||
|
require.Equal(t, StatusActive, userRepo.updated[0].Status)
|
||||||
|
require.Equal(t, []int64{1001}, invalidator.userIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContentModerationUnbanUser_ActiveUserOnlyInvalidatesAuthCache(t *testing.T) {
|
||||||
|
userRepo := &contentModerationTestUserRepo{user: &User{ID: 1001, Email: "user@example.com", Status: StatusActive}}
|
||||||
|
invalidator := &contentModerationTestAuthCacheInvalidator{}
|
||||||
|
repo := &contentModerationTestRepo{}
|
||||||
|
svc := NewContentModerationService(nil, repo, nil, nil, userRepo, invalidator, nil)
|
||||||
|
|
||||||
|
result, err := svc.UnbanUser(context.Background(), 1001)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, StatusActive, result.Status)
|
||||||
|
require.Empty(t, userRepo.updated)
|
||||||
|
require.Equal(t, []int64{1001}, invalidator.userIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func contentModerationIntPtr(v int) *int {
|
||||||
|
return &v
|
||||||
|
}
|
||||||
@ -107,6 +107,8 @@ const (
|
|||||||
SettingKeyAffiliateRebateFreezeHours = "affiliate_rebate_freeze_hours" // 返利冻结期(小时,0=不冻结)
|
SettingKeyAffiliateRebateFreezeHours = "affiliate_rebate_freeze_hours" // 返利冻结期(小时,0=不冻结)
|
||||||
SettingKeyAffiliateRebateDurationDays = "affiliate_rebate_duration_days" // 返利有效期(天,0=永久)
|
SettingKeyAffiliateRebateDurationDays = "affiliate_rebate_duration_days" // 返利有效期(天,0=永久)
|
||||||
SettingKeyAffiliateRebatePerInviteeCap = "affiliate_rebate_per_invitee_cap" // 单人返利上限(0=无上限)
|
SettingKeyAffiliateRebatePerInviteeCap = "affiliate_rebate_per_invitee_cap" // 单人返利上限(0=无上限)
|
||||||
|
SettingKeyRiskControlEnabled = "risk_control_enabled" // 是否启用风控中心入口与审计链路
|
||||||
|
SettingKeyContentModerationConfig = "content_moderation_config" // 内容审计配置(JSON)
|
||||||
|
|
||||||
// 邮件服务设置
|
// 邮件服务设置
|
||||||
SettingKeySMTPHost = "smtp_host" // SMTP服务器地址
|
SettingKeySMTPHost = "smtp_host" // SMTP服务器地址
|
||||||
|
|||||||
@ -90,6 +90,69 @@ type OpenAIImagesRequest struct {
|
|||||||
bodyHash string
|
bodyHash string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *OpenAIImagesRequest) ModerationBody() []byte {
|
||||||
|
if r == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
payload := map[string]any{}
|
||||||
|
if prompt := strings.TrimSpace(r.Prompt); prompt != "" {
|
||||||
|
payload["prompt"] = prompt
|
||||||
|
}
|
||||||
|
images := r.moderationImages()
|
||||||
|
if len(images) > 0 {
|
||||||
|
payload["images"] = images
|
||||||
|
}
|
||||||
|
if len(payload) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *OpenAIImagesRequest) moderationImages() []map[string]string {
|
||||||
|
if r == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
images := make([]map[string]string, 0, len(r.InputImageURLs)+len(r.Uploads)+1)
|
||||||
|
for _, imageURL := range r.InputImageURLs {
|
||||||
|
imageURL = strings.TrimSpace(imageURL)
|
||||||
|
if imageURL != "" {
|
||||||
|
images = append(images, map[string]string{"image_url": imageURL})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, upload := range r.Uploads {
|
||||||
|
if dataURL := upload.ModerationDataURL(); dataURL != "" {
|
||||||
|
images = append(images, map[string]string{"image_url": dataURL})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if maskURL := strings.TrimSpace(r.MaskImageURL); maskURL != "" {
|
||||||
|
images = append(images, map[string]string{"image_url": maskURL})
|
||||||
|
}
|
||||||
|
if r.MaskUpload != nil {
|
||||||
|
if dataURL := r.MaskUpload.ModerationDataURL(); dataURL != "" {
|
||||||
|
images = append(images, map[string]string{"image_url": dataURL})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return images
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u OpenAIImagesUpload) ModerationDataURL() string {
|
||||||
|
if len(u.Data) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
contentType := strings.TrimSpace(u.ContentType)
|
||||||
|
if contentType == "" {
|
||||||
|
contentType = http.DetectContentType(u.Data)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(strings.ToLower(contentType), "image/") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("data:%s;base64,%s", contentType, base64.StdEncoding.EncodeToString(u.Data))
|
||||||
|
}
|
||||||
|
|
||||||
func (r *OpenAIImagesRequest) IsEdits() bool {
|
func (r *OpenAIImagesRequest) IsEdits() bool {
|
||||||
return r != nil && r.Endpoint == openAIImagesEditsEndpoint
|
return r != nil && r.Endpoint == openAIImagesEditsEndpoint
|
||||||
}
|
}
|
||||||
|
|||||||
@ -90,6 +90,51 @@ func TestOpenAIGatewayServiceParseOpenAIImagesRequest_MultipartEdit(t *testing.T
|
|||||||
require.Equal(t, OpenAIImagesCapabilityNative, parsed.RequiredCapability)
|
require.Equal(t, OpenAIImagesCapabilityNative, parsed.RequiredCapability)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOpenAIImagesRequestModerationBody_JSONEditIncludesInputImageURLs(t *testing.T) {
|
||||||
|
parsed := &OpenAIImagesRequest{
|
||||||
|
Endpoint: openAIImagesEditsEndpoint,
|
||||||
|
Prompt: "replace background",
|
||||||
|
InputImageURLs: []string{"https://example.com/source.png"},
|
||||||
|
MaskImageURL: "https://example.com/mask.png",
|
||||||
|
}
|
||||||
|
|
||||||
|
input := ExtractContentModerationInput(ContentModerationProtocolOpenAIImages, parsed.ModerationBody())
|
||||||
|
|
||||||
|
require.Equal(t, "replace background", input.Text)
|
||||||
|
require.Equal(t, []string{"https://example.com/source.png", "https://example.com/mask.png"}, input.Images)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAIImagesRequestModerationBody_MultipartEditIncludesUploadsInMemory(t *testing.T) {
|
||||||
|
parsed := &OpenAIImagesRequest{
|
||||||
|
Endpoint: openAIImagesEditsEndpoint,
|
||||||
|
Prompt: "replace background",
|
||||||
|
Uploads: []OpenAIImagesUpload{{
|
||||||
|
FieldName: "image",
|
||||||
|
FileName: "source.png",
|
||||||
|
ContentType: "image/png",
|
||||||
|
Data: []byte("fake-image-bytes"),
|
||||||
|
}},
|
||||||
|
MaskUpload: &OpenAIImagesUpload{
|
||||||
|
FieldName: "mask",
|
||||||
|
FileName: "mask.png",
|
||||||
|
ContentType: "image/png",
|
||||||
|
Data: []byte("fake-mask-bytes"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
input := ExtractContentModerationInput(ContentModerationProtocolOpenAIImages, parsed.ModerationBody())
|
||||||
|
|
||||||
|
require.Equal(t, "replace background", input.Text)
|
||||||
|
require.Equal(t, []string{
|
||||||
|
"data:image/png;base64,ZmFrZS1pbWFnZS1ieXRlcw==",
|
||||||
|
"data:image/png;base64,ZmFrZS1tYXNrLWJ5dGVz",
|
||||||
|
}, input.Images)
|
||||||
|
|
||||||
|
log := (&ContentModerationService{}).buildLog(ContentModerationCheckInput{}, defaultContentModerationConfig(), ContentModerationActionAllow, false, "", 0, nil, input.ExcerptText(), nil, nil, "")
|
||||||
|
require.Equal(t, "replace background", log.InputExcerpt)
|
||||||
|
require.NotContains(t, log.InputExcerpt, "ZmFrZS")
|
||||||
|
}
|
||||||
|
|
||||||
func TestOpenAIGatewayServiceParseOpenAIImagesRequest_NormalizesOfficialAndCustomSizes(t *testing.T) {
|
func TestOpenAIGatewayServiceParseOpenAIImagesRequest_NormalizesOfficialAndCustomSizes(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
|||||||
@ -223,6 +223,7 @@ type OpenAIWSIngressHooks struct {
|
|||||||
// 的 reasoning effort 后缀推导,禁止用于上游请求或计费模型。
|
// 的 reasoning effort 后缀推导,禁止用于上游请求或计费模型。
|
||||||
InitialRequestModel string
|
InitialRequestModel string
|
||||||
BeforeTurn func(turn int) error
|
BeforeTurn func(turn int) error
|
||||||
|
BeforeRequest func(turn int, payload []byte, originalModel string) error
|
||||||
AfterTurn func(turn int, result *OpenAIForwardResult, turnErr error)
|
AfterTurn func(turn int, result *OpenAIForwardResult, turnErr error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -3222,6 +3223,11 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
for {
|
for {
|
||||||
|
if turn > 1 && !skipBeforeTurn && hooks != nil && hooks.BeforeRequest != nil {
|
||||||
|
if err := hooks.BeforeRequest(turn, currentPayload, currentOriginalModel); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
if !skipBeforeTurn && hooks != nil && hooks.BeforeTurn != nil {
|
if !skipBeforeTurn && hooks != nil && hooks.BeforeTurn != nil {
|
||||||
if err := hooks.BeforeTurn(turn); err != nil {
|
if err := hooks.BeforeTurn(turn); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@ -387,6 +387,19 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough(
|
|||||||
if msgType != coderws.MessageText {
|
if msgType != coderws.MessageText {
|
||||||
return payload, nil, nil
|
return payload, nil, nil
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(gjson.GetBytes(payload, "type").String()) == "response.create" && hooks != nil && hooks.BeforeRequest != nil {
|
||||||
|
turnNo := int(completedTurns.Load()) + 1
|
||||||
|
if turnNo < 2 {
|
||||||
|
turnNo = 2
|
||||||
|
}
|
||||||
|
requestModel := usageMeta.requestModelForFrame(payload)
|
||||||
|
if requestModel == "" {
|
||||||
|
requestModel = capturedSessionModel
|
||||||
|
}
|
||||||
|
if err := hooks.BeforeRequest(turnNo, payload, requestModel); err != nil {
|
||||||
|
return payload, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
// 在评估策略前先刷新 capturedSessionModel:客户端可能通过
|
// 在评估策略前先刷新 capturedSessionModel:客户端可能通过
|
||||||
// session.update 修改 session-level model(Realtime /
|
// session.update 修改 session-level model(Realtime /
|
||||||
// Responses WS 协议允许),如果不刷新就会出现
|
// Responses WS 协议允许),如果不刷新就会出现
|
||||||
|
|||||||
@ -456,6 +456,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
|||||||
SettingKeyChannelMonitorDefaultIntervalSeconds,
|
SettingKeyChannelMonitorDefaultIntervalSeconds,
|
||||||
SettingKeyAvailableChannelsEnabled,
|
SettingKeyAvailableChannelsEnabled,
|
||||||
SettingKeyAffiliateEnabled,
|
SettingKeyAffiliateEnabled,
|
||||||
|
SettingKeyRiskControlEnabled,
|
||||||
}
|
}
|
||||||
|
|
||||||
settings, err := s.settingRepo.GetMultiple(ctx, keys)
|
settings, err := s.settingRepo.GetMultiple(ctx, keys)
|
||||||
@ -545,6 +546,8 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
|||||||
AvailableChannelsEnabled: settings[SettingKeyAvailableChannelsEnabled] == "true",
|
AvailableChannelsEnabled: settings[SettingKeyAvailableChannelsEnabled] == "true",
|
||||||
|
|
||||||
AffiliateEnabled: settings[SettingKeyAffiliateEnabled] == "true",
|
AffiliateEnabled: settings[SettingKeyAffiliateEnabled] == "true",
|
||||||
|
|
||||||
|
RiskControlEnabled: settings[SettingKeyRiskControlEnabled] == "true",
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -692,6 +695,7 @@ type PublicSettingsInjectionPayload struct {
|
|||||||
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
|
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
|
||||||
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
|
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
|
||||||
AffiliateEnabled bool `json:"affiliate_enabled"`
|
AffiliateEnabled bool `json:"affiliate_enabled"`
|
||||||
|
RiskControlEnabled bool `json:"risk_control_enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPublicSettingsForInjection returns public settings in a format suitable for HTML injection.
|
// GetPublicSettingsForInjection returns public settings in a format suitable for HTML injection.
|
||||||
@ -745,6 +749,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
|
|||||||
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
|
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
|
||||||
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
|
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
|
||||||
AffiliateEnabled: settings.AffiliateEnabled,
|
AffiliateEnabled: settings.AffiliateEnabled,
|
||||||
|
RiskControlEnabled: settings.RiskControlEnabled,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1232,6 +1237,9 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
|
|||||||
// Affiliate (邀请返利) feature switch
|
// Affiliate (邀请返利) feature switch
|
||||||
updates[SettingKeyAffiliateEnabled] = strconv.FormatBool(settings.AffiliateEnabled)
|
updates[SettingKeyAffiliateEnabled] = strconv.FormatBool(settings.AffiliateEnabled)
|
||||||
|
|
||||||
|
// 风控中心功能开关
|
||||||
|
updates[SettingKeyRiskControlEnabled] = strconv.FormatBool(settings.RiskControlEnabled)
|
||||||
|
|
||||||
// Claude Code version check
|
// Claude Code version check
|
||||||
updates[SettingKeyMinClaudeCodeVersion] = settings.MinClaudeCodeVersion
|
updates[SettingKeyMinClaudeCodeVersion] = settings.MinClaudeCodeVersion
|
||||||
updates[SettingKeyMaxClaudeCodeVersion] = settings.MaxClaudeCodeVersion
|
updates[SettingKeyMaxClaudeCodeVersion] = settings.MaxClaudeCodeVersion
|
||||||
@ -1903,6 +1911,9 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
|
|||||||
// Affiliate (邀请返利) feature (default disabled; opt-in)
|
// Affiliate (邀请返利) feature (default disabled; opt-in)
|
||||||
SettingKeyAffiliateEnabled: "false",
|
SettingKeyAffiliateEnabled: "false",
|
||||||
|
|
||||||
|
// 风控中心功能(默认关闭,显式启用)
|
||||||
|
SettingKeyRiskControlEnabled: "false",
|
||||||
|
|
||||||
// Claude Code version check (default: empty = disabled)
|
// Claude Code version check (default: empty = disabled)
|
||||||
SettingKeyMinClaudeCodeVersion: "",
|
SettingKeyMinClaudeCodeVersion: "",
|
||||||
SettingKeyMaxClaudeCodeVersion: "",
|
SettingKeyMaxClaudeCodeVersion: "",
|
||||||
@ -2242,6 +2253,9 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
|
|||||||
// Affiliate (邀请返利) feature (default: disabled; strict true)
|
// Affiliate (邀请返利) feature (default: disabled; strict true)
|
||||||
result.AffiliateEnabled = settings[SettingKeyAffiliateEnabled] == "true"
|
result.AffiliateEnabled = settings[SettingKeyAffiliateEnabled] == "true"
|
||||||
|
|
||||||
|
// 风控中心功能(默认关闭,严格 true 才启用)
|
||||||
|
result.RiskControlEnabled = settings[SettingKeyRiskControlEnabled] == "true"
|
||||||
|
|
||||||
// Claude Code version check
|
// Claude Code version check
|
||||||
result.MinClaudeCodeVersion = settings[SettingKeyMinClaudeCodeVersion]
|
result.MinClaudeCodeVersion = settings[SettingKeyMinClaudeCodeVersion]
|
||||||
result.MaxClaudeCodeVersion = settings[SettingKeyMaxClaudeCodeVersion]
|
result.MaxClaudeCodeVersion = settings[SettingKeyMaxClaudeCodeVersion]
|
||||||
|
|||||||
@ -106,6 +106,7 @@ type SystemSettings struct {
|
|||||||
|
|
||||||
DefaultConcurrency int
|
DefaultConcurrency int
|
||||||
DefaultBalance float64
|
DefaultBalance float64
|
||||||
|
RiskControlEnabled bool
|
||||||
AffiliateEnabled bool
|
AffiliateEnabled bool
|
||||||
AffiliateRebateRate float64
|
AffiliateRebateRate float64
|
||||||
AffiliateRebateFreezeHours int
|
AffiliateRebateFreezeHours int
|
||||||
@ -233,6 +234,9 @@ type PublicSettings struct {
|
|||||||
|
|
||||||
// Affiliate (邀请返利) feature toggle
|
// Affiliate (邀请返利) feature toggle
|
||||||
AffiliateEnabled bool `json:"affiliate_enabled"`
|
AffiliateEnabled bool `json:"affiliate_enabled"`
|
||||||
|
|
||||||
|
// 风控中心功能开关
|
||||||
|
RiskControlEnabled bool `json:"risk_control_enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WeChatConnectOAuthConfig struct {
|
type WeChatConnectOAuthConfig struct {
|
||||||
|
|||||||
@ -509,6 +509,7 @@ var ProviderSet = wire.NewSet(
|
|||||||
NewGroupCapacityService,
|
NewGroupCapacityService,
|
||||||
NewChannelService,
|
NewChannelService,
|
||||||
NewModelPricingResolver,
|
NewModelPricingResolver,
|
||||||
|
NewContentModerationService,
|
||||||
NewAffiliateService,
|
NewAffiliateService,
|
||||||
ProvidePaymentConfigService,
|
ProvidePaymentConfigService,
|
||||||
NewPaymentService,
|
NewPaymentService,
|
||||||
|
|||||||
45
backend/migrations/135_content_moderation.sql
Normal file
45
backend/migrations/135_content_moderation.sql
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
-- 风控中心内容审计配置与记录
|
||||||
|
|
||||||
|
INSERT INTO settings (key, value, updated_at)
|
||||||
|
VALUES ('risk_control_enabled', 'false', NOW())
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS content_moderation_logs (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
request_id VARCHAR(128) NOT NULL DEFAULT '',
|
||||||
|
user_id BIGINT REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
user_email VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
api_key_id BIGINT REFERENCES api_keys(id) ON DELETE SET NULL,
|
||||||
|
api_key_name VARCHAR(100) NOT NULL DEFAULT '',
|
||||||
|
group_id BIGINT REFERENCES groups(id) ON DELETE SET NULL,
|
||||||
|
group_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
endpoint VARCHAR(128) NOT NULL DEFAULT '',
|
||||||
|
provider VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
model VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
mode VARCHAR(32) NOT NULL DEFAULT '',
|
||||||
|
action VARCHAR(32) NOT NULL DEFAULT '',
|
||||||
|
flagged BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
highest_category VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
highest_score DECIMAL(8, 6) NOT NULL DEFAULT 0,
|
||||||
|
category_scores JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
threshold_snapshot JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
input_excerpt TEXT NOT NULL DEFAULT '',
|
||||||
|
upstream_latency_ms INT,
|
||||||
|
error TEXT NOT NULL DEFAULT '',
|
||||||
|
violation_count INT NOT NULL DEFAULT 0,
|
||||||
|
auto_banned BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
email_sent BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
queue_delay_ms INT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE content_moderation_logs ADD COLUMN IF NOT EXISTS violation_count INT NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE content_moderation_logs ADD COLUMN IF NOT EXISTS auto_banned BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
ALTER TABLE content_moderation_logs ADD COLUMN IF NOT EXISTS email_sent BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
ALTER TABLE content_moderation_logs ADD COLUMN IF NOT EXISTS queue_delay_ms INT;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_content_moderation_logs_created_at ON content_moderation_logs(created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_content_moderation_logs_group_created_at ON content_moderation_logs(group_id, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_content_moderation_logs_flagged_created_at ON content_moderation_logs(flagged, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_content_moderation_logs_user_created_at ON content_moderation_logs(user_id, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_content_moderation_logs_api_key_created_at ON content_moderation_logs(api_key_id, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_content_moderation_logs_endpoint_created_at ON content_moderation_logs(endpoint, created_at DESC);
|
||||||
@ -30,6 +30,7 @@ import channelMonitorAPI from './channelMonitor'
|
|||||||
import channelMonitorTemplateAPI from './channelMonitorTemplate'
|
import channelMonitorTemplateAPI from './channelMonitorTemplate'
|
||||||
import adminPaymentAPI from './payment'
|
import adminPaymentAPI from './payment'
|
||||||
import affiliatesAPI from './affiliates'
|
import affiliatesAPI from './affiliates'
|
||||||
|
import riskControlAPI from './riskControl'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unified admin API object for convenient access
|
* Unified admin API object for convenient access
|
||||||
@ -61,7 +62,8 @@ export const adminAPI = {
|
|||||||
channelMonitor: channelMonitorAPI,
|
channelMonitor: channelMonitorAPI,
|
||||||
channelMonitorTemplate: channelMonitorTemplateAPI,
|
channelMonitorTemplate: channelMonitorTemplateAPI,
|
||||||
payment: adminPaymentAPI,
|
payment: adminPaymentAPI,
|
||||||
affiliates: affiliatesAPI
|
affiliates: affiliatesAPI,
|
||||||
|
riskControl: riskControlAPI
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@ -91,7 +93,8 @@ export {
|
|||||||
channelMonitorAPI,
|
channelMonitorAPI,
|
||||||
channelMonitorTemplateAPI,
|
channelMonitorTemplateAPI,
|
||||||
adminPaymentAPI,
|
adminPaymentAPI,
|
||||||
affiliatesAPI
|
affiliatesAPI,
|
||||||
|
riskControlAPI
|
||||||
}
|
}
|
||||||
|
|
||||||
export default adminAPI
|
export default adminAPI
|
||||||
@ -101,3 +104,4 @@ export type { BalanceHistoryItem } from './users'
|
|||||||
export type { ErrorPassthroughRule, CreateRuleRequest, UpdateRuleRequest } from './errorPassthrough'
|
export type { ErrorPassthroughRule, CreateRuleRequest, UpdateRuleRequest } from './errorPassthrough'
|
||||||
export type { BackupAgentHealth, DataManagementConfig } from './dataManagement'
|
export type { BackupAgentHealth, DataManagementConfig } from './dataManagement'
|
||||||
export type { TLSFingerprintProfile, CreateProfileRequest, UpdateProfileRequest } from './tlsFingerprintProfile'
|
export type { TLSFingerprintProfile, CreateProfileRequest, UpdateProfileRequest } from './tlsFingerprintProfile'
|
||||||
|
export type { ContentModerationConfig, ContentModerationLog, ModerationMode } from './riskControl'
|
||||||
|
|||||||
251
frontend/src/api/admin/riskControl.ts
Normal file
251
frontend/src/api/admin/riskControl.ts
Normal file
@ -0,0 +1,251 @@
|
|||||||
|
import { apiClient } from '../client'
|
||||||
|
|
||||||
|
export type ModerationMode = 'off' | 'observe' | 'pre_block'
|
||||||
|
|
||||||
|
export interface ContentModerationConfig {
|
||||||
|
enabled: boolean
|
||||||
|
mode: ModerationMode
|
||||||
|
base_url: string
|
||||||
|
model: string
|
||||||
|
api_key_configured: boolean
|
||||||
|
api_key_masked: string
|
||||||
|
api_key_count: number
|
||||||
|
api_key_masks: string[]
|
||||||
|
api_key_statuses: ContentModerationAPIKeyStatus[]
|
||||||
|
timeout_ms: number
|
||||||
|
sample_rate: number
|
||||||
|
all_groups: boolean
|
||||||
|
group_ids: number[]
|
||||||
|
record_non_hits: boolean
|
||||||
|
worker_count: number
|
||||||
|
queue_size: number
|
||||||
|
block_status: number
|
||||||
|
block_message: string
|
||||||
|
email_on_hit: boolean
|
||||||
|
auto_ban_enabled: boolean
|
||||||
|
ban_threshold: number
|
||||||
|
violation_window_hours: number
|
||||||
|
retry_count: number
|
||||||
|
hit_retention_days: number
|
||||||
|
non_hit_retention_days: number
|
||||||
|
pre_hash_check_enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ContentModerationAPIKeyStatusValue = 'unknown' | 'ok' | 'error' | 'frozen'
|
||||||
|
|
||||||
|
export interface ContentModerationAPIKeyStatus {
|
||||||
|
index: number
|
||||||
|
key_hash: string
|
||||||
|
masked: string
|
||||||
|
status: ContentModerationAPIKeyStatusValue
|
||||||
|
failure_count: number
|
||||||
|
success_count: number
|
||||||
|
last_error: string
|
||||||
|
last_checked_at?: string
|
||||||
|
frozen_until?: string
|
||||||
|
last_latency_ms: number
|
||||||
|
last_http_status: number
|
||||||
|
last_tested: boolean
|
||||||
|
configured: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestContentModerationAPIKeysPayload {
|
||||||
|
api_keys?: string[]
|
||||||
|
base_url?: string
|
||||||
|
model?: string
|
||||||
|
timeout_ms?: number
|
||||||
|
prompt?: string
|
||||||
|
images?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestContentModerationAPIKeysResponse {
|
||||||
|
items: ContentModerationAPIKeyStatus[]
|
||||||
|
audit_result?: ContentModerationTestAuditResult
|
||||||
|
image_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContentModerationTestAuditResult {
|
||||||
|
flagged: boolean
|
||||||
|
highest_category: string
|
||||||
|
highest_score: number
|
||||||
|
composite_score: number
|
||||||
|
category_scores: Record<string, number>
|
||||||
|
thresholds: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateContentModerationConfig {
|
||||||
|
enabled?: boolean
|
||||||
|
mode?: ModerationMode
|
||||||
|
base_url?: string
|
||||||
|
model?: string
|
||||||
|
api_key?: string
|
||||||
|
api_keys?: string[]
|
||||||
|
clear_api_key?: boolean
|
||||||
|
timeout_ms?: number
|
||||||
|
sample_rate?: number
|
||||||
|
all_groups?: boolean
|
||||||
|
group_ids?: number[]
|
||||||
|
record_non_hits?: boolean
|
||||||
|
worker_count?: number
|
||||||
|
queue_size?: number
|
||||||
|
block_status?: number
|
||||||
|
block_message?: string
|
||||||
|
email_on_hit?: boolean
|
||||||
|
auto_ban_enabled?: boolean
|
||||||
|
ban_threshold?: number
|
||||||
|
violation_window_hours?: number
|
||||||
|
retry_count?: number
|
||||||
|
hit_retention_days?: number
|
||||||
|
non_hit_retention_days?: number
|
||||||
|
pre_hash_check_enabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContentModerationRuntimeStatus {
|
||||||
|
enabled: boolean
|
||||||
|
risk_control_enabled: boolean
|
||||||
|
mode: ModerationMode
|
||||||
|
worker_count: number
|
||||||
|
max_workers: number
|
||||||
|
active_workers: number
|
||||||
|
idle_workers: number
|
||||||
|
queue_size: number
|
||||||
|
queue_length: number
|
||||||
|
queue_usage_percent: number
|
||||||
|
enqueued: number
|
||||||
|
dropped: number
|
||||||
|
processed: number
|
||||||
|
errors: number
|
||||||
|
api_key_statuses: ContentModerationAPIKeyStatus[]
|
||||||
|
flagged_hash_count: number
|
||||||
|
last_cleanup_at?: string
|
||||||
|
last_cleanup_deleted_hit: number
|
||||||
|
last_cleanup_deleted_non_hit: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContentModerationLog {
|
||||||
|
id: number
|
||||||
|
request_id: string
|
||||||
|
user_id: number | null
|
||||||
|
user_email: string
|
||||||
|
api_key_id: number | null
|
||||||
|
api_key_name: string
|
||||||
|
group_id: number | null
|
||||||
|
group_name: string
|
||||||
|
endpoint: string
|
||||||
|
provider: string
|
||||||
|
model: string
|
||||||
|
mode: string
|
||||||
|
action: string
|
||||||
|
flagged: boolean
|
||||||
|
highest_category: string
|
||||||
|
highest_score: number
|
||||||
|
category_scores: Record<string, number>
|
||||||
|
threshold_snapshot: Record<string, number>
|
||||||
|
input_excerpt: string
|
||||||
|
upstream_latency_ms: number | null
|
||||||
|
error: string
|
||||||
|
violation_count: number
|
||||||
|
auto_banned: boolean
|
||||||
|
email_sent: boolean
|
||||||
|
user_status: string
|
||||||
|
queue_delay_ms: number | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListContentModerationLogsParams {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
result?: string
|
||||||
|
group_id?: number
|
||||||
|
endpoint?: string
|
||||||
|
search?: string
|
||||||
|
from?: string
|
||||||
|
to?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContentModerationLogsResponse {
|
||||||
|
items: ContentModerationLog[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
page_size: number
|
||||||
|
pages: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContentModerationUnbanUserResponse {
|
||||||
|
user_id: number
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeleteFlaggedHashResponse {
|
||||||
|
input_hash: string
|
||||||
|
deleted: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClearFlaggedHashesResponse {
|
||||||
|
deleted: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getConfig(): Promise<ContentModerationConfig> {
|
||||||
|
const { data } = await apiClient.get<ContentModerationConfig>('/admin/risk-control/config')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateConfig(
|
||||||
|
payload: UpdateContentModerationConfig
|
||||||
|
): Promise<ContentModerationConfig> {
|
||||||
|
const { data } = await apiClient.put<ContentModerationConfig>('/admin/risk-control/config', payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStatus(): Promise<ContentModerationRuntimeStatus> {
|
||||||
|
const { data } = await apiClient.get<ContentModerationRuntimeStatus>('/admin/risk-control/status')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testAPIKeys(
|
||||||
|
payload: TestContentModerationAPIKeysPayload = {}
|
||||||
|
): Promise<TestContentModerationAPIKeysResponse> {
|
||||||
|
const { data } = await apiClient.post<TestContentModerationAPIKeysResponse>('/admin/risk-control/api-keys/test', payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listLogs(
|
||||||
|
params: ListContentModerationLogsParams = {}
|
||||||
|
): Promise<ContentModerationLogsResponse> {
|
||||||
|
const { data } = await apiClient.get<ContentModerationLogsResponse>('/admin/risk-control/logs', {
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unbanUser(userID: number): Promise<ContentModerationUnbanUserResponse> {
|
||||||
|
const { data } = await apiClient.post<ContentModerationUnbanUserResponse>(
|
||||||
|
`/admin/risk-control/users/${userID}/unban`
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteFlaggedHash(inputHash: string): Promise<DeleteFlaggedHashResponse> {
|
||||||
|
const { data } = await apiClient.delete<DeleteFlaggedHashResponse>('/admin/risk-control/hashes', {
|
||||||
|
data: { input_hash: inputHash },
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearFlaggedHashes(): Promise<ClearFlaggedHashesResponse> {
|
||||||
|
const { data } = await apiClient.delete<ClearFlaggedHashesResponse>('/admin/risk-control/hashes/all')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const riskControlAPI = {
|
||||||
|
getConfig,
|
||||||
|
updateConfig,
|
||||||
|
getStatus,
|
||||||
|
testAPIKeys,
|
||||||
|
listLogs,
|
||||||
|
unbanUser,
|
||||||
|
deleteFlaggedHash,
|
||||||
|
clearFlaggedHashes,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default riskControlAPI
|
||||||
@ -444,6 +444,7 @@ export interface SystemSettings {
|
|||||||
|
|
||||||
// Payment configuration
|
// Payment configuration
|
||||||
payment_enabled: boolean;
|
payment_enabled: boolean;
|
||||||
|
risk_control_enabled: boolean;
|
||||||
payment_min_amount: number;
|
payment_min_amount: number;
|
||||||
payment_max_amount: number;
|
payment_max_amount: number;
|
||||||
payment_daily_limit: number;
|
payment_daily_limit: number;
|
||||||
@ -613,6 +614,7 @@ export interface UpdateSettingsRequest {
|
|||||||
enable_anthropic_cache_ttl_1h_injection?: boolean;
|
enable_anthropic_cache_ttl_1h_injection?: boolean;
|
||||||
// Payment configuration
|
// Payment configuration
|
||||||
payment_enabled?: boolean;
|
payment_enabled?: boolean;
|
||||||
|
risk_control_enabled?: boolean;
|
||||||
payment_min_amount?: number;
|
payment_min_amount?: number;
|
||||||
payment_max_amount?: number;
|
payment_max_amount?: number;
|
||||||
payment_daily_limit?: number;
|
payment_daily_limit?: number;
|
||||||
|
|||||||
@ -593,6 +593,21 @@ const SignalIcon = {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ShieldIcon = {
|
||||||
|
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: 'M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z'
|
||||||
|
})
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const PriceTagIcon = {
|
const PriceTagIcon = {
|
||||||
render: () =>
|
render: () =>
|
||||||
h(
|
h(
|
||||||
@ -635,6 +650,7 @@ const flagChannelMonitor = makeSidebarFlag(FeatureFlags.channelMonitor)
|
|||||||
const flagPayment = makeSidebarFlag(FeatureFlags.payment)
|
const flagPayment = makeSidebarFlag(FeatureFlags.payment)
|
||||||
const flagAvailableChannels = makeSidebarFlag(FeatureFlags.availableChannels)
|
const flagAvailableChannels = makeSidebarFlag(FeatureFlags.availableChannels)
|
||||||
const flagAffiliate = makeSidebarFlag(FeatureFlags.affiliate)
|
const flagAffiliate = makeSidebarFlag(FeatureFlags.affiliate)
|
||||||
|
const flagRiskControl = makeSidebarFlag(FeatureFlags.riskControl)
|
||||||
const flagOpsMonitoring = () => adminSettingsStore.opsMonitoringEnabled
|
const flagOpsMonitoring = () => adminSettingsStore.opsMonitoringEnabled
|
||||||
const flagAdminPayment = () => adminSettingsStore.paymentEnabled
|
const flagAdminPayment = () => adminSettingsStore.paymentEnabled
|
||||||
|
|
||||||
@ -719,6 +735,7 @@ const adminNavItems = computed((): NavItem[] => {
|
|||||||
{ path: '/admin/accounts', label: t('nav.accounts'), icon: GlobeIcon },
|
{ path: '/admin/accounts', label: t('nav.accounts'), icon: GlobeIcon },
|
||||||
{ path: '/admin/announcements', label: t('nav.announcements'), icon: BellIcon },
|
{ path: '/admin/announcements', label: t('nav.announcements'), icon: BellIcon },
|
||||||
{ path: '/admin/proxies', label: t('nav.proxies'), icon: ServerIcon },
|
{ path: '/admin/proxies', label: t('nav.proxies'), icon: ServerIcon },
|
||||||
|
{ path: '/admin/risk-control', label: t('nav.riskControl'), icon: ShieldIcon, hideInSimpleMode: true, featureFlag: flagRiskControl },
|
||||||
{ path: '/admin/redeem', label: t('nav.redeemCodes'), icon: TicketIcon, hideInSimpleMode: true },
|
{ 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/promo-codes', label: t('nav.promoCodes'), icon: GiftIcon, hideInSimpleMode: true },
|
||||||
{
|
{
|
||||||
|
|||||||
@ -382,6 +382,7 @@ export default {
|
|||||||
channelPricing: 'Channel Pricing',
|
channelPricing: 'Channel Pricing',
|
||||||
channelMonitor: 'Channel Monitor',
|
channelMonitor: 'Channel Monitor',
|
||||||
channelStatus: 'Channel Status',
|
channelStatus: 'Channel Status',
|
||||||
|
riskControl: 'Risk Control',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Auth
|
// Auth
|
||||||
@ -410,6 +411,9 @@ export default {
|
|||||||
passwordRequired: 'Password is required',
|
passwordRequired: 'Password is required',
|
||||||
passwordMinLength: 'Password must be at least 6 characters',
|
passwordMinLength: 'Password must be at least 6 characters',
|
||||||
loginFailed: 'Login failed. Please check your credentials and try again.',
|
loginFailed: 'Login failed. Please check your credentials and try again.',
|
||||||
|
errors: {
|
||||||
|
USER_NOT_ACTIVE: 'Account has been disabled.',
|
||||||
|
},
|
||||||
registrationFailed: 'Registration failed. Please try again.',
|
registrationFailed: 'Registration failed. Please try again.',
|
||||||
emailSuffixNotAllowed: 'This email domain is not allowed for registration.',
|
emailSuffixNotAllowed: 'This email domain is not allowed for registration.',
|
||||||
emailSuffixNotAllowedWithAllowed:
|
emailSuffixNotAllowedWithAllowed:
|
||||||
@ -2305,6 +2309,200 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
riskControl: {
|
||||||
|
title: 'Risk Control',
|
||||||
|
description: 'Configure content moderation and review audit records',
|
||||||
|
loadFailed: 'Failed to load risk control',
|
||||||
|
saveFailed: 'Failed to save content moderation config',
|
||||||
|
logsFailed: 'Failed to load audit records',
|
||||||
|
saved: 'Content moderation config saved',
|
||||||
|
refresh: 'Refresh',
|
||||||
|
config: 'Content Moderation Config',
|
||||||
|
configHint: 'Use OpenAI Moderations to score request content and handle threshold hits by mode.',
|
||||||
|
openSettings: 'Moderation Settings',
|
||||||
|
settingsTitle: 'Content Moderation Settings',
|
||||||
|
refreshStatus: 'Refresh Status',
|
||||||
|
records: 'Audit Records',
|
||||||
|
recordsHint: 'Shows hits, blocks, errors, and sampled records.',
|
||||||
|
saveConfig: 'Save Moderation Config',
|
||||||
|
statusFailed: 'Failed to load runtime status',
|
||||||
|
enabled: 'Enable Content Moderation',
|
||||||
|
enabledHint: 'When off, gateway requests are not moderated even if the menu is enabled.',
|
||||||
|
mode: 'Global Mode',
|
||||||
|
modePreBlock: 'Pre-Block',
|
||||||
|
modePreBlockDesc: 'Synchronously reviews the latest user input before every request and rejects hits immediately.',
|
||||||
|
modeObserve: 'Observe Only',
|
||||||
|
modeObserveDesc: 'Requests pass through while the latest user input is queued for async review; hits are recorded, notified, and counted.',
|
||||||
|
modeOff: 'Off',
|
||||||
|
modeOffDesc: 'Content moderation is disabled and no audit records are written.',
|
||||||
|
baseUrl: 'OpenAI Base URL',
|
||||||
|
model: 'Model',
|
||||||
|
apiKey: 'OpenAI API Key',
|
||||||
|
apiKeys: 'OpenAI API Keys',
|
||||||
|
apiKeyCount: '{count} keys',
|
||||||
|
apiKeyPlaceholder: 'Enter API Key',
|
||||||
|
apiKeysPlaceholder: 'One API Key per line',
|
||||||
|
apiKeysPlaceholderKeep: 'Leave empty to keep stored keys; enter values to replace them',
|
||||||
|
apiKeysHint: '{count} keys are currently stored. Values entered here replace stored keys; leave empty to keep them.',
|
||||||
|
apiKeyPlaceholderKeep: 'Leave empty to keep current key',
|
||||||
|
apiKeyWillClear: 'Configured key will be cleared on save',
|
||||||
|
apiKeyConfigured: 'Configured',
|
||||||
|
apiKeyTemporary: 'Pending',
|
||||||
|
inputApiKeyCount: '{count} keys in input',
|
||||||
|
storedApiKeyCount: '{count} stored keys',
|
||||||
|
testInputApiKeys: 'Test input keys',
|
||||||
|
testStoredApiKeys: 'Test stored keys',
|
||||||
|
testContentWithStoredApiKey: 'Test content with stored key',
|
||||||
|
testingApiKeys: 'Testing',
|
||||||
|
apiKeyTestNoInput: 'Enter OpenAI API Keys to test first',
|
||||||
|
apiKeyTestDone: 'Key test completed for {count} keys',
|
||||||
|
apiKeyTestFailed: 'Failed to test OpenAI API Keys',
|
||||||
|
apiKeyHealth: 'Key Availability',
|
||||||
|
apiKeyFreezeRule: 'Three consecutive failures freeze a key for 1 minute; moderation rotation skips frozen keys.',
|
||||||
|
apiKeyRows: '{count} keys',
|
||||||
|
apiKeyHealthEmpty: 'No key status yet',
|
||||||
|
apiKeyHealthEmptyHint: 'Save keys or test input keys to see availability.',
|
||||||
|
apiKeyStatusOk: 'Available',
|
||||||
|
apiKeyStatusError: 'Error',
|
||||||
|
apiKeyStatusFrozen: 'Frozen',
|
||||||
|
apiKeyStatusUnknown: 'Untested',
|
||||||
|
apiKeyFailureCount: '{count} failures',
|
||||||
|
apiKeyLatency: '{ms} ms',
|
||||||
|
apiKeyHTTPStatus: 'HTTP {status}',
|
||||||
|
apiKeyFrozenUntil: 'Frozen until {time}',
|
||||||
|
apiKeyLastChecked: 'Checked at {time}',
|
||||||
|
apiKeyNotTested: 'Not tested',
|
||||||
|
auditTestInput: 'Audit Test Input',
|
||||||
|
auditTestInputHint: 'Enter a prompt and upload or paste images; images are sent as base64 and are not stored.',
|
||||||
|
auditTestPromptPlaceholder: 'Enter a user prompt to test; leave empty to only test key availability.',
|
||||||
|
auditTestImages: 'Test Images',
|
||||||
|
auditTestImagesHint: 'Upload, drag, or paste images. Up to 4 images, 8MB each.',
|
||||||
|
addAuditTestImage: 'Add image',
|
||||||
|
clearAuditTest: 'Clear test',
|
||||||
|
auditTestImageLimit: 'You can add up to {count} test images',
|
||||||
|
auditTestImageTooLarge: 'Each test image must be 8MB or smaller',
|
||||||
|
auditTestImageReadFailed: 'Failed to read test image',
|
||||||
|
auditTestResult: 'Audit Test Result',
|
||||||
|
auditTestHighest: 'Top category {category}, score {score}',
|
||||||
|
auditTestComposite: 'Composite score',
|
||||||
|
auditTestFlagged: 'Threshold hit',
|
||||||
|
auditTestPassed: 'Pass',
|
||||||
|
notConfigured: 'Not configured',
|
||||||
|
clearApiKey: 'Clear stored key',
|
||||||
|
keepApiKey: 'Keep stored key',
|
||||||
|
timeoutMs: 'HTTP Timeout (ms)',
|
||||||
|
retryCount: 'Retry Count',
|
||||||
|
sampleRate: 'Sample Rate',
|
||||||
|
recordNonHits: 'Record Non-Hits',
|
||||||
|
recordNonHitsHint: 'When enabled, sampled non-hit request summaries are redacted before storage.',
|
||||||
|
preHashCheck: 'Enable Pre-Hash Check',
|
||||||
|
preHashCheckHint: 'Hashes from async hits are blocked before moderation; this does not send email or increment ban counters.',
|
||||||
|
flaggedHashCount: 'Current hash collection size: {count}',
|
||||||
|
flaggedHashHint: 'Hashes are stored permanently in Redis; paste a full 64-character hash to remove a false block, or clear all stored hashes.',
|
||||||
|
flaggedHashPlaceholder: 'Paste full 64-character input hash',
|
||||||
|
deleteFlaggedHash: 'Delete hash',
|
||||||
|
clearFlaggedHashes: 'Clear all',
|
||||||
|
clearFlaggedHashesConfirm: 'Clear all risk input hashes? This does not delete audit records, but removes all historical hash blocks.',
|
||||||
|
flaggedHashDeleted: 'Risk hash deleted',
|
||||||
|
flaggedHashNotFound: 'Risk hash not found',
|
||||||
|
flaggedHashDeleteFailed: 'Failed to delete risk hash',
|
||||||
|
flaggedHashesCleared: 'Cleared {count} risk hashes',
|
||||||
|
flaggedHashesClearFailed: 'Failed to clear risk hashes',
|
||||||
|
workerCount: 'Worker Count',
|
||||||
|
queueSize: 'Async Queue Size',
|
||||||
|
blockStatus: 'Block HTTP Status',
|
||||||
|
blockMessage: 'Custom Block Message',
|
||||||
|
emailOnHit: 'Email on Hit',
|
||||||
|
emailOnHitHint: 'When enabled, send a risk-control email on every hit; auto-ban notices are always sent.',
|
||||||
|
autoBan: 'Auto Ban User',
|
||||||
|
autoBanHint: 'Disable the user, invalidate auth cache, and send a ban notice after the hit threshold is reached.',
|
||||||
|
banThreshold: 'Ban Threshold',
|
||||||
|
violationWindowHours: 'Count Window (hours)',
|
||||||
|
hitRetentionDays: 'Hit Record Retention (days)',
|
||||||
|
nonHitRetentionDays: 'Non-Hit Record Retention (days, max 3)',
|
||||||
|
violationCount: '{count} hits',
|
||||||
|
emailSent: 'Email sent',
|
||||||
|
emailNotSent: 'No email',
|
||||||
|
autoBanned: 'Banned',
|
||||||
|
unbanUser: 'Unban',
|
||||||
|
unbanSuccess: 'User has been unbanned',
|
||||||
|
unbanFailed: 'Failed to unban user',
|
||||||
|
inputDetailTitle: 'Input Summary Detail',
|
||||||
|
inputDetailContent: 'Full Content',
|
||||||
|
queueDelay: 'Queued {ms} ms',
|
||||||
|
allGroups: 'All Groups',
|
||||||
|
allGroupsHint: 'Auditing all groups',
|
||||||
|
selectedGroupsHint: 'Auditing selected groups',
|
||||||
|
groupScope: 'Audit Groups',
|
||||||
|
groupScopeHint: 'Switch on for all groups, or turn off to choose specific groups.',
|
||||||
|
selectedGroups: 'Selected Groups',
|
||||||
|
searchGroups: 'Search group name or platform',
|
||||||
|
noGroups: 'No groups available',
|
||||||
|
emptyLogs: 'No audit records',
|
||||||
|
workerStatus: 'Worker Runtime',
|
||||||
|
workerStatusHint: 'Queue and worker pool status for asynchronous observation tasks.',
|
||||||
|
workerPool: 'Worker Pool',
|
||||||
|
workerPoolMeta: '{active} processing, {idle} idle and ready, {total} total',
|
||||||
|
queueUsage: 'Queue Usage',
|
||||||
|
activeWorkers: 'Processing',
|
||||||
|
idleWorkers: 'Idle Ready',
|
||||||
|
workerActive: 'Processing an asynchronous audit task',
|
||||||
|
workerIdle: 'Started, idle and ready',
|
||||||
|
workerDisabled: 'Risk control or content audit is disabled',
|
||||||
|
processed: 'Processed',
|
||||||
|
droppedErrors: 'Dropped / Errors',
|
||||||
|
autoRefresh: 'Auto refresh every 15s',
|
||||||
|
lastCleanup: 'Last cleanup: {time}',
|
||||||
|
cleanupStats: 'Last cleanup deleted {hit} hits and {nonHit} non-hits',
|
||||||
|
riskSwitchOff: 'System switch off',
|
||||||
|
tabs: {
|
||||||
|
basic: 'Basic',
|
||||||
|
scope: 'Scope',
|
||||||
|
runtime: 'Runtime',
|
||||||
|
response: 'Hit Notice',
|
||||||
|
retention: 'Retention',
|
||||||
|
},
|
||||||
|
overview: {
|
||||||
|
status: 'Status',
|
||||||
|
enabled: 'Enabled',
|
||||||
|
disabled: 'Disabled',
|
||||||
|
apiKey: 'API Key',
|
||||||
|
groupScope: 'Scope',
|
||||||
|
logs: 'Audit Records',
|
||||||
|
currentFilter: 'Current filter',
|
||||||
|
},
|
||||||
|
filters: {
|
||||||
|
search: 'Search user/key/summary',
|
||||||
|
from: 'From',
|
||||||
|
to: 'To',
|
||||||
|
allGroups: 'All Groups',
|
||||||
|
allEndpoints: 'All Endpoints',
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
time: 'Time',
|
||||||
|
group: 'Group',
|
||||||
|
user: 'User',
|
||||||
|
apiKey: 'API Key',
|
||||||
|
endpoint: 'Endpoint',
|
||||||
|
result: 'Result',
|
||||||
|
highest: 'Highest',
|
||||||
|
actionMeta: 'Action',
|
||||||
|
latency: 'Latency',
|
||||||
|
input: 'Input Summary',
|
||||||
|
},
|
||||||
|
result: {
|
||||||
|
all: 'All Results',
|
||||||
|
hit: 'Hit',
|
||||||
|
blocked: 'Blocked',
|
||||||
|
pass: 'Pass',
|
||||||
|
error: 'Error',
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
block: 'Blocked',
|
||||||
|
error: 'Error',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// Channel Monitor
|
// Channel Monitor
|
||||||
channelMonitor: {
|
channelMonitor: {
|
||||||
title: 'Channel Monitor',
|
title: 'Channel Monitor',
|
||||||
@ -4862,6 +5060,13 @@ export default {
|
|||||||
enabled: 'Enable Available Channels',
|
enabled: 'Enable Available Channels',
|
||||||
enabledHint: 'When off, the sidebar entry is hidden and the endpoint returns an empty list.',
|
enabledHint: 'When off, the sidebar entry is hidden and the endpoint returns an empty list.',
|
||||||
},
|
},
|
||||||
|
riskControl: {
|
||||||
|
title: 'Risk Control',
|
||||||
|
description: 'Enable the content moderation menu and gateway audit entry point. Disabled by default.',
|
||||||
|
configureLink: 'Configure content moderation in Risk Control',
|
||||||
|
enabled: 'Enable Risk Control',
|
||||||
|
enabledHint: 'When off, the admin sidebar entry is hidden and gateway moderation is skipped.',
|
||||||
|
},
|
||||||
affiliate: {
|
affiliate: {
|
||||||
title: 'Affiliate (Invite Rebate)',
|
title: 'Affiliate (Invite Rebate)',
|
||||||
description: 'Existing users invite new ones; the inviter earns a percentage rebate on the invitee’s recharges. Disabled by default.',
|
description: 'Existing users invite new ones; the inviter earns a percentage rebate on the invitee’s recharges. Disabled by default.',
|
||||||
|
|||||||
@ -382,6 +382,7 @@ export default {
|
|||||||
channelPricing: '渠道定价',
|
channelPricing: '渠道定价',
|
||||||
channelMonitor: '渠道监控',
|
channelMonitor: '渠道监控',
|
||||||
channelStatus: '渠道状态',
|
channelStatus: '渠道状态',
|
||||||
|
riskControl: '风控中心',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Auth
|
// Auth
|
||||||
@ -410,6 +411,9 @@ export default {
|
|||||||
passwordRequired: '请输入密码',
|
passwordRequired: '请输入密码',
|
||||||
passwordMinLength: '密码至少需要 6 个字符',
|
passwordMinLength: '密码至少需要 6 个字符',
|
||||||
loginFailed: '登录失败,请检查您的凭据后重试。',
|
loginFailed: '登录失败,请检查您的凭据后重试。',
|
||||||
|
errors: {
|
||||||
|
USER_NOT_ACTIVE: '账号已被禁用',
|
||||||
|
},
|
||||||
registrationFailed: '注册失败,请重试。',
|
registrationFailed: '注册失败,请重试。',
|
||||||
emailSuffixNotAllowed: '该邮箱域名不在允许注册范围内。',
|
emailSuffixNotAllowed: '该邮箱域名不在允许注册范围内。',
|
||||||
emailSuffixNotAllowedWithAllowed: '该邮箱域名不被允许。可用域名:{suffixes}',
|
emailSuffixNotAllowedWithAllowed: '该邮箱域名不被允许。可用域名:{suffixes}',
|
||||||
@ -2382,6 +2386,200 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
riskControl: {
|
||||||
|
title: '风控中心',
|
||||||
|
description: '配置内容审计策略并查看审核记录',
|
||||||
|
loadFailed: '加载风控中心失败',
|
||||||
|
saveFailed: '保存内容审计配置失败',
|
||||||
|
logsFailed: '加载审核记录失败',
|
||||||
|
saved: '内容审计配置已保存',
|
||||||
|
refresh: '刷新',
|
||||||
|
config: '内容审计配置',
|
||||||
|
configHint: '调用 OpenAI Moderations 进行请求内容评分,命中阈值后按模式处理。',
|
||||||
|
openSettings: '内容审计设置',
|
||||||
|
settingsTitle: '内容审计设置',
|
||||||
|
refreshStatus: '刷新状态',
|
||||||
|
records: '审核记录',
|
||||||
|
recordsHint: '展示命中、拦截、异常和已采样记录。',
|
||||||
|
saveConfig: '保存内容审计配置',
|
||||||
|
statusFailed: '加载运行状态失败',
|
||||||
|
enabled: '开启内容审计',
|
||||||
|
enabledHint: '关闭后即使风控中心菜单启用,也不会审核网关请求。',
|
||||||
|
mode: '全局模式',
|
||||||
|
modePreBlock: '前置拦截',
|
||||||
|
modePreBlockDesc: '每次请求先同步审核最新用户输入,命中后立即拒绝请求。',
|
||||||
|
modeObserve: '仅观察',
|
||||||
|
modeObserveDesc: '请求直接放行,最新用户输入进入异步审核队列;命中后只记录、通知和按规则累计。',
|
||||||
|
modeOff: '关闭',
|
||||||
|
modeOffDesc: '不执行内容审计,也不会写入审核记录。',
|
||||||
|
baseUrl: 'OpenAI Base URL',
|
||||||
|
model: '模型名',
|
||||||
|
apiKey: 'OpenAI API Key',
|
||||||
|
apiKeys: 'OpenAI API Keys',
|
||||||
|
apiKeyCount: '{count} 个 Key',
|
||||||
|
apiKeyPlaceholder: '请输入 API Key',
|
||||||
|
apiKeysPlaceholder: '每行一个 API Key',
|
||||||
|
apiKeysPlaceholderKeep: '留空保持已保存的 Key;填写后将替换为这些 Key',
|
||||||
|
apiKeysHint: '当前已保存 {count} 个 Key;填写文本框会替换已保存 Key,留空则保持不变。',
|
||||||
|
apiKeyPlaceholderKeep: '留空保持不变',
|
||||||
|
apiKeyWillClear: '保存后清除已配置 Key',
|
||||||
|
apiKeyConfigured: '已配置',
|
||||||
|
apiKeyTemporary: '待保存',
|
||||||
|
inputApiKeyCount: '输入区 {count} 个 Key',
|
||||||
|
storedApiKeyCount: '已保存 {count} 个 Key',
|
||||||
|
testInputApiKeys: '测试输入区 Key',
|
||||||
|
testStoredApiKeys: '测试已保存 Key',
|
||||||
|
testContentWithStoredApiKey: '用已保存 Key 试跑内容',
|
||||||
|
testingApiKeys: '测试中',
|
||||||
|
apiKeyTestNoInput: '请先输入需要测试的 OpenAI API Key',
|
||||||
|
apiKeyTestDone: 'Key 测试完成,共 {count} 个',
|
||||||
|
apiKeyTestFailed: '测试 OpenAI API Key 失败',
|
||||||
|
apiKeyHealth: 'Key 可用状态',
|
||||||
|
apiKeyFreezeRule: '连续 3 次失败会冻结 1 分钟,审计轮询会自动跳过。',
|
||||||
|
apiKeyRows: '{count} 个',
|
||||||
|
apiKeyHealthEmpty: '暂无 Key 状态',
|
||||||
|
apiKeyHealthEmptyHint: '保存 Key 或测试输入区 Key 后会显示可用性。',
|
||||||
|
apiKeyStatusOk: '可用',
|
||||||
|
apiKeyStatusError: '异常',
|
||||||
|
apiKeyStatusFrozen: '冻结',
|
||||||
|
apiKeyStatusUnknown: '未测试',
|
||||||
|
apiKeyFailureCount: '失败 {count} 次',
|
||||||
|
apiKeyLatency: '{ms} ms',
|
||||||
|
apiKeyHTTPStatus: 'HTTP {status}',
|
||||||
|
apiKeyFrozenUntil: '冻结至 {time}',
|
||||||
|
apiKeyLastChecked: '检查于 {time}',
|
||||||
|
apiKeyNotTested: '尚未测试',
|
||||||
|
auditTestInput: '审计试跑输入',
|
||||||
|
auditTestInputHint: '可填写提示词并上传或粘贴图片;图片以 base64 发送,不会保存文件。',
|
||||||
|
auditTestPromptPlaceholder: '输入要测试的用户提示词;留空时仅测试 Key 可用性。',
|
||||||
|
auditTestImages: '测试图片',
|
||||||
|
auditTestImagesHint: '支持上传、拖拽或粘贴图片,最多 4 张,每张不超过 8MB。',
|
||||||
|
addAuditTestImage: '添加图片',
|
||||||
|
clearAuditTest: '清空试跑',
|
||||||
|
auditTestImageLimit: '最多只能添加 {count} 张测试图片',
|
||||||
|
auditTestImageTooLarge: '单张测试图片不能超过 8MB',
|
||||||
|
auditTestImageReadFailed: '读取测试图片失败',
|
||||||
|
auditTestResult: '审计试跑结果',
|
||||||
|
auditTestHighest: '最高分类 {category},分数 {score}',
|
||||||
|
auditTestComposite: '综合评分',
|
||||||
|
auditTestFlagged: '命中阈值',
|
||||||
|
auditTestPassed: '未命中',
|
||||||
|
notConfigured: '未配置',
|
||||||
|
clearApiKey: '清除已保存 Key',
|
||||||
|
keepApiKey: '保留已保存 Key',
|
||||||
|
timeoutMs: 'HTTP 超时 (ms)',
|
||||||
|
retryCount: '失败重试次数',
|
||||||
|
sampleRate: '采样率',
|
||||||
|
recordNonHits: '记录未命中输入',
|
||||||
|
recordNonHitsHint: '开启后会记录抽样但未命中的请求摘要,摘要会先脱敏再入库。',
|
||||||
|
preHashCheck: '启用前置哈希比对',
|
||||||
|
preHashCheckHint: '异步审核命中过的输入哈希会被前置拦截;该拦截不发送邮件,也不累计封禁次数。',
|
||||||
|
flaggedHashCount: '当前哈希集合数量:{count} 个',
|
||||||
|
flaggedHashHint: '哈希永久保存在 Redis 集合中;可粘贴完整 64 位哈希删除误拦截项,或一键清空全部风险哈希。',
|
||||||
|
flaggedHashPlaceholder: '粘贴完整 64 位输入哈希',
|
||||||
|
deleteFlaggedHash: '删除指定哈希',
|
||||||
|
clearFlaggedHashes: '一键清空',
|
||||||
|
clearFlaggedHashesConfirm: '确定要清空全部风险输入哈希吗?此操作不会删除审核记录,但会取消所有历史哈希拦截。',
|
||||||
|
flaggedHashDeleted: '风险哈希已删除',
|
||||||
|
flaggedHashNotFound: '该风险哈希不存在',
|
||||||
|
flaggedHashDeleteFailed: '删除风险哈希失败',
|
||||||
|
flaggedHashesCleared: '已清空 {count} 个风险哈希',
|
||||||
|
flaggedHashesClearFailed: '清空风险哈希失败',
|
||||||
|
workerCount: 'Worker 数',
|
||||||
|
queueSize: '异步队列大小',
|
||||||
|
blockStatus: '拦截 HTTP 状态码',
|
||||||
|
blockMessage: '自定义拦截提示',
|
||||||
|
emailOnHit: '命中后发送邮件',
|
||||||
|
emailOnHitHint: '开启后每次达到阈值都会向用户发送风控提醒邮件;自动封禁通知始终发送。',
|
||||||
|
autoBan: '自动封禁用户',
|
||||||
|
autoBanHint: '命中次数达到阈值后将禁用用户账号、刷新认证缓存并发送封禁通知邮件。',
|
||||||
|
banThreshold: '封禁触发次数',
|
||||||
|
violationWindowHours: '累计窗口(小时)',
|
||||||
|
hitRetentionDays: '命中记录保留(天)',
|
||||||
|
nonHitRetentionDays: '未命中记录保留(天,最多 3 天)',
|
||||||
|
violationCount: '{count} 次',
|
||||||
|
emailSent: '已发邮件',
|
||||||
|
emailNotSent: '未发邮件',
|
||||||
|
autoBanned: '已封禁',
|
||||||
|
unbanUser: '解封',
|
||||||
|
unbanSuccess: '用户已解封',
|
||||||
|
unbanFailed: '解封用户失败',
|
||||||
|
inputDetailTitle: '输入摘要详情',
|
||||||
|
inputDetailContent: '完整内容',
|
||||||
|
queueDelay: '排队 {ms} ms',
|
||||||
|
allGroups: '全部分组',
|
||||||
|
allGroupsHint: '当前审计全部分组',
|
||||||
|
selectedGroupsHint: '当前审计指定分组',
|
||||||
|
groupScope: '审计分组',
|
||||||
|
groupScopeHint: '开启右侧开关表示全部分组,关闭后选择指定分组。',
|
||||||
|
selectedGroups: '指定分组',
|
||||||
|
searchGroups: '搜索分组名称或平台',
|
||||||
|
noGroups: '暂无可用分组',
|
||||||
|
emptyLogs: '暂无审核记录',
|
||||||
|
workerStatus: 'Worker 运行状态',
|
||||||
|
workerStatusHint: '异步观察任务的队列和 worker 池状态。',
|
||||||
|
workerPool: 'Worker 池',
|
||||||
|
workerPoolMeta: '{active} 个处理中,{idle} 个空闲可用,共 {total} 个',
|
||||||
|
queueUsage: '队列占用',
|
||||||
|
activeWorkers: '处理中',
|
||||||
|
idleWorkers: '空闲可用',
|
||||||
|
workerActive: '正在处理异步审计任务',
|
||||||
|
workerIdle: '已启动,当前空闲可用',
|
||||||
|
workerDisabled: '风控或内容审计未启用',
|
||||||
|
processed: '已处理',
|
||||||
|
droppedErrors: '丢弃/异常',
|
||||||
|
autoRefresh: '每 15 秒自动刷新',
|
||||||
|
lastCleanup: '上次清理:{time}',
|
||||||
|
cleanupStats: '上次清理删除命中 {hit} 条,未命中 {nonHit} 条',
|
||||||
|
riskSwitchOff: '系统开关关闭',
|
||||||
|
tabs: {
|
||||||
|
basic: '基础',
|
||||||
|
scope: '审计范围',
|
||||||
|
runtime: '运行队列',
|
||||||
|
response: '命中通知',
|
||||||
|
retention: '日志保留',
|
||||||
|
},
|
||||||
|
overview: {
|
||||||
|
status: '运行状态',
|
||||||
|
enabled: '已启用',
|
||||||
|
disabled: '未启用',
|
||||||
|
apiKey: 'API Key',
|
||||||
|
groupScope: '审计范围',
|
||||||
|
logs: '审核记录',
|
||||||
|
currentFilter: '当前筛选结果',
|
||||||
|
},
|
||||||
|
filters: {
|
||||||
|
search: '按用户/Key/摘要搜索',
|
||||||
|
from: '开始时间',
|
||||||
|
to: '结束时间',
|
||||||
|
allGroups: '全部分组',
|
||||||
|
allEndpoints: '全部端点',
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
time: '时间',
|
||||||
|
group: '分组',
|
||||||
|
user: '用户',
|
||||||
|
apiKey: 'API Key',
|
||||||
|
endpoint: '端点',
|
||||||
|
result: '结果',
|
||||||
|
highest: '最高分',
|
||||||
|
actionMeta: '处置',
|
||||||
|
latency: '上游耗时',
|
||||||
|
input: '输入摘要',
|
||||||
|
},
|
||||||
|
result: {
|
||||||
|
all: '全部结果',
|
||||||
|
hit: '命中',
|
||||||
|
blocked: '已拦截',
|
||||||
|
pass: '未命中',
|
||||||
|
error: '异常',
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
block: '拦截',
|
||||||
|
error: '异常',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// Channel Monitor
|
// Channel Monitor
|
||||||
channelMonitor: {
|
channelMonitor: {
|
||||||
title: '渠道监控',
|
title: '渠道监控',
|
||||||
@ -5025,6 +5223,13 @@ export default {
|
|||||||
enabled: '启用可用渠道',
|
enabled: '启用可用渠道',
|
||||||
enabledHint: '关闭后用户端侧边栏入口隐藏,接口返回空数组。',
|
enabledHint: '关闭后用户端侧边栏入口隐藏,接口返回空数组。',
|
||||||
},
|
},
|
||||||
|
riskControl: {
|
||||||
|
title: '风控中心',
|
||||||
|
description: '启用内容审计菜单和全端点请求审核入口。默认关闭。',
|
||||||
|
configureLink: '前往 风控中心 配置内容审计',
|
||||||
|
enabled: '启用风控中心',
|
||||||
|
enabledHint: '关闭后管理员侧边栏入口隐藏,网关内容审计不会执行。',
|
||||||
|
},
|
||||||
affiliate: {
|
affiliate: {
|
||||||
title: '邀请返利',
|
title: '邀请返利',
|
||||||
description: '老用户邀请新用户注册,新用户充值后老用户按比例获得返利额度。默认关闭。',
|
description: '老用户邀请新用户注册,新用户充值后老用户按比例获得返利额度。默认关闭。',
|
||||||
|
|||||||
@ -505,6 +505,19 @@ const routes: RouteRecordRaw[] = [
|
|||||||
descriptionKey: 'admin.settings.description'
|
descriptionKey: 'admin.settings.description'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/admin/risk-control',
|
||||||
|
name: 'AdminRiskControl',
|
||||||
|
component: () => import('@/views/admin/RiskControlView.vue'),
|
||||||
|
meta: {
|
||||||
|
requiresAuth: true,
|
||||||
|
requiresAdmin: true,
|
||||||
|
title: 'Risk Control',
|
||||||
|
titleKey: 'admin.riskControl.title',
|
||||||
|
descriptionKey: 'admin.riskControl.description',
|
||||||
|
requiresRiskControl: true
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/admin/usage',
|
path: '/admin/usage',
|
||||||
name: 'AdminUsage',
|
name: 'AdminUsage',
|
||||||
@ -747,6 +760,14 @@ router.beforeEach((to, _from, next) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (to.meta.requiresRiskControl) {
|
||||||
|
const riskControlEnabled = appStore.cachedPublicSettings?.risk_control_enabled === true
|
||||||
|
if (!riskControlEnabled) {
|
||||||
|
next(authStore.isAdmin ? '/admin/settings' : '/dashboard')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 简易模式下限制访问某些页面
|
// 简易模式下限制访问某些页面
|
||||||
if (authStore.isSimpleMode) {
|
if (authStore.isSimpleMode) {
|
||||||
const restrictedPaths = [
|
const restrictedPaths = [
|
||||||
|
|||||||
6
frontend/src/router/meta.d.ts
vendored
6
frontend/src/router/meta.d.ts
vendored
@ -49,6 +49,12 @@ declare module 'vue-router' {
|
|||||||
*/
|
*/
|
||||||
requiresPayment?: boolean
|
requiresPayment?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否要求风控中心功能开关已启用
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
requiresRiskControl?: boolean
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* i18n key for the page title
|
* i18n key for the page title
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -355,6 +355,7 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
channel_monitor_enabled: true,
|
channel_monitor_enabled: true,
|
||||||
channel_monitor_default_interval_seconds: 60,
|
channel_monitor_default_interval_seconds: 60,
|
||||||
available_channels_enabled: false,
|
available_channels_enabled: false,
|
||||||
|
risk_control_enabled: false,
|
||||||
affiliate_enabled: false,
|
affiliate_enabled: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -197,6 +197,7 @@ export interface PublicSettings {
|
|||||||
home_content: string
|
home_content: string
|
||||||
hide_ccs_import_button: boolean
|
hide_ccs_import_button: boolean
|
||||||
payment_enabled: boolean
|
payment_enabled: boolean
|
||||||
|
risk_control_enabled: boolean
|
||||||
table_default_page_size: number
|
table_default_page_size: number
|
||||||
table_page_size_options: number[]
|
table_page_size_options: number[]
|
||||||
custom_menu_items: CustomMenuItem[]
|
custom_menu_items: CustomMenuItem[]
|
||||||
|
|||||||
@ -109,6 +109,11 @@ export const FeatureFlags = {
|
|||||||
mode: 'opt-out',
|
mode: 'opt-out',
|
||||||
label: 'Payment',
|
label: 'Payment',
|
||||||
}),
|
}),
|
||||||
|
riskControl: defineFlag({
|
||||||
|
key: 'risk_control_enabled',
|
||||||
|
mode: 'opt-in',
|
||||||
|
label: 'Risk Control',
|
||||||
|
}),
|
||||||
affiliate: defineFlag({
|
affiliate: defineFlag({
|
||||||
key: 'affiliate_enabled',
|
key: 'affiliate_enabled',
|
||||||
mode: 'opt-in',
|
mode: 'opt-in',
|
||||||
|
|||||||
1574
frontend/src/views/admin/RiskControlView.vue
Normal file
1574
frontend/src/views/admin/RiskControlView.vue
Normal file
File diff suppressed because it is too large
Load Diff
@ -4264,6 +4264,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{{ t('admin.settings.features.riskControl.title') }}
|
||||||
|
</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t('admin.settings.features.riskControl.description') }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1.5 text-xs">
|
||||||
|
<router-link
|
||||||
|
to="/admin/risk-control"
|
||||||
|
class="inline-flex items-center gap-1 text-primary-600 hover:underline dark:text-primary-400"
|
||||||
|
>
|
||||||
|
{{ t('admin.settings.features.riskControl.configureLink') }}
|
||||||
|
<span aria-hidden="true">→</span>
|
||||||
|
</router-link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-5 p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ t('admin.settings.features.riskControl.enabled') }}
|
||||||
|
</label>
|
||||||
|
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t('admin.settings.features.riskControl.enabledHint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Toggle v-model="form.risk_control_enabled" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Affiliate (邀请返利) feature card -->
|
<!-- Affiliate (邀请返利) feature card -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||||
@ -5828,6 +5861,7 @@ const form = reactive<SettingsForm>({
|
|||||||
backend_mode_enabled: false,
|
backend_mode_enabled: false,
|
||||||
hide_ccs_import_button: false,
|
hide_ccs_import_button: false,
|
||||||
payment_enabled: false,
|
payment_enabled: false,
|
||||||
|
risk_control_enabled: false,
|
||||||
payment_min_amount: 1,
|
payment_min_amount: 1,
|
||||||
payment_max_amount: 10000,
|
payment_max_amount: 10000,
|
||||||
payment_daily_limit: 50000,
|
payment_daily_limit: 50000,
|
||||||
@ -6863,6 +6897,7 @@ async function saveSettings() {
|
|||||||
form.enable_anthropic_cache_ttl_1h_injection,
|
form.enable_anthropic_cache_ttl_1h_injection,
|
||||||
// Payment configuration
|
// Payment configuration
|
||||||
payment_enabled: form.payment_enabled,
|
payment_enabled: form.payment_enabled,
|
||||||
|
risk_control_enabled: form.risk_control_enabled,
|
||||||
payment_min_amount: Number(form.payment_min_amount) || 0,
|
payment_min_amount: Number(form.payment_min_amount) || 0,
|
||||||
payment_max_amount: Number(form.payment_max_amount) || 0,
|
payment_max_amount: Number(form.payment_max_amount) || 0,
|
||||||
payment_daily_limit: Number(form.payment_daily_limit) || 0,
|
payment_daily_limit: Number(form.payment_daily_limit) || 0,
|
||||||
|
|||||||
@ -186,6 +186,7 @@ import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
|||||||
import { useAuthStore, useAppStore } from '@/stores'
|
import { useAuthStore, useAppStore } from '@/stores'
|
||||||
import { getPublicSettings, isTotp2FARequired, isWeChatWebOAuthEnabled } from '@/api/auth'
|
import { getPublicSettings, isTotp2FARequired, isWeChatWebOAuthEnabled } from '@/api/auth'
|
||||||
import type { TotpLoginResponse } from '@/types'
|
import type { TotpLoginResponse } from '@/types'
|
||||||
|
import { extractI18nErrorMessage } from '@/utils/apiError'
|
||||||
import { clearAllAffiliateReferralCodes } from '@/utils/oauthAffiliate'
|
import { clearAllAffiliateReferralCodes } from '@/utils/oauthAffiliate'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@ -369,16 +370,7 @@ async function handleLogin(): Promise<void> {
|
|||||||
turnstileToken.value = ''
|
turnstileToken.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle login error
|
errorMessage.value = extractI18nErrorMessage(error, t, 'auth.errors', t('auth.loginFailed'))
|
||||||
const err = error as { message?: string; response?: { data?: { detail?: string } } }
|
|
||||||
|
|
||||||
if (err.response?.data?.detail) {
|
|
||||||
errorMessage.value = err.response.data.detail
|
|
||||||
} else if (err.message) {
|
|
||||||
errorMessage.value = err.message
|
|
||||||
} else {
|
|
||||||
errorMessage.value = t('auth.loginFailed')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also show error toast
|
// Also show error toast
|
||||||
appStore.showError(errorMessage.value)
|
appStore.showError(errorMessage.value)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user