feat(log):新增违规请求审查日志
This commit is contained in:
1 parent
bdca11903d
commit
ec532559c4
11 files changed
+572
-71
No files matched your search
@@ -25,6 +25,7 @@ type IAdminV1 interface {
|
||||
RequestAuditLogList(ctx context.Context, req *v1.RequestAuditLogListReq) (res *v1.RequestAuditLogListRes, err error)
|
||||
RequestAuditLogDetail(ctx context.Context, req *v1.RequestAuditLogDetailReq) (res *v1.RequestAuditLogDetailRes, err error)
|
||||
OperationLogExport(ctx context.Context, req *v1.OperationLogExportReq) (res *v1.OperationLogExportRes, err error)
|
||||
ContentFilterLogList(ctx context.Context, req *v1.ContentFilterLogListReq) (res *v1.ContentFilterLogListRes, err error)
|
||||
AdminLogin(ctx context.Context, req *v1.AdminLoginReq) (res *v1.AdminLoginRes, err error)
|
||||
AdminLogout(ctx context.Context, req *v1.AdminLogoutReq) (res *v1.AdminLogoutRes, err error)
|
||||
AdminRefresh(ctx context.Context, req *v1.AdminRefreshReq) (res *v1.AdminRefreshRes, err error)
|
||||
|
||||
@@ -95,3 +95,24 @@ type OperationLogExportReq struct {
|
||||
}
|
||||
|
||||
type OperationLogExportRes struct{}
|
||||
|
||||
// === 内容过滤拦截日志 ===
|
||||
|
||||
type ContentFilterLogListReq struct {
|
||||
g.Meta `path:"/audit/content-filter-logs" method:"get" mime:"json" tags:"管理后台-审计" summary:"内容过滤拦截日志"`
|
||||
Page int `json:"page" d:"1"`
|
||||
PageSize int `json:"page_size" d:"20"`
|
||||
TenantID int `json:"tenant_id" dc:"租户ID"`
|
||||
Mode string `json:"mode" dc:"过滤模式:log/replace/block"`
|
||||
Blocked string `json:"blocked" dc:"是否被拦截:true/false"`
|
||||
StartDate string `json:"start_date" dc:"开始日期"`
|
||||
EndDate string `json:"end_date" dc:"结束日期"`
|
||||
Keyword string `json:"keyword" dc:"关键词搜索(敏感词/路径)"`
|
||||
}
|
||||
|
||||
type ContentFilterLogListRes struct {
|
||||
List []map[string]any `json:"list"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
v1 "github.com/qianfree/team-api/api/admin/v1"
|
||||
"github.com/qianfree/team-api/internal/service"
|
||||
)
|
||||
|
||||
func (c *ControllerV1) ContentFilterLogList(ctx context.Context, req *v1.ContentFilterLogListReq) (res *v1.ContentFilterLogListRes, err error) {
|
||||
return service.Admin().ContentFilterLogList(ctx, req)
|
||||
}
|
||||
@@ -457,3 +457,87 @@ func (s *sAdmin) ExportOperationLogs(ctx context.Context, req *v1.OperationLogEx
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ContentFilterLogList returns a paginated list of content filter interception logs.
|
||||
func (s *sAdmin) ContentFilterLogList(ctx context.Context, req *v1.ContentFilterLogListReq) (*v1.ContentFilterLogListRes, error) {
|
||||
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
|
||||
|
||||
var conditions []string
|
||||
var args []any
|
||||
|
||||
if req.TenantID > 0 {
|
||||
conditions = append(conditions, "l.tenant_id = ?")
|
||||
args = append(args, int64(req.TenantID))
|
||||
}
|
||||
if req.Mode != "" {
|
||||
conditions = append(conditions, "l.filter_mode = ?")
|
||||
args = append(args, req.Mode)
|
||||
}
|
||||
if req.Blocked == "true" {
|
||||
conditions = append(conditions, "l.blocked = true")
|
||||
} else if req.Blocked == "false" {
|
||||
conditions = append(conditions, "l.blocked = false")
|
||||
}
|
||||
if req.StartDate != "" {
|
||||
conditions = append(conditions, "l.created_at >= ?")
|
||||
args = append(args, req.StartDate+" 00:00:00")
|
||||
}
|
||||
if req.EndDate != "" {
|
||||
conditions = append(conditions, "l.created_at <= ?")
|
||||
args = append(args, req.EndDate+" 23:59:59")
|
||||
}
|
||||
if req.Keyword != "" {
|
||||
conditions = append(conditions, "(l.matched_words::text ILIKE ? OR l.path ILIKE ?)")
|
||||
kw := "%" + req.Keyword + "%"
|
||||
args = append(args, kw, kw)
|
||||
}
|
||||
|
||||
where := ""
|
||||
if len(conditions) > 0 {
|
||||
where = " WHERE " + strings.Join(conditions, " AND ")
|
||||
}
|
||||
|
||||
fields := `l.id, l.tenant_id, l.user_id, l.api_key_id, l.project_id, l.request_id, l.method, l.path, l.client_ip,
|
||||
l.filter_mode, l.matched_words, l.original_snippet, l.blocked, l.created_at,
|
||||
t.name AS tenant_name, u.username AS user_name, k.name AS api_key_name, p.name AS project_name`
|
||||
|
||||
// Count
|
||||
countSQL := "SELECT COUNT(*) AS total FROM aud_content_filter_logs l" + where
|
||||
countResult, err := g.DB().Ctx(ctx).Query(ctx, countSQL, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
total := 0
|
||||
if len(countResult) > 0 {
|
||||
total = countResult[0]["total"].Int()
|
||||
}
|
||||
|
||||
// Query with JOINs
|
||||
dataSQL := fmt.Sprintf(`SELECT %s FROM aud_content_filter_logs l
|
||||
LEFT JOIN tnt_tenants t ON l.tenant_id = t.id
|
||||
LEFT JOIN tnt_users u ON l.user_id = u.id
|
||||
LEFT JOIN api_keys k ON l.api_key_id = k.id
|
||||
LEFT JOIN tnt_projects p ON l.project_id = p.id
|
||||
%s ORDER BY l.created_at DESC LIMIT %d OFFSET %d`, fields, where, pageSize, (page-1)*pageSize)
|
||||
|
||||
result, err := g.DB().Ctx(ctx).Query(ctx, dataSQL, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items := make([]map[string]any, 0, len(result))
|
||||
for _, row := range result {
|
||||
m := make(map[string]any, len(row))
|
||||
for k, v := range row {
|
||||
m[k] = v.Val()
|
||||
}
|
||||
items = append(items, m)
|
||||
}
|
||||
|
||||
return &v1.ContentFilterLogListRes{
|
||||
List: items,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/cloudflare/ahocorasick"
|
||||
@@ -17,15 +19,20 @@ type FilterResult struct {
|
||||
FilteredText string
|
||||
}
|
||||
|
||||
// ContentFilterEngine manages the Aho-Corasick automaton for multi-pattern matching.
|
||||
// It is safe for concurrent use via sync.RWMutex.
|
||||
type ContentFilterEngine struct {
|
||||
mu sync.RWMutex
|
||||
// filterSnapshot is an immutable snapshot of the filter engine state.
|
||||
// Stored in atomic.Value for lock-free reads in the hot path.
|
||||
type filterSnapshot struct {
|
||||
mode string
|
||||
repl string
|
||||
matcher *ahocorasick.Matcher
|
||||
literalPatterns []string // patterns fed into the AC matcher (no wildcards)
|
||||
words []string // original word list (including wildcard patterns)
|
||||
mode string // off/log/replace/block
|
||||
repl string // replacement string for "replace" mode
|
||||
literalPatterns []string
|
||||
wildcardInners []string // precomputed inner literals from wildcard patterns
|
||||
}
|
||||
|
||||
// ContentFilterEngine manages the Aho-Corasick automaton for multi-pattern matching.
|
||||
type ContentFilterEngine struct {
|
||||
snapshot atomic.Value // stores *filterSnapshot
|
||||
mu sync.Mutex // protects Rebuild
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -36,17 +43,15 @@ var (
|
||||
// ContentFilter returns the global ContentFilterEngine singleton.
|
||||
func ContentFilter() *ContentFilterEngine {
|
||||
contentFilterEngineOnce.Do(func() {
|
||||
contentFilterEngine = &ContentFilterEngine{
|
||||
mode: "off",
|
||||
repl: "***",
|
||||
}
|
||||
eng := &ContentFilterEngine{}
|
||||
eng.snapshot.Store(&filterSnapshot{mode: "off", repl: "***"})
|
||||
contentFilterEngine = eng
|
||||
})
|
||||
return contentFilterEngine
|
||||
}
|
||||
|
||||
// InitContentFilter initializes the content filter engine and starts the
|
||||
// Redis Pub/Sub subscriber for auto-rebuild on settings changes.
|
||||
// Call this once at startup.
|
||||
func InitContentFilter(ctx context.Context) {
|
||||
engine := ContentFilter()
|
||||
engine.Rebuild(ctx)
|
||||
@@ -64,20 +69,23 @@ func (e *ContentFilterEngine) Rebuild(ctx context.Context) {
|
||||
repl = "***"
|
||||
}
|
||||
|
||||
var rawWords []string
|
||||
if err := cfg.GetOptionJSON(ctx, "content_filter_words", &rawWords); err != nil {
|
||||
var words []string
|
||||
if err := cfg.GetOptionJSON(ctx, "content_filter_words", &words); err != nil {
|
||||
g.Log().Warningf(ctx, "[ContentFilter] failed to parse content_filter_words: %v", err)
|
||||
rawWords = nil
|
||||
words = nil
|
||||
}
|
||||
|
||||
// Wildcard patterns like "*赌博*" cannot be matched directly by AC,
|
||||
// so we separate them and handle via strings.Contains in Check().
|
||||
var literalPatterns []string
|
||||
for _, w := range rawWords {
|
||||
var wildcardInners []string
|
||||
for _, w := range words {
|
||||
if w == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(w, "*") {
|
||||
inner := strings.TrimLeft(strings.TrimRight(w, "*"), "*")
|
||||
if inner != "" {
|
||||
wildcardInners = append(wildcardInners, inner)
|
||||
}
|
||||
continue
|
||||
}
|
||||
literalPatterns = append(literalPatterns, w)
|
||||
@@ -89,66 +97,62 @@ func (e *ContentFilterEngine) Rebuild(ctx context.Context) {
|
||||
}
|
||||
|
||||
e.mu.Lock()
|
||||
e.matcher = matcher
|
||||
e.literalPatterns = literalPatterns
|
||||
e.words = rawWords
|
||||
e.mode = mode
|
||||
e.repl = repl
|
||||
e.snapshot.Store(&filterSnapshot{
|
||||
mode: mode,
|
||||
repl: repl,
|
||||
matcher: matcher,
|
||||
literalPatterns: literalPatterns,
|
||||
wildcardInners: wildcardInners,
|
||||
})
|
||||
e.mu.Unlock()
|
||||
|
||||
g.Log().Infof(ctx, "[ContentFilter] rebuilt: mode=%s, words=%d, literals=%d", mode, len(rawWords), len(literalPatterns))
|
||||
g.Log().Infof(ctx, "[ContentFilter] rebuilt: mode=%s, words=%d, literals=%d, wildcards=%d", mode, len(words), len(literalPatterns), len(wildcardInners))
|
||||
}
|
||||
|
||||
// Check scans the given text for sensitive words and returns a FilterResult.
|
||||
func (e *ContentFilterEngine) Check(text string) *FilterResult {
|
||||
e.mu.RLock()
|
||||
mode := e.mode
|
||||
matcher := e.matcher
|
||||
literalPatterns := e.literalPatterns
|
||||
words := e.words
|
||||
repl := e.repl
|
||||
e.mu.RUnlock()
|
||||
// Check scans the given body for sensitive words and returns a FilterResult.
|
||||
// Accepts []byte to avoid string conversion on the hot path.
|
||||
func (e *ContentFilterEngine) Check(body []byte) *FilterResult {
|
||||
snap := e.snapshot.Load().(*filterSnapshot)
|
||||
|
||||
if mode == "off" || text == "" {
|
||||
return &FilterResult{
|
||||
Matched: false,
|
||||
MatchedWords: nil,
|
||||
FilteredText: text,
|
||||
}
|
||||
}
|
||||
|
||||
result := &FilterResult{
|
||||
Matched: false,
|
||||
MatchedWords: nil,
|
||||
FilteredText: text,
|
||||
if snap.mode == "off" || len(body) == 0 {
|
||||
return &FilterResult{FilteredText: string(body)}
|
||||
}
|
||||
|
||||
matchedSet := make(map[string]bool)
|
||||
|
||||
// Phase 1: Aho-Corasick multi-pattern matching for literal words.
|
||||
// MatchThreadSafe returns indices into the original dictionary (literalPatterns).
|
||||
if matcher != nil {
|
||||
hits := matcher.MatchThreadSafe([]byte(text))
|
||||
if snap.matcher != nil {
|
||||
hits := snap.matcher.MatchThreadSafe(body)
|
||||
for _, idx := range hits {
|
||||
if idx >= 0 && idx < len(literalPatterns) {
|
||||
matchedSet[literalPatterns[idx]] = true
|
||||
if idx >= 0 && idx < len(snap.literalPatterns) {
|
||||
matchedSet[snap.literalPatterns[idx]] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Wildcard pattern matching via strings.Contains.
|
||||
// Patterns like "*赌博*" mean "contains 赌博".
|
||||
for _, w := range words {
|
||||
if !strings.Contains(w, "*") {
|
||||
continue
|
||||
// Phase 2: Wildcard pattern matching via bytes.Contains.
|
||||
// For non-replace modes, skip if already matched; early-exit on first hit.
|
||||
if snap.mode != "replace" {
|
||||
if len(matchedSet) > 0 {
|
||||
// Already have a match, no need for wildcard phase
|
||||
} else {
|
||||
for _, inner := range snap.wildcardInners {
|
||||
if bytes.Contains(body, []byte(inner)) {
|
||||
matchedSet[inner] = true
|
||||
break // one hit is enough for log/block
|
||||
}
|
||||
}
|
||||
}
|
||||
// Strip leading/trailing wildcards to get the inner literal.
|
||||
inner := strings.TrimLeft(strings.TrimRight(w, "*"), "*")
|
||||
if inner != "" && strings.Contains(text, inner) {
|
||||
matchedSet[inner] = true
|
||||
} else {
|
||||
// Replace mode: must find ALL matches for complete replacement
|
||||
for _, inner := range snap.wildcardInners {
|
||||
if bytes.Contains(body, []byte(inner)) {
|
||||
matchedSet[inner] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result := &FilterResult{FilteredText: string(body)}
|
||||
if len(matchedSet) == 0 {
|
||||
return result
|
||||
}
|
||||
@@ -158,21 +162,19 @@ func (e *ContentFilterEngine) Check(text string) *FilterResult {
|
||||
result.MatchedWords = append(result.MatchedWords, w)
|
||||
}
|
||||
|
||||
// Build filtered text by replacing all matched words.
|
||||
filtered := text
|
||||
// Build filtered text (only needed for replace mode, but compute for completeness)
|
||||
filtered := string(body)
|
||||
for w := range matchedSet {
|
||||
filtered = strings.ReplaceAll(filtered, w, repl)
|
||||
filtered = strings.ReplaceAll(filtered, w, snap.repl)
|
||||
}
|
||||
result.FilteredText = filtered
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// GetMode returns the current filter mode thread-safely.
|
||||
// GetMode returns the current filter mode (lock-free via atomic).
|
||||
func (e *ContentFilterEngine) GetMode() string {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
return e.mode
|
||||
return e.snapshot.Load().(*filterSnapshot).mode
|
||||
}
|
||||
|
||||
// startSubscriber subscribes to Redis Pub/Sub "settings:changed" channel
|
||||
|
||||
@@ -40,7 +40,7 @@ func ContentFilter(r *ghttp.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
result := common.ContentFilter().Check(string(body))
|
||||
result := common.ContentFilter().Check(body)
|
||||
if !result.Matched {
|
||||
r.Middleware.Next()
|
||||
return
|
||||
@@ -50,6 +50,9 @@ func ContentFilter(r *ghttp.Request) {
|
||||
r.SetCtxVar(CtxKeyContentFilterMatched, true)
|
||||
r.SetCtxVar(CtxKeyContentFilterWords, result.MatchedWords)
|
||||
|
||||
// Asynchronously persist the interception log
|
||||
insertContentFilterLog(r, mode, result.MatchedWords, string(body))
|
||||
|
||||
switch mode {
|
||||
case "log":
|
||||
g.Log().Infof(r.Context(), "[ContentFilter] matched words: %v", result.MatchedWords)
|
||||
@@ -72,6 +75,46 @@ func ContentFilter(r *ghttp.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// insertContentFilterLog persists a content filter interception record asynchronously.
|
||||
func insertContentFilterLog(r *ghttp.Request, mode string, matchedWords []string, body string) {
|
||||
tenantId := r.GetCtxVar(CtxKeyTenantID).Int64()
|
||||
userId := r.GetCtxVar(CtxKeyUserID).Int64()
|
||||
apiKeyId := r.GetCtxVar(CtxKeyApiKeyID).Int64()
|
||||
projectId := r.GetCtxVar(CtxKeyProjectID).Int64()
|
||||
requestId := r.GetCtxVar("RequestId").String()
|
||||
method := r.Method
|
||||
path := r.URL.Path
|
||||
clientIp := r.GetClientIp()
|
||||
|
||||
// Truncate body snippet for replace mode only
|
||||
snippet := ""
|
||||
if mode == "replace" && len(body) > 0 {
|
||||
const maxSnippet = 500
|
||||
if len(body) > maxSnippet {
|
||||
snippet = body[:maxSnippet]
|
||||
} else {
|
||||
snippet = body
|
||||
}
|
||||
}
|
||||
|
||||
blocked := mode == "block"
|
||||
wordsJSON, _ := json.Marshal(matchedWords)
|
||||
|
||||
go func() {
|
||||
_, err := g.DB().Exec(context.Background(),
|
||||
`INSERT INTO aud_content_filter_logs
|
||||
(tenant_id, user_id, api_key_id, project_id, request_id, method, path, client_ip,
|
||||
filter_mode, matched_words, original_snippet, blocked, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, now())`,
|
||||
tenantId, userId, apiKeyId, projectId, requestId, method, path, clientIp,
|
||||
mode, string(wordsJSON), snippet, blocked)
|
||||
if err != nil {
|
||||
g.Log().Warningf(context.Background(),
|
||||
"[ContentFilter] failed to insert filter log: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// writeContentFilterError writes an error response in the relay-native format.
|
||||
// /v1/messages uses Claude format, all other paths use OpenAI format.
|
||||
func writeContentFilterError(r *ghttp.Request, statusCode int, errType, message string) {
|
||||
|
||||
@@ -41,6 +41,8 @@ type (
|
||||
GetRequestAuditLogDetail(ctx context.Context, req *v1.RequestAuditLogDetailReq) (*v1.RequestAuditLogDetailRes, error)
|
||||
// ExportOperationLogs exports operation logs to CSV or Excel.
|
||||
ExportOperationLogs(ctx context.Context, req *v1.OperationLogExportReq) (*v1.OperationLogExportRes, error)
|
||||
// ContentFilterLogList returns a paginated list of content filter interception logs.
|
||||
ContentFilterLogList(ctx context.Context, req *v1.ContentFilterLogListReq) (*v1.ContentFilterLogListRes, error)
|
||||
// Login handles admin login.
|
||||
Login(ctx context.Context, req *v1.AdminLoginReq) (*v1.AdminLoginRes, error)
|
||||
// Logout handles admin logout.
|
||||
|
||||
+44
-3
@@ -1,5 +1,5 @@
|
||||
-- +goose Up
|
||||
-- 租户模型自定义定价 + 异步任务表重命名
|
||||
-- 租户模型自定义定价 + 异步任务表重命名 + 内容过滤日志表
|
||||
|
||||
-- 1. 租户模型分配增加自定义缓存定价覆盖字段和阶梯定价支持
|
||||
ALTER TABLE mdl_tenant_models
|
||||
@@ -50,8 +50,49 @@ COMMENT ON COLUMN tsk_model_tasks.finish_time IS '任务完成(成功或失败
|
||||
COMMENT ON COLUMN tsk_model_tasks.created_at IS '记录创建时间';
|
||||
COMMENT ON COLUMN tsk_model_tasks.updated_at IS '记录更新时间';
|
||||
|
||||
-- 3. 创建内容过滤拦截日志表
|
||||
CREATE TABLE aud_content_filter_logs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT,
|
||||
user_id BIGINT,
|
||||
api_key_id BIGINT,
|
||||
project_id BIGINT,
|
||||
request_id VARCHAR(64),
|
||||
method VARCHAR(10),
|
||||
path VARCHAR(500),
|
||||
client_ip VARCHAR(45),
|
||||
filter_mode VARCHAR(20) NOT NULL,
|
||||
matched_words JSONB NOT NULL,
|
||||
original_snippet TEXT,
|
||||
blocked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_aud_content_filter_logs_created_brin ON aud_content_filter_logs USING brin (created_at);
|
||||
CREATE INDEX idx_aud_content_filter_logs_tenant ON aud_content_filter_logs USING btree (tenant_id, created_at);
|
||||
CREATE INDEX idx_aud_content_filter_logs_mode ON aud_content_filter_logs (filter_mode);
|
||||
|
||||
COMMENT ON TABLE aud_content_filter_logs IS '内容过滤拦截日志,记录所有命中敏感词的请求';
|
||||
COMMENT ON COLUMN aud_content_filter_logs.id IS '主键ID';
|
||||
COMMENT ON COLUMN aud_content_filter_logs.tenant_id IS '租户ID';
|
||||
COMMENT ON COLUMN aud_content_filter_logs.user_id IS '用户ID';
|
||||
COMMENT ON COLUMN aud_content_filter_logs.api_key_id IS 'API Key ID';
|
||||
COMMENT ON COLUMN aud_content_filter_logs.project_id IS '项目ID';
|
||||
COMMENT ON COLUMN aud_content_filter_logs.request_id IS '请求唯一ID';
|
||||
COMMENT ON COLUMN aud_content_filter_logs.method IS 'HTTP 方法';
|
||||
COMMENT ON COLUMN aud_content_filter_logs.path IS '请求路径';
|
||||
COMMENT ON COLUMN aud_content_filter_logs.client_ip IS '客户端 IP';
|
||||
COMMENT ON COLUMN aud_content_filter_logs.filter_mode IS '过滤模式:log / replace / block';
|
||||
COMMENT ON COLUMN aud_content_filter_logs.matched_words IS '命中的敏感词列表(JSONB 数组)';
|
||||
COMMENT ON COLUMN aud_content_filter_logs.original_snippet IS '原始请求体片段(截断存储,仅 replace 模式)';
|
||||
COMMENT ON COLUMN aud_content_filter_logs.blocked IS '是否被拦截(mode=block 时为 true)';
|
||||
COMMENT ON COLUMN aud_content_filter_logs.created_at IS '创建时间';
|
||||
|
||||
-- +goose Down
|
||||
-- 移除注释
|
||||
-- 3. 删除内容过滤日志表
|
||||
DROP TABLE IF EXISTS aud_content_filter_logs;
|
||||
|
||||
-- 2. 移除注释
|
||||
COMMENT ON TABLE tsk_model_tasks IS NULL;
|
||||
COMMENT ON COLUMN tsk_model_tasks.id IS NULL;
|
||||
COMMENT ON COLUMN tsk_model_tasks.public_task_id IS NULL;
|
||||
@@ -90,7 +131,7 @@ ALTER TABLE tsk_model_tasks RENAME CONSTRAINT uk_tsk_model_tasks_public_id TO uk
|
||||
-- 恢复表名
|
||||
ALTER TABLE tsk_model_tasks RENAME TO tsk_async_tasks;
|
||||
|
||||
-- 移除定价字段
|
||||
-- 1. 移除定价字段
|
||||
ALTER TABLE mdl_tenant_models
|
||||
DROP COLUMN IF EXISTS custom_cache_read_price,
|
||||
DROP COLUMN IF EXISTS custom_cache_creation_price,
|
||||
@@ -252,6 +252,12 @@ const adminRoutes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/monitor/ErrorLogsPage.vue'),
|
||||
meta: { title: '系统错误日志' },
|
||||
},
|
||||
{
|
||||
path: 'content-filter-logs',
|
||||
name: 'AdminContentFilterLogs',
|
||||
component: () => import('@/views/monitor/ContentFilterLogsPage.vue'),
|
||||
meta: { title: '拦截日志' },
|
||||
},
|
||||
{
|
||||
path: 'cron-jobs',
|
||||
name: 'AdminCronJobs',
|
||||
|
||||
@@ -214,6 +214,7 @@ const menuGroups = [
|
||||
{ name: 'AdminAlertRules', label: '告警规则', icon: IconNotification },
|
||||
{ name: 'AdminAlertEvents', label: '告警记录', icon: IconFile },
|
||||
{ name: 'AdminErrorLogs', label: '错误日志', icon: IconFile },
|
||||
{ name: 'AdminContentFilterLogs', label: '拦截日志', icon: IconFile },
|
||||
{ name: 'AdminCronJobs', label: '定时任务', icon: IconFile },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, h } from 'vue'
|
||||
import { Tag } from '@arco-design/web-vue'
|
||||
import type { TableColumnData } from '@arco-design/web-vue'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const loading = ref(false)
|
||||
const data = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
const pagination = reactive({ current: 1, pageSize: 20 })
|
||||
|
||||
const filterMode = ref('')
|
||||
const filterBlocked = ref('')
|
||||
const filterTenantId = ref<number | undefined>(undefined)
|
||||
const filterDateRange = ref<string[]>([])
|
||||
const filterKeyword = ref('')
|
||||
|
||||
// Tenant selector
|
||||
const tenantOptions = ref<{ value: number; label: string }[]>([])
|
||||
const tenantLoading = ref(false)
|
||||
const tenantKeyword = ref('')
|
||||
const tenantPage = ref(1)
|
||||
const tenantHasMore = ref(false)
|
||||
let tenantSearchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
async function fetchTenantOptions(reset = true) {
|
||||
tenantLoading.value = true
|
||||
try {
|
||||
const params: any = { page: tenantPage.value, page_size: 20 }
|
||||
if (tenantKeyword.value) params.keyword = tenantKeyword.value
|
||||
const res = await request.get('/admin/tenants/select', { params })
|
||||
const d = res.data?.data
|
||||
const list: { id: number; name: string; code: string }[] = d?.list || []
|
||||
const items = list.map(t => ({ value: t.id, label: `${t.name} (${t.code})` }))
|
||||
if (reset) {
|
||||
tenantOptions.value = items
|
||||
} else {
|
||||
tenantOptions.value = [...tenantOptions.value, ...items]
|
||||
}
|
||||
tenantHasMore.value = tenantOptions.value.length < (d?.total || 0)
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
tenantLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleTenantSearch(val: string) {
|
||||
if (tenantSearchTimer) clearTimeout(tenantSearchTimer)
|
||||
tenantSearchTimer = setTimeout(() => {
|
||||
tenantKeyword.value = val
|
||||
tenantPage.value = 1
|
||||
fetchTenantOptions(true)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function handleTenantScrollReachEdge(e: Event) {
|
||||
if (!tenantHasMore.value || tenantLoading.value) return
|
||||
const el = e.target as HTMLElement
|
||||
if (el.scrollHeight - el.scrollTop - el.clientHeight < 30) {
|
||||
tenantPage.value++
|
||||
fetchTenantOptions(false)
|
||||
}
|
||||
}
|
||||
|
||||
function openTenantDropdown() {
|
||||
if (tenantOptions.value.length === 0) {
|
||||
tenantKeyword.value = ''
|
||||
tenantPage.value = 1
|
||||
fetchTenantOptions(true)
|
||||
}
|
||||
}
|
||||
|
||||
const modeColorMap: Record<string, string> = {
|
||||
log: 'arcoblue',
|
||||
replace: 'orange',
|
||||
block: 'red',
|
||||
}
|
||||
const modeLabelMap: Record<string, string> = {
|
||||
log: '仅记录',
|
||||
replace: '替换',
|
||||
block: '拦截',
|
||||
}
|
||||
|
||||
const columns: TableColumnData[] = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 60 },
|
||||
{
|
||||
title: '过滤模式',
|
||||
dataIndex: 'filter_mode',
|
||||
width: 90,
|
||||
render: ({ record }: any) => {
|
||||
const mode = record.filter_mode
|
||||
return h(Tag, { color: modeColorMap[mode] || 'gray' }, () => modeLabelMap[mode] || mode)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '是否拦截',
|
||||
dataIndex: 'blocked',
|
||||
width: 90,
|
||||
render: ({ record }: any) => {
|
||||
return record.blocked
|
||||
? h(Tag, { color: 'red' }, () => '已拦截')
|
||||
: h(Tag, { color: 'green' }, () => '通过')
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '租户',
|
||||
dataIndex: 'tenant_name',
|
||||
width: 120,
|
||||
ellipsis: true,
|
||||
tooltip: true,
|
||||
},
|
||||
{
|
||||
title: '用户',
|
||||
dataIndex: 'user_name',
|
||||
width: 110,
|
||||
ellipsis: true,
|
||||
tooltip: true,
|
||||
},
|
||||
{
|
||||
title: '项目',
|
||||
dataIndex: 'project_name',
|
||||
width: 110,
|
||||
ellipsis: true,
|
||||
tooltip: true,
|
||||
},
|
||||
{
|
||||
title: 'API Key',
|
||||
dataIndex: 'api_key_name',
|
||||
width: 120,
|
||||
ellipsis: true,
|
||||
tooltip: true,
|
||||
},
|
||||
{ title: '请求方法', dataIndex: 'method', width: 80 },
|
||||
{
|
||||
title: '请求路径',
|
||||
dataIndex: 'path',
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
tooltip: true,
|
||||
},
|
||||
{
|
||||
title: '命中敏感词',
|
||||
dataIndex: 'matched_words',
|
||||
width: 250,
|
||||
render: ({ record }: any) => {
|
||||
let words: string[] = []
|
||||
if (typeof record.matched_words === 'string') {
|
||||
try { words = JSON.parse(record.matched_words) } catch { words = [record.matched_words] }
|
||||
} else if (Array.isArray(record.matched_words)) {
|
||||
words = record.matched_words
|
||||
}
|
||||
return h('div', { style: 'display:flex;flex-wrap:wrap;gap:4px' },
|
||||
words.map((w: string) => h(Tag, { color: 'red', size: 'small' }, () => w))
|
||||
)
|
||||
},
|
||||
},
|
||||
{ title: '客户端IP', dataIndex: 'client_ip', width: 130 },
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'created_at',
|
||||
width: 160,
|
||||
render: ({ record }: any) => {
|
||||
if (!record.created_at) return '-'
|
||||
return new Date(record.created_at).toLocaleString('zh-CN')
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = {
|
||||
page: pagination.current,
|
||||
page_size: pagination.pageSize,
|
||||
}
|
||||
if (filterMode.value) params.mode = filterMode.value
|
||||
if (filterBlocked.value) params.blocked = filterBlocked.value
|
||||
if (filterTenantId.value) params.tenant_id = filterTenantId.value
|
||||
if (filterKeyword.value) params.keyword = filterKeyword.value
|
||||
if (filterDateRange.value?.length === 2) {
|
||||
params.start_date = filterDateRange.value[0]
|
||||
params.end_date = filterDateRange.value[1]
|
||||
}
|
||||
const res: any = await request.get('/admin/audit/content-filter-logs', { params })
|
||||
const d = res.data?.data || res.data
|
||||
data.value = d?.list || []
|
||||
total.value = d?.total || 0
|
||||
} catch {
|
||||
data.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
pagination.current = page
|
||||
fetchData()
|
||||
}
|
||||
|
||||
function handlePageSizeChange(pageSize: number) {
|
||||
pagination.pageSize = pageSize
|
||||
pagination.current = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.current = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
filterMode.value = ''
|
||||
filterBlocked.value = ''
|
||||
filterTenantId.value = undefined
|
||||
filterDateRange.value = []
|
||||
filterKeyword.value = ''
|
||||
pagination.current = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader title="过滤拦截日志" description="查看内容过滤拦截记录,分析敏感词触发趋势" />
|
||||
|
||||
<a-card :bordered="false" class="mb-4">
|
||||
<a-space wrap>
|
||||
<a-select v-model="filterMode" placeholder="过滤模式" allow-clear style="width: 130px">
|
||||
<a-option value="log">仅记录</a-option>
|
||||
<a-option value="replace">替换</a-option>
|
||||
<a-option value="block">拦截</a-option>
|
||||
</a-select>
|
||||
<a-select v-model="filterBlocked" placeholder="拦截状态" allow-clear style="width: 130px">
|
||||
<a-option value="true">已拦截</a-option>
|
||||
<a-option value="false">未拦截</a-option>
|
||||
</a-select>
|
||||
<a-select
|
||||
v-model="filterTenantId"
|
||||
:options="tenantOptions"
|
||||
:loading="tenantLoading"
|
||||
placeholder="选择租户"
|
||||
allow-search
|
||||
allow-clear
|
||||
:filter-option="false"
|
||||
style="width: 200px"
|
||||
@search="handleTenantSearch"
|
||||
@dropdown-scroll-reach-edge="handleTenantScrollReachEdge"
|
||||
@popup-visible-change="(visible: boolean) => { if (visible) openTenantDropdown() }"
|
||||
/>
|
||||
<a-range-picker v-model="filterDateRange" style="width: 260px" />
|
||||
<a-input v-model="filterKeyword" placeholder="搜索敏感词/路径" allow-clear style="width: 200px" @keydown.enter="handleSearch" />
|
||||
<a-button type="primary" @click="handleSearch">搜索</a-button>
|
||||
<a-button @click="handleReset">重置</a-button>
|
||||
</a-space>
|
||||
</a-card>
|
||||
|
||||
<a-card :bordered="false">
|
||||
<a-table
|
||||
:data="data"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
:pagination="{
|
||||
current: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
total: total,
|
||||
showTotal: true,
|
||||
showPageSize: true,
|
||||
}"
|
||||
@page-change="handlePageChange"
|
||||
@page-size-change="handlePageSizeChange"
|
||||
row-key="id"
|
||||
/>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mb-4 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user