feat(log):新增违规请求审查日志

This commit is contained in:
qian committed 2026-05-17 10:34:09 +08:00
1 parent bdca11903d
commit ec532559c4
11 files changed
+572 -71

No files matched your search

+1
View File
@@ -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)
+21
View File
@@ -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)
}
+84
View File
@@ -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
}
+69 -67
View File
@@ -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
+44 -1
View File
@@ -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) {
+2
View File
@@ -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.
@@ -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,
+6
View File
@@ -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',
+1
View File
@@ -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>