mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
上游对单次请求下发 error.code=cyber_policy 硬阻断时,网关在所有端点 (/v1/responses、/v1/chat/completions、/v1/messages、WebSocket)及流式/ 非流式路径下,将该结果原样透传给客户端,绝不 failover、换号或同步拦截; 命中后异步完成审计与计费: - 风控中心记录 cyber_policy 留痕并发送通知邮件,落库先于发信,SMTP 阻塞 不影响留痕 - ops 错误请求记录,状态码对齐客户端实际接收(流式 200 / 非流式 400) - 用量明细标记 request_type=cyber,按上游真实 token 计费,HTTP 与 WebSocket 计费口径统一,零 token 命中不误扣 - 会话级自动屏蔽(管理员开关,默认关):命中的会话在可配 TTL 内本地拦截 不再发往上游,仅屏蔽该会话不影响同 Key 其他会话 - 封号计数排除开关:可选让 cyber 命中不计入自动封号,命中当次不判定且 历史行在违规计数中一并排除 WebSocket 多轮连接下 cyber 标记按 turn 生命周期管理,逐轮独立检测与记录; 透传的错误响应不被兜底逻辑追加内容污染。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
251 lines
8.8 KiB
Go
251 lines
8.8 KiB
Go
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"`
|
|
APIKeysMode string `json:"api_keys_mode"`
|
|
DeleteAPIKeyHashes *[]string `json:"delete_api_key_hashes"`
|
|
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"`
|
|
Thresholds *map[string]float64 `json:"thresholds"`
|
|
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"`
|
|
// cyber_policy 命中是否排除出自动封号计数;前端 RiskControlView 已发送该字段,
|
|
// service.UpdateContentModerationConfigInput 已支持,此前 handler 层缺透传导致开关静默失效。
|
|
CyberPolicyExcludeFromBanCount *bool `json:"cyber_policy_exclude_from_ban_count"`
|
|
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"`
|
|
BlockedKeywords *[]string `json:"blocked_keywords"`
|
|
KeywordBlockingMode *string `json:"keyword_blocking_mode"`
|
|
ModelFilter *service.ContentModerationModelFilter `json:"model_filter"`
|
|
}
|
|
|
|
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,
|
|
APIKeysMode: req.APIKeysMode,
|
|
DeleteAPIKeyHashes: req.DeleteAPIKeyHashes,
|
|
ClearAPIKey: req.ClearAPIKey,
|
|
TimeoutMS: req.TimeoutMS,
|
|
SampleRate: req.SampleRate,
|
|
AllGroups: req.AllGroups,
|
|
GroupIDs: req.GroupIDs,
|
|
RecordNonHits: req.RecordNonHits,
|
|
Thresholds: req.Thresholds,
|
|
WorkerCount: req.WorkerCount,
|
|
QueueSize: req.QueueSize,
|
|
BlockStatus: req.BlockStatus,
|
|
BlockMessage: req.BlockMessage,
|
|
EmailOnHit: req.EmailOnHit,
|
|
AutoBanEnabled: req.AutoBanEnabled,
|
|
BanThreshold: req.BanThreshold,
|
|
ViolationWindowHours: req.ViolationWindowHours,
|
|
CyberPolicyExcludeFromBanCount: req.CyberPolicyExcludeFromBanCount,
|
|
RetryCount: req.RetryCount,
|
|
HitRetentionDays: req.HitRetentionDays,
|
|
NonHitRetentionDays: req.NonHitRetentionDays,
|
|
PreHashCheckEnabled: req.PreHashCheckEnabled,
|
|
BlockedKeywords: req.BlockedKeywords,
|
|
KeywordBlockingMode: req.KeywordBlockingMode,
|
|
ModelFilter: req.ModelFilter,
|
|
})
|
|
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
|
|
}
|