diff --git a/.ai/domain/domain-map.md b/.ai/domain/domain-map.md index c439728..82b4109 100644 --- a/.ai/domain/domain-map.md +++ b/.ai/domain/domain-map.md @@ -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-013B(Identity_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** | diff --git a/.ai/domain/entity-catalog.md b/.ai/domain/entity-catalog.md index ad9d96a..c73fd6f 100644 --- a/.ai/domain/entity-catalog.md +++ b/.ai/domain/entity-catalog.md @@ -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 | 后置 | diff --git a/.ai/product/feature-map.md b/.ai/product/feature-map.md index 7363f8e..ab00f2f 100644 --- a/.ai/product/feature-map.md +++ b/.ai/product/feature-map.md @@ -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** Entitlement(Loop)。`docs/WAVE0/LOOP_AUTHORIZATION.md`。 | +| Phase D+ | **Contract-First**:**ECR-013A…018 Closed** → **ECR-019** ContentSafety(Loop)。`docs/WAVE0/LOOP_AUTHORIZATION.md`。 | | 排除 | **UGC / 社区广场**(M10.2)仍 `[No]`;真支付最后 | 不计入 P1 Complete;不进入五 Tab。 diff --git a/.ai/product/feature-spec/README.md b/.ai/product/feature-spec/README.md index 33b8a41..af18cd7 100644 --- a/.ai/product/feature-spec/README.md +++ b/.ai/product/feature-spec/README.md @@ -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` → 填满 → 在本表登记 → 再编码。 diff --git a/.ai/product/feature-spec/ops-admin.md b/.ai/product/feature-spec/ops-admin.md index c43f0f5..c656b48 100644 --- a/.ai/product/feature-spec/ops-admin.md +++ b/.ai/product/feature-spec/ops-admin.md @@ -215,6 +215,7 @@ Phase A 可先 `console`/本地;不挡验收。 | **I(ECR-016 Closed)** | UserIntelligence — Spec `ops-user-intelligence.md` | | **J(ECR-017 Closed)** | AskOperations — Spec `ops-ask-operations.md` | | **K(ECR-018 Closed)** | Entitlement — Spec `ops-entitlement.md` | -| 后置 | ContentSafety / QualityFeedback / AICoreConfig(Loop 续跑) | +| **L(ECR-019 Closed)** | ContentSafety FilterRule — Spec `ops-content-safety.md` | +| 后置 | QualityFeedback / AICoreConfig / CrisisCare(Loop 续跑) | | D | 订单筛选 · 展示价 · 退款只读(另开 ECR) | | 后置 | 封禁加深(Account_Risk)· 推送占位 | diff --git a/.ai/product/feature-spec/ops-content-safety.md b/.ai/product/feature-spec/ops-content-safety.md new file mode 100644 index 0000000..fd376c0 --- /dev/null +++ b/.ai/product/feature-spec/ops-content-safety.md @@ -0,0 +1,42 @@ +# Feature Spec: ContentSafety · FilterRule(Ops · 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 < 500ms | +| AC-O-01 | evaluate 不写 Audit(只读试匹配) | + +contract_diff: `docs/CONTRACT_DIFF/ECR-019.yaml` diff --git a/apps/admin-h5/src/api/client.ts b/apps/admin-h5/src/api/client.ts index ca3992d..e67177d 100644 --- a/apps/admin-h5/src/api/client.ts +++ b/apps/admin-h5/src/api/client.ts @@ -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<{ diff --git a/apps/admin-h5/src/layouts/AdminShell.vue b/apps/admin-h5/src/layouts/AdminShell.vue index 3d9aed1..84464b4 100644 --- a/apps/admin-h5/src/layouts/AdminShell.vue +++ b/apps/admin-h5/src/layouts/AdminShell.vue @@ -31,6 +31,7 @@ async function onLogout() { 套餐 兑换码 问答 + 安全 订单 审计 diff --git a/apps/admin-h5/src/pages/SafetyPage.vue b/apps/admin-h5/src/pages/SafetyPage.vue new file mode 100644 index 0000000..167afb7 --- /dev/null +++ b/apps/admin-h5/src/pages/SafetyPage.vue @@ -0,0 +1,100 @@ + + + + + 内容安全 + FilterRule 只读 · 试匹配不写审核工单 + 加载中… + {{ error }} + + + 过滤规则 + 暂无规则 + + + 代码分类动作模式状态 + + + + {{ r.title }} {{ r.code }} + {{ r.category }} + {{ r.action }} + {{ r.pattern }} + {{ r.active ? '启用' : '停用' }}{{ r.system ? ' · 系统' : '' }} + + + + + + 试匹配 + + + 试匹配 + {{ evalMsg }} + + + + {{ m.title }} · {{ m.category }} · {{ m.action }} + + + + + + + + diff --git a/apps/admin-h5/src/router/index.ts b/apps/admin-h5/src/router/index.ts index 30635e0..4be8e1d 100644 --- a/apps/admin-h5/src/router/index.ts +++ b/apps/admin-h5/src/router/index.ts @@ -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') }, ], }, diff --git a/apps/api/internal/handler/admin.go b/apps/api/internal/handler/admin.go index 9e20c14..6dd3c26 100644 --- a/apps/api/internal/handler/admin.go +++ b/apps/api/internal/handler/admin.go @@ -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) { diff --git a/apps/api/internal/handler/admin_content_safety.go b/apps/api/internal/handler/admin_content_safety.go new file mode 100644 index 0000000..6ca711b --- /dev/null +++ b/apps/api/internal/handler/admin_content_safety.go @@ -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}) +} diff --git a/apps/api/internal/integration/content_safety_test.go b/apps/api/internal/integration/content_safety_test.go new file mode 100644 index 0000000..5b7ac52 --- /dev/null +++ b/apps/api/internal/integration/content_safety_test.go @@ -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) + } +} diff --git a/apps/api/internal/repository/content_safety_repo.go b/apps/api/internal/repository/content_safety_repo.go new file mode 100644 index 0000000..54fb6aa --- /dev/null +++ b/apps/api/internal/repository/content_safety_repo.go @@ -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 +} diff --git a/apps/api/internal/service/admin/content_safety.go b/apps/api/internal/service/admin/content_safety.go new file mode 100644 index 0000000..f88134b --- /dev/null +++ b/apps/api/internal/service/admin/content_safety.go @@ -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) +} diff --git a/apps/api/internal/service/admin/rbac.go b/apps/api/internal/service/admin/rbac.go index bf9889a..f85e5c1 100644 --- a/apps/api/internal/service/admin/rbac.go +++ b/apps/api/internal/service/admin/rbac.go @@ -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 ( diff --git a/apps/api/migrations/000020_content_safety_filter.down.sql b/apps/api/migrations/000020_content_safety_filter.down.sql new file mode 100644 index 0000000..0c4daed --- /dev/null +++ b/apps/api/migrations/000020_content_safety_filter.down.sql @@ -0,0 +1,4 @@ +-- ECR-019 down + +DELETE FROM admin_role_permissions WHERE code = 'admin.content_safety.read'; +DROP TABLE IF EXISTS filter_rules; diff --git a/apps/api/migrations/000020_content_safety_filter.up.sql b/apps/api/migrations/000020_content_safety_filter.up.sql new file mode 100644 index 0000000..83763c2 --- /dev/null +++ b/apps/api/migrations/000020_content_safety_filter.up.sql @@ -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; diff --git a/docs/BACKEND_DESIGN/BD-2026-019-content-safety.md b/docs/BACKEND_DESIGN/BD-2026-019-content-safety.md new file mode 100644 index 0000000..e854105 --- /dev/null +++ b/docs/BACKEND_DESIGN/BD-2026-019-content-safety.md @@ -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 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a277c6a..95c20ca 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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**:AskOperations(AskSessionView 只读 · `admin.ask.read` · admin-h5 `/ask` · migration 000019) - **ECR-016 Closed**:UserIntelligence(`GET /admin/users/:id/insight` 只读聚合 · admin-h5 洞察 Tab · Migration NO) diff --git a/docs/CODE_REVIEW/ECR-019.md b/docs/CODE_REVIEW/ECR-019.md new file mode 100644 index 0000000..0caf21f --- /dev/null +++ b/docs/CODE_REVIEW/ECR-019.md @@ -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 diff --git a/docs/CONTRACT_DIFF/ECR-019.yaml b/docs/CONTRACT_DIFF/ECR-019.yaml new file mode 100644 index 0000000..2c15d4a --- /dev/null +++ b/docs/CONTRACT_DIFF/ECR-019.yaml @@ -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 diff --git a/docs/ECR/ECR-019-content-safety.md b/docs/ECR/ECR-019-content-safety.md new file mode 100644 index 0000000..c511c0f --- /dev/null +++ b/docs/ECR/ECR-019-content-safety.md @@ -0,0 +1,19 @@ +# ECR-019 + +**Title:** ContentSafety · FilterRule(只读薄切片) +**Status:** **Closed** +**Closed:** 2026-08-07(Loop 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 diff --git a/docs/ENGINEERING_SPEC/ECR-019-content-safety.md b/docs/ENGINEERING_SPEC/ECR-019-content-safety.md new file mode 100644 index 0000000..d3c41ec --- /dev/null +++ b/docs/ENGINEERING_SPEC/ECR-019-content-safety.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 diff --git a/docs/HANDOFF/ECR-019-architect-to-engineer.md b/docs/HANDOFF/ECR-019-architect-to-engineer.md new file mode 100644 index 0000000..1ddecc0 --- /dev/null +++ b/docs/HANDOFF/ECR-019-architect-to-engineer.md @@ -0,0 +1,3 @@ +# HANDOFF — ECR-019 Architect → Engineer + +Loop continuous · Approved + Coding. Migration 000020. Forbidden: ModerationCase写/UGC/真支付. diff --git a/docs/HANDOFF/ECR-019-engineer-to-reviewer.md b/docs/HANDOFF/ECR-019-engineer-to-reviewer.md new file mode 100644 index 0000000..8fbb42d --- /dev/null +++ b/docs/HANDOFF/ECR-019-engineer-to-reviewer.md @@ -0,0 +1,3 @@ +# HANDOFF — ECR-019 Engineer → Reviewer + +TestContentSafetyFilterRules PASS · /safety · Ready for Closed. diff --git a/docs/PRODUCT_SPEC/ECR-019-content-safety.md b/docs/PRODUCT_SPEC/ECR-019-content-safety.md new file mode 100644 index 0000000..a0a3a6b --- /dev/null +++ b/docs/PRODUCT_SPEC/ECR-019-content-safety.md @@ -0,0 +1,3 @@ +# PRODUCT_SPEC — ECR-019 + +对齐 ops-content-safety.md · Approved · Loop · L2 · FilterRule 只读 diff --git a/docs/STATE/ECR-019.md b/docs/STATE/ECR-019.md new file mode 100644 index 0000000..5313bb1 --- /dev/null +++ b/docs/STATE/ECR-019.md @@ -0,0 +1,6 @@ +# STATE — ECR-019 + +| Status | **Closed** | +| Phase | closed | +| Spec | ops-content-safety.md | +| Updated | 2026-08-07 | diff --git a/docs/TASKS/TASK-019-ECR019.yaml b/docs/TASKS/TASK-019-ECR019.yaml new file mode 100644 index 0000000..25198c4 --- /dev/null +++ b/docs/TASKS/TASK-019-ECR019.yaml @@ -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 diff --git a/docs/TEST_REPORT/ECR-019.md b/docs/TEST_REPORT/ECR-019.md new file mode 100644 index 0000000..737ae04 --- /dev/null +++ b/docs/TEST_REPORT/ECR-019.md @@ -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 < 500ms | +| AC-O-01 | evaluate 不写工单 | diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index f676961..ef1e114 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -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 | diff --git a/docs/WAVE0/LOOP_AUTHORIZATION.md b/docs/WAVE0/LOOP_AUTHORIZATION.md index 84c74f2..ef9045e 100644 --- a/docs/WAVE0/LOOP_AUTHORIZATION.md +++ b/docs/WAVE0/LOOP_AUTHORIZATION.md @@ -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 | diff --git a/proto/openapi.yaml b/proto/openapi.yaml index 3d10fa4..3e340fc 100644 --- a/proto/openapi.yaml +++ b/proto/openapi.yaml @@ -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]
FilterRule 只读 · 试匹配不写审核工单
加载中…
{{ error }}
暂无规则
{{ r.code }}