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
+1 -1
View File
@@ -36,7 +36,7 @@
| Analytics_OpsB | GrowthInsights / UserIntelligence(read) | Shipped Ops-B |
| Ops_Content | ExploreConfig (partial) | Shipped Ops-C |
| Account_Risk | AccountLifecycle | Spec via ECR-013BIdentity_Profile owns UserStatus |
| Content_Safety | ContentSafety · CrisisCare | Draft |
| Content_Safety | ContentSafety · CrisisCare | ContentSafety **ECR-019**FilterRule);CrisisCare Draft |
| Ask_Ops | AskOperations · AICoreConfig | AskOperations **ECR-017**(只读);AICoreConfig Draft |
| Ops_CMS_NoUGC | OpsCMS | Draft |
| Community | — | **Forbidden** |
+1 -1
View File
@@ -28,7 +28,7 @@ Status: `Draft`
| HomeTool | Ops_Content | ExploreConfig | 已存在 Ops-C |
| ScalePublishState | Ops_Content | ExploreConfig | 已存在 |
| AnalyticsSession / Event | Analytics_OpsB | GrowthInsights | 已存在 Ops-B |
| FilterRule | Content_Safety | ContentSafety | 后置 |
| FilterRule | Content_Safety | ContentSafety | **ECR-019** 只读 + evaluate |
| ModerationCase | Content_Safety | ContentSafety | 后置 |
| CrisisEvent / CrisisPolicy | Content_Safety | CrisisCare | 后置 |
| SystemPrompt / Knowledge* / ToolDefinition | Ask_Ops | AICoreConfig | 后置 |
+1 -1
View File
@@ -235,7 +235,7 @@ UI **不出现「塔罗」**。禁止神谕吉凶、恐吓话术。
| Phase A `[Ops]` | 登录 · 用户/订单查询 · 会员授予 · 审计 · `apps/admin-h5`ECR-006 Closed |
| Phase B `[Ops]` | 行为分析:自有埋点 + 管理端「数据」看板(**ECR-007 Closed** · Spec `ops-analytics.md` |
| Phase C `[Ops]` | 内容:首页宫格 CRUD · 测评上下架(**ECR-008 Closed** · Spec `ops-content.md` |
| Phase D+ | **Contract-First****ECR-013A…017 Closed** → **ECR-018** EntitlementLoop)。`docs/WAVE0/LOOP_AUTHORIZATION.md`。 |
| Phase D+ | **Contract-First****ECR-013A…018 Closed** → **ECR-019** ContentSafetyLoop)。`docs/WAVE0/LOOP_AUTHORIZATION.md`。 |
| 排除 | **UGC / 社区广场**M10.2)仍 `[No]`;真支付最后 |
不计入 P1 Complete;不进入五 Tab。
+1
View File
@@ -28,6 +28,7 @@
| [ops-user-intelligence.md](ops-user-intelligence.md) | 用户洞察 UserIntelligence | §7 | `admin-h5` 用户详情「洞察」· `GET /admin/users/:id/insight` | Ops-D · **ECR-016 Closed** |
| [ops-ask-operations.md](ops-ask-operations.md) | 问答运营 AskOperations | §7 | `admin-h5` `/ask` · `GET /admin/ask/threads*` | Ops-D · **ECR-017 Closed** |
| [ops-entitlement.md](ops-entitlement.md) | 用户权益 Entitlement | §7 | `admin-h5` 用户详情「权益」· `GET /admin/users/:id/entitlements` | Ops-D · **ECR-018 Closed** |
| [ops-content-safety.md](ops-content-safety.md) | 内容安全 ContentSafety | §7 | `admin-h5` `/safety` · `GET /admin/content-safety/*` | Ops-D · **ECR-019 Closed** |
新功能:复制 `_TEMPLATE.md` → 填满 → 在本表登记 → 再编码。
+2 -1
View File
@@ -215,6 +215,7 @@ Phase A 可先 `console`/本地;不挡验收。
| **IECR-016 Closed** | UserIntelligence — Spec `ops-user-intelligence.md` |
| **JECR-017 Closed** | AskOperations — Spec `ops-ask-operations.md` |
| **KECR-018 Closed** | Entitlement — Spec `ops-entitlement.md` |
| 后置 | ContentSafety / QualityFeedback / AICoreConfigLoop 续跑) |
| **LECR-019 Closed** | ContentSafety FilterRule — Spec `ops-content-safety.md` |
| 后置 | QualityFeedback / AICoreConfig / CrisisCareLoop 续跑) |
| D | 订单筛选 · 展示价 · 退款只读(另开 ECR) |
| 后置 | 封禁加深(Account_Risk)· 推送占位 |
@@ -0,0 +1,42 @@
# Feature Spec: ContentSafety · FilterRuleOps · ECR-019
> Status: `Active`Loop continuous · **ECR-019 Closed**
> Parent: WAVE0-FROZEN · Predecessor: ECR-018 Closed
> Capability: `ContentSafety` · BC: `Content_Safety`
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
## Non-goals
ModerationCase 写回 · CrisisPolicy 配置 · 真 NLP/厂商审核 · UGC · 真支付 · 用户侧硬拦截上线(本切片仅运营只读 + 试匹配)
## L2 Domain
| 概念 | 语义 |
|------|------|
| `FilterRule` | code 唯一;category ∈ {crisis,abuse,spam,pii}action ∈ {flag,block,escalate}system 种子不可本切片删除 |
## L3 API
| Method | Path | 权限 | 语义 |
|--------|------|------|------|
| GET | `/admin/content-safety/filter-rules` | `admin.content_safety.read` | 列表 |
| GET | `/admin/content-safety/filter-rules/:id` | 同上 | 详情 |
| POST | `/admin/content-safety/evaluate` | 同上 | 试匹配(不写工单) |
## Migration
`000020``filter_rules` + 种子规则 + 授予 `admin.content_safety.read`
## L4 AC
| ID | Then |
|----|------|
| AC-F-01 | list 含 system 种子 ≥1 |
| AC-F-02 | evaluate 命中种子 pattern → matches 非空 |
| AC-F-03 | get 未知 id → 404 |
| AC-S-01 | 无 Admin → 401 |
| AC-S-02 | 无 content_safety.read → 403 |
| AC-P-01 | list &lt; 500ms |
| AC-O-01 | evaluate 不写 Audit(只读试匹配) |
contract_diff: `docs/CONTRACT_DIFF/ECR-019.yaml`
+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;
@@ -0,0 +1,25 @@
# Backend Design: ECR-019 ContentSafety
| ID | BD-2026-019 |
| Status | Approved |
| Coding | Loop authorized |
| Level | L2 |
| Migration | YES 000020 |
## Backend Change Boundary
```text
Domain: FilterRule (read) + evaluate (no ModerationCase write)
App: AdminHandler → admin.Service → AdminRepo
API: GET /admin/content-safety/filter-rules[+/:id]
POST /admin/content-safety/evaluate
Permission: admin.content_safety.read
Migration: 000020 table + seed + perm
UI: admin-h5 /safety
```
## Out of boundary
ModerationCase · CrisisPolicy write · UGC · Payment · external moderation vendor
Rollback: down migration + remove routes/UI
+1
View File
@@ -2,6 +2,7 @@
## 2026-08-07
- **ECR-019 Closed**ContentSafety FilterRule`filter_rules` · evaluate · admin-h5 `/safety` · migration 000020
- **ECR-018 Closed**Entitlement`GET /admin/users/:id/entitlements` · admin-h5 权益 Tab · Migration NO
- **ECR-017 Closed**AskOperationsAskSessionView 只读 · `admin.ask.read` · admin-h5 `/ask` · migration 000019
- **ECR-016 Closed**UserIntelligence`GET /admin/users/:id/insight` 只读聚合 · admin-h5 洞察 Tab · Migration NO
+9
View File
@@ -0,0 +1,9 @@
# CODE_REVIEW — ECR-019
**Verdict:** Approve → Closed
Date: 2026-08-07 · Loop continuous
- FilterRule 表 + 只读/试匹配;无 ModerationCase 写
- Handler → Service → Repository;无 UGC / 真支付
- Integration AC mapped · OpenAPI updated
+26
View File
@@ -0,0 +1,26 @@
ecr: ECR-019
capability: ContentSafety
bounded_context: Content_Safety
parent: WAVE0-FROZEN
predecessor: ECR-018
change:
type: additive
breaking_change: false
migration_required: true
compatibility_notes: >
Adds filter_rules table with system seeds and read/evaluate admin APIs.
No ModerationCase / Crisis write paths.
apis:
- method: GET
path: /api/v1/admin/content-safety/filter-rules
change: added
- method: GET
path: /api/v1/admin/content-safety/filter-rules/{id}
change: added
- method: POST
path: /api/v1/admin/content-safety/evaluate
change: added
perms:
- code: admin.content_safety.read
change: added
+19
View File
@@ -0,0 +1,19 @@
# ECR-019
**Title:** ContentSafety · FilterRule(只读薄切片)
**Status:** **Closed**
**Closed:** 2026-08-07Loop continuous
**Parent:** WAVE0-FROZEN · **Predecessor:** ECR-018 Closed
**Change Level:** L2
## Change
`filter_rules` + `GET /admin/content-safety/filter-rules*` · `POST .../evaluate`;权限 `admin.content_safety.read`admin-h5「安全」页。
## Forbidden
ModerationCase 写 · Crisis 配置写 · UGC · 真支付 · 厂商审核接入
## Linked
Spec `ops-content-safety.md` · BD-2026-019 · CONTRACT_DIFF/ECR-019.yaml · TEST_REPORT/ECR-019.md
@@ -0,0 +1,7 @@
# ENGINEERING_SPEC — ECR-019
1. migration 000020 filter_rules + perm
2. AdminRepo list/get/evaluate
3. Admin API + OpenAPI
4. admin-h5 /safety
5. Integration · Closed
@@ -0,0 +1,3 @@
# HANDOFF — ECR-019 Architect → Engineer
Loop continuous · Approved + Coding. Migration 000020. Forbidden: ModerationCase写/UGC/真支付.
@@ -0,0 +1,3 @@
# HANDOFF — ECR-019 Engineer → Reviewer
TestContentSafetyFilterRules PASS · /safety · Ready for Closed.
@@ -0,0 +1,3 @@
# PRODUCT_SPEC — ECR-019
对齐 ops-content-safety.md · Approved · Loop · L2 · FilterRule 只读
+6
View File
@@ -0,0 +1,6 @@
# STATE — ECR-019
| Status | **Closed** |
| Phase | closed |
| Spec | ops-content-safety.md |
| Updated | 2026-08-07 |
+12
View File
@@ -0,0 +1,12 @@
id: TASK-019-ECR019
ecr: ECR-019
title: ContentSafety FilterRule implement
role: engineer
status: closed
change_level: L2
parent: WAVE0-FROZEN
predecessor: ECR-018
acceptance:
- Spec AC mapped
- FilterRule read + evaluate only
- No UGC / payment / ModerationCase write
+32
View File
@@ -0,0 +1,32 @@
# TEST_REPORT — ECR-019 ContentSafety
Date: 2026-08-07 · Loop continuous · commit: (pending)
## Commands
```bash
cd apps/api && go test ./internal/integration/ -run TestContentSafetyFilterRules -count=1
npm run build:admin
python3 scripts/ess-validate.py --phase review --ecr ECR-019
python3 scripts/ess-gate-check.py --ecr ECR-019
```
## Results
| Check | Result |
|-------|--------|
| TestContentSafetyFilterRules | PASS |
| build:admin | PASS |
| ess-validate review | PASS |
## AC
| ID | Evidence |
|----|----------|
| AC-F-01 | list 含 system 种子 |
| AC-F-02 | evaluate「不想活了」命中 |
| AC-F-03 | 未知 id → 404 |
| AC-S-01 | 无 token → 401 |
| AC-S-02 | 仅 users.read → 403 |
| AC-P-01 | list &lt; 500ms |
| AC-O-01 | evaluate 不写工单 |
+1
View File
@@ -23,3 +23,4 @@
| ECR-016 | UserIntelligence | **Closed** | Spec ops-user-intelligence · BD-2026-016 · TEST_REPORT · CODE_REVIEW · Loop continuous |
| ECR-017 | AskOperations | **Closed** | Spec ops-ask-operations · BD-2026-017 · migration 000019 · TEST_REPORT · CODE_REVIEW · Loop continuous |
| ECR-018 | Entitlement | **Closed** | Spec ops-entitlement · BD-2026-018 · TEST_REPORT · CODE_REVIEW · Loop continuous |
| ECR-019 | ContentSafety | **Closed** | Spec ops-content-safety · BD-2026-019 · migration 000020 · TEST_REPORT · CODE_REVIEW · Loop continuous |
+1 -1
View File
@@ -30,4 +30,4 @@ Human 明文:**直接用 Loop,不用人工确认。**
| Done | Next |
|------|------|
| ECR-013A/B/014/015/016/017/018 Closed | **ECR-019** ContentSafety 薄切片(FilterRule 只读优先);禁真支付/UGC |
| ECR-013A…019 Closed | **ECR-020** QualityFeedback 薄切片(或 AICoreConfig);禁真支付/UGC |
+45
View File
@@ -416,6 +416,51 @@ paths:
'404':
description: Not found
/api/v1/admin/content-safety/filter-rules:
get:
tags: [admin]
summary: List FilterRule
description: Requires admin.content_safety.read
responses:
'200':
description: OK
'401':
description: Unauthorized
'403':
description: Forbidden
/api/v1/admin/content-safety/filter-rules/{id}:
get:
tags: [admin]
summary: Get FilterRule
parameters:
- in: path
name: id
required: true
schema: { type: string, format: uuid }
responses:
'200':
description: OK
'404':
description: Not found
/api/v1/admin/content-safety/evaluate:
post:
tags: [admin]
summary: Preview FilterRule matches (no ModerationCase write)
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [text]
properties:
text: { type: string }
responses:
'200':
description: OK
/api/v1/admin/orders:
get:
tags: [admin]