feat(ECR-019): ContentSafety FilterRule 只读并 Closed

新增 filter_rules、admin.content_safety.read、列表/详情/试匹配 API 与 admin-h5「安全」页;禁审核写/UGC/真支付。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-07 22:13:22 +08:00
co-authored by Cursor
parent de025d72bc
commit cae3380cbf
33 changed files with 724 additions and 6 deletions
+32
View File
@@ -243,6 +243,38 @@ export const adminApi = {
updated_at: string
messages: Array<{ id: string; role: string; content: string; created_at: string }>
}>('GET', `/ask/threads/${id}`),
filterRules: () =>
request<{
items: Array<{
id: string
code: string
title: string
category: string
pattern: string
action: string
active: boolean
system: boolean
updated_at: string
}>
}>('GET', '/content-safety/filter-rules'),
filterRule: (id: string) =>
request<{
id: string
code: string
title: string
category: string
pattern: string
action: string
active: boolean
system: boolean
updated_at: string
}>('GET', `/content-safety/filter-rules/${id}`),
evaluateContent: (text: string) =>
request<{ matches: Array<{ code: string; title: string; category: string; action: string }> }>(
'POST',
'/content-safety/evaluate',
{ text },
),
orders: () =>
request<{
items: Array<{
+1
View File
@@ -31,6 +31,7 @@ async function onLogout() {
<RouterLink to="/plans">套餐</RouterLink>
<RouterLink to="/codes">兑换码</RouterLink>
<RouterLink to="/ask">问答</RouterLink>
<RouterLink to="/safety">安全</RouterLink>
<RouterLink to="/orders">订单</RouterLink>
<RouterLink to="/audit">审计</RouterLink>
</nav>
+100
View File
@@ -0,0 +1,100 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { adminApi } from '@/api/client'
type Rule = Awaited<ReturnType<typeof adminApi.filterRules>>['items'][number]
const loading = ref(false)
const error = ref('')
const items = ref<Rule[]>([])
const sample = ref('我不想活了,求帮助')
const matches = ref<Array<{ code: string; title: string; category: string; action: string }>>([])
const evalMsg = ref('')
async function load() {
loading.value = true
error.value = ''
try {
const res = await adminApi.filterRules()
items.value = res.items || []
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
async function runEval() {
evalMsg.value = ''
try {
const res = await adminApi.evaluateContent(sample.value)
matches.value = res.matches || []
evalMsg.value = matches.value.length ? `命中 ${matches.value.length}` : '未命中'
} catch (e) {
evalMsg.value = e instanceof Error ? e.message : '试匹配失败'
matches.value = []
}
}
onMounted(load)
</script>
<template>
<section>
<h1>内容安全</h1>
<p class="muted">FilterRule 只读 · 试匹配不写审核工单</p>
<p v-if="loading" class="muted">加载中</p>
<p v-else-if="error" class="err">{{ error }}</p>
<div v-else class="layout">
<div class="card">
<h2>过滤规则</h2>
<p v-if="!items.length" class="muted">暂无规则</p>
<table v-else>
<thead>
<tr><th>代码</th><th>分类</th><th>动作</th><th>模式</th><th>状态</th></tr>
</thead>
<tbody>
<tr v-for="r in items" :key="r.id">
<td>{{ r.title }} <code>{{ r.code }}</code></td>
<td>{{ r.category }}</td>
<td>{{ r.action }}</td>
<td>{{ r.pattern }}</td>
<td>{{ r.active ? '启用' : '停用' }}{{ r.system ? ' · 系统' : '' }}</td>
</tr>
</tbody>
</table>
</div>
<div class="card">
<h2>试匹配</h2>
<textarea v-model="sample" rows="4" />
<div class="row">
<button class="btn" type="button" @click="runEval">试匹配</button>
<span class="muted">{{ evalMsg }}</span>
</div>
<ul v-if="matches.length" class="hits">
<li v-for="m in matches" :key="m.code">
{{ m.title }} · {{ m.category }} · <strong>{{ m.action }}</strong>
</li>
</ul>
</div>
</div>
</section>
</template>
<style scoped>
h1 { margin: 0 0 0.35rem; font-size: 1.35rem; }
h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
.layout { display: grid; grid-template-columns: 1.2fr 1fr; gap: 1rem; margin-top: 1rem; }
textarea {
width: 100%;
border: 1px solid var(--line);
border-radius: 8px;
padding: 0.55rem 0.7rem;
resize: vertical;
font: inherit;
}
.row { display: flex; gap: 0.6rem; align-items: center; margin-top: 0.6rem; }
.hits { margin: 0.75rem 0 0; padding-left: 1.1rem; }
code { font-size: 0.75rem; color: var(--muted); margin-left: 0.25rem; }
@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
</style>
+1
View File
@@ -18,6 +18,7 @@ const router = createRouter({
{ path: 'plans', name: 'plans', component: () => import('@/pages/MembershipPlansPage.vue') },
{ path: 'codes', name: 'codes', component: () => import('@/pages/RedemptionPage.vue') },
{ path: 'ask', name: 'ask', component: () => import('@/pages/AskPage.vue') },
{ path: 'safety', name: 'safety', component: () => import('@/pages/SafetyPage.vue') },
{ path: 'audit', name: 'audit', component: () => import('@/pages/AuditPage.vue') },
],
},
+1
View File
@@ -49,6 +49,7 @@ func (h *AdminHandler) Register(api *gin.RouterGroup) {
h.registerInsight(authed)
h.registerAskOps(authed)
h.registerEntitlement(authed)
h.registerContentSafety(authed)
}
func (h *AdminHandler) Login(c *gin.Context) {
@@ -0,0 +1,63 @@
package handler
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
"github.com/yuxingu/digital-psychology/apps/api/internal/service/admin"
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
)
func (h *AdminHandler) registerContentSafety(authed *gin.RouterGroup) {
g := authed.Group("/content-safety")
g.GET("/filter-rules", middleware.RequireAdminPermission(h.Svc, admin.PermContentSafetyRead), h.ListFilterRules)
g.GET("/filter-rules/:id", middleware.RequireAdminPermission(h.Svc, admin.PermContentSafetyRead), h.GetFilterRule)
g.POST("/evaluate", middleware.RequireAdminPermission(h.Svc, admin.PermContentSafetyRead), h.EvaluateContent)
}
func (h *AdminHandler) ListFilterRules(c *gin.Context) {
items, err := h.Svc.ListFilterRules(c.Request.Context())
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50022, "list filter rules failed")
return
}
response.OK(c, gin.H{"items": items})
}
func (h *AdminHandler) GetFilterRule(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
response.Fail(c, http.StatusBadRequest, 40002, "invalid id")
return
}
row, err := h.Svc.GetFilterRule(c.Request.Context(), id)
if errors.Is(err, admin.ErrFilterRuleNotFound) {
response.Fail(c, http.StatusNotFound, 40403, "filter rule not found")
return
}
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50023, "get filter rule failed")
return
}
response.OK(c, row)
}
func (h *AdminHandler) EvaluateContent(c *gin.Context) {
var body struct {
Text string `json:"text"`
}
if err := c.ShouldBindJSON(&body); err != nil {
response.Fail(c, http.StatusBadRequest, 40000, "invalid body")
return
}
matches, err := h.Svc.EvaluateContent(c.Request.Context(), body.Text)
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50024, "evaluate failed")
return
}
response.OK(c, gin.H{"matches": matches})
}
@@ -0,0 +1,112 @@
package integration_test
import (
"context"
"encoding/json"
"fmt"
"net/http"
"testing"
"time"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
func TestContentSafetyFilterRules(t *testing.T) {
r, pool := setupAPIPool(t)
ctx := context.Background()
tok := adminLogin(t, r, "admin", "change-me")
_, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/content-safety/filter-rules", nil, "")
if code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", code)
}
limitedRoleID := uuid.New()
_, err := pool.Exec(ctx, `
INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`,
limitedRoleID, "cs_lim_"+limitedRoleID.String()[:8])
if err != nil {
t.Fatal(err)
}
_, err = pool.Exec(ctx, `
INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.users.read')`, limitedRoleID)
if err != nil {
t.Fatal(err)
}
hash, err := bcrypt.GenerateFromPassword([]byte("limited-pass"), bcrypt.DefaultCost)
if err != nil {
t.Fatal(err)
}
limUser := fmt.Sprintf("cslim_%d", time.Now().UnixNano())
_, err = pool.Exec(ctx, `
INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1,$2,$3)`,
limUser, string(hash), limitedRoleID)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, limUser)
_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID)
})
limTok := adminLogin(t, r, limUser, "limited-pass")
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/content-safety/filter-rules", nil, limTok)
if code != http.StatusForbidden {
t.Fatalf("expected 403, got %d", code)
}
start := time.Now()
env, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/content-safety/filter-rules", nil, tok)
if code != 200 || env.Code != 0 {
t.Fatalf("list http=%d msg=%s", code, env.Message)
}
if time.Since(start) > 500*time.Millisecond {
t.Fatalf("list too slow %v", time.Since(start))
}
var list struct {
Items []struct {
ID string `json:"id"`
Code string `json:"code"`
System bool `json:"system"`
} `json:"items"`
}
_ = json.Unmarshal(env.Data, &list)
if len(list.Items) < 1 {
t.Fatal("expected seeded filter rules")
}
var firstID string
for _, it := range list.Items {
if it.System {
firstID = it.ID
break
}
}
if firstID == "" {
firstID = list.Items[0].ID
}
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/content-safety/filter-rules/"+firstID, nil, tok)
if code != 200 {
t.Fatalf("get %d", code)
}
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/content-safety/filter-rules/"+fakeUUID(), nil, tok)
if code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", code)
}
env, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/content-safety/evaluate",
map[string]string{"text": "真的不想活了怎么办"}, tok)
if code != 200 {
t.Fatalf("evaluate %d msg=%s", code, env.Message)
}
var ev struct {
Matches []struct {
Code string `json:"code"`
} `json:"matches"`
}
_ = json.Unmarshal(env.Data, &ev)
if len(ev.Matches) < 1 {
t.Fatalf("expected match, got %#v", ev)
}
}
@@ -0,0 +1,95 @@
package repository
import (
"context"
"errors"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// FilterRuleRow is ContentSafety FilterRule persistence.
type FilterRuleRow struct {
ID uuid.UUID `json:"id"`
Code string `json:"code"`
Title string `json:"title"`
Category string `json:"category"`
Pattern string `json:"pattern"`
Action string `json:"action"`
Active bool `json:"active"`
System bool `json:"system"`
UpdatedAt time.Time `json:"updated_at"`
}
// FilterMatch is one evaluate hit.
type FilterMatch struct {
Code string `json:"code"`
Title string `json:"title"`
Category string `json:"category"`
Action string `json:"action"`
}
// ListFilterRules returns active-first filter rules.
func (r *AdminRepo) ListFilterRules(ctx context.Context) ([]FilterRuleRow, error) {
rows, err := r.Pool.Query(ctx, `
SELECT id, code, title, category, pattern, action, active, system, updated_at
FROM filter_rules
ORDER BY active DESC, category ASC, code ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []FilterRuleRow
for rows.Next() {
var f FilterRuleRow
if err := rows.Scan(
&f.ID, &f.Code, &f.Title, &f.Category, &f.Pattern, &f.Action, &f.Active, &f.System, &f.UpdatedAt,
); err != nil {
return nil, err
}
out = append(out, f)
}
return out, rows.Err()
}
// GetFilterRule loads one rule by id.
func (r *AdminRepo) GetFilterRule(ctx context.Context, id uuid.UUID) (*FilterRuleRow, error) {
var f FilterRuleRow
err := r.Pool.QueryRow(ctx, `
SELECT id, code, title, category, pattern, action, active, system, updated_at
FROM filter_rules WHERE id=$1`, id,
).Scan(&f.ID, &f.Code, &f.Title, &f.Category, &f.Pattern, &f.Action, &f.Active, &f.System, &f.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
if err != nil {
return nil, err
}
return &f, nil
}
// EvaluateFilterRules runs simple substring match on active rules (ops preview).
func (r *AdminRepo) EvaluateFilterRules(ctx context.Context, text string) ([]FilterMatch, error) {
rules, err := r.ListFilterRules(ctx)
if err != nil {
return nil, err
}
lower := strings.ToLower(text)
var out []FilterMatch
for _, rule := range rules {
if !rule.Active || rule.Pattern == "" {
continue
}
if strings.Contains(lower, strings.ToLower(rule.Pattern)) {
out = append(out, FilterMatch{
Code: rule.Code, Title: rule.Title, Category: rule.Category, Action: rule.Action,
})
}
}
if out == nil {
out = []FilterMatch{}
}
return out, nil
}
@@ -0,0 +1,39 @@
package admin
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
var ErrFilterRuleNotFound = errString("filter rule not found")
// ListFilterRules returns FilterRule catalog.
func (s *Service) ListFilterRules(ctx context.Context) ([]repository.FilterRuleRow, error) {
items, err := s.Repo.ListFilterRules(ctx)
if err != nil {
return nil, err
}
if items == nil {
items = []repository.FilterRuleRow{}
}
return items, nil
}
// GetFilterRule loads one rule.
func (s *Service) GetFilterRule(ctx context.Context, id uuid.UUID) (*repository.FilterRuleRow, error) {
row, err := s.Repo.GetFilterRule(ctx, id)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrFilterRuleNotFound
}
return row, err
}
// EvaluateContent runs read-only filter preview.
func (s *Service) EvaluateContent(ctx context.Context, text string) ([]repository.FilterMatch, error) {
return s.Repo.EvaluateFilterRules(ctx, text)
}
+2 -1
View File
@@ -25,6 +25,7 @@ const (
PermMembershipCodesRead = "admin.membership.codes.read"
PermMembershipCodesWrite = "admin.membership.codes.write"
PermAskRead = "admin.ask.read"
PermContentSafetyRead = "admin.content_safety.read"
)
var knownPermissions = map[string]struct{}{
@@ -33,7 +34,7 @@ var knownPermissions = map[string]struct{}{
PermContentWrite: {}, PermRolesRead: {}, PermRolesWrite: {},
PermUsersStatusWrite: {}, PermMembershipPlansRead: {}, PermMembershipPlansWrite: {},
PermMembershipCodesRead: {}, PermMembershipCodesWrite: {},
PermAskRead: {},
PermAskRead: {}, PermContentSafetyRead: {},
}
var (
@@ -0,0 +1,4 @@
-- ECR-019 down
DELETE FROM admin_role_permissions WHERE code = 'admin.content_safety.read';
DROP TABLE IF EXISTS filter_rules;
@@ -0,0 +1,33 @@
-- ECR-019 ContentSafety FilterRule
CREATE TABLE IF NOT EXISTS filter_rules (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code varchar(64) NOT NULL UNIQUE,
title varchar(128) NOT NULL,
category varchar(32) NOT NULL
CHECK (category IN ('crisis','abuse','spam','pii')),
pattern text NOT NULL,
action varchar(32) NOT NULL
CHECK (action IN ('flag','block','escalate')),
active boolean NOT NULL DEFAULT true,
system boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_filter_rules_category ON filter_rules(category);
CREATE INDEX IF NOT EXISTS idx_filter_rules_active ON filter_rules(active);
INSERT INTO filter_rules(code, title, category, pattern, action, active, system)
VALUES
('crisis_self_harm', '自伤危机关键词', 'crisis', '不想活了', 'escalate', true, true),
('abuse_threat', '人身威胁', 'abuse', '弄死你', 'block', true, true),
('spam_promo', '营销骚扰', 'spam', '加微信领红包', 'flag', true, true),
('pii_id_card', '身份证号形态提示', 'pii', '身份证号', 'flag', true, true)
ON CONFLICT (code) DO NOTHING;
INSERT INTO admin_role_permissions(role_id, code)
SELECT r.id, 'admin.content_safety.read'
FROM admin_roles r
WHERE r.name = 'super_admin'
ON CONFLICT DO NOTHING;