diff --git a/.ai/domain/domain-map.md b/.ai/domain/domain-map.md index f5b7254..018b897 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 | ContentSafety **ECR-019**(FilterRule);CrisisCare Draft | +| Content_Safety | ContentSafety · CrisisCare | ContentSafety **ECR-019**;CrisisCare **ECR-022**(CrisisPolicy 只读) | | Ask_Ops | AskOperations · AICoreConfig | AskOperations **ECR-017/020**;AICoreConfig **ECR-021**(SystemPrompt 只读) | | Ops_CMS_NoUGC | OpsCMS | Draft | | Community | — | **Forbidden** | diff --git a/.ai/domain/entity-catalog.md b/.ai/domain/entity-catalog.md index df782e8..bc78d1a 100644 --- a/.ai/domain/entity-catalog.md +++ b/.ai/domain/entity-catalog.md @@ -30,7 +30,7 @@ Status: `Draft` | AnalyticsSession / Event | Analytics_OpsB | GrowthInsights | 已存在 Ops-B | | FilterRule | Content_Safety | ContentSafety | **ECR-019** 只读 + evaluate | | ModerationCase | Content_Safety | ContentSafety | 后置 | -| CrisisEvent / CrisisPolicy | Content_Safety | CrisisCare | 后置 | +| CrisisEvent / CrisisPolicy | Content_Safety | CrisisCare | **ECR-022** CrisisPolicy 只读;CrisisEvent 后置 | | SystemPrompt / Knowledge* / ToolDefinition | Ask_Ops | AICoreConfig | **ECR-021** SystemPrompt 只读;Knowledge/Tools 后置 | | AskSessionView / QualityFeedback | Ask_Ops | AskOperations | **ECR-017** AskSessionView;**ECR-020** QualityFeedback | | Banner / FeedSlot | Ops_CMS_NoUGC | OpsCMS | 后置 | diff --git a/.ai/product/feature-map.md b/.ai/product/feature-map.md index 0c63e2e..17c75d5 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…020 Closed** → **ECR-021** AICoreConfig(Loop)。`docs/WAVE0/LOOP_AUTHORIZATION.md`。 | +| Phase D+ | **Contract-First**:**ECR-013A…021 Closed** → **ECR-022** CrisisCare(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 f3225f0..b1cf4b9 100644 --- a/.ai/product/feature-spec/README.md +++ b/.ai/product/feature-spec/README.md @@ -31,6 +31,7 @@ | [ops-content-safety.md](ops-content-safety.md) | 内容安全 ContentSafety | §7 | `admin-h5` `/safety` · `GET /admin/content-safety/*` | Ops-D · **ECR-019 Closed** | | [ops-quality-feedback.md](ops-quality-feedback.md) | 问答质量反馈 QualityFeedback | §7 | `admin-h5` `/ask` · `GET/POST /admin/ask/feedback*` | Ops-D · **ECR-020 Closed** | | [ops-ai-core-config.md](ops-ai-core-config.md) | AI 核心配置 AICoreConfig | §7 | `admin-h5` `/ai` · `GET /admin/ai/system-prompts*` | Ops-D · **ECR-021 Closed** | +| [ops-crisis-care.md](ops-crisis-care.md) | 危机关怀 CrisisCare | §7 | `admin-h5` `/crisis` · `GET /admin/crisis/policies*` | Ops-D · **ECR-022 Closed** | 新功能:复制 `_TEMPLATE.md` → 填满 → 在本表登记 → 再编码。 diff --git a/.ai/product/feature-spec/ops-admin.md b/.ai/product/feature-spec/ops-admin.md index e689fe1..aa00980 100644 --- a/.ai/product/feature-spec/ops-admin.md +++ b/.ai/product/feature-spec/ops-admin.md @@ -218,6 +218,7 @@ Phase A 可先 `console`/本地;不挡验收。 | **L(ECR-019 Closed)** | ContentSafety FilterRule — Spec `ops-content-safety.md` | | **M(ECR-020 Closed)** | QualityFeedback — Spec `ops-quality-feedback.md` | | **N(ECR-021 Closed)** | AICoreConfig SystemPrompt — Spec `ops-ai-core-config.md` | -| 后置 | CrisisCare / Knowledge·Tools 写面(Loop 续跑) | +| **O(ECR-022 Closed)** | CrisisCare CrisisPolicy — Spec `ops-crisis-care.md` | +| 后置 | Knowledge·Tools 写面 / CrisisEvent / 真支付(Loop 续跑) | | D | 订单筛选 · 展示价 · 退款只读(另开 ECR) | | 后置 | 封禁加深(Account_Risk)· 推送占位 | diff --git a/.ai/product/feature-spec/ops-crisis-care.md b/.ai/product/feature-spec/ops-crisis-care.md new file mode 100644 index 0000000..0ba759f --- /dev/null +++ b/.ai/product/feature-spec/ops-crisis-care.md @@ -0,0 +1,42 @@ +# Feature Spec: CrisisCare · CrisisPolicy(Ops · ECR-022) + +> Status: `Active`(Loop continuous · **ECR-022 Closed**) +> Parent: WAVE0-FROZEN · Predecessor: ECR-021 Closed +> Capability: `CrisisCare` · BC: `Content_Safety` +> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md` + +## Non-goals + +CrisisEvent 入库工单 · InterventionOutcome · 策略在线编辑 · 医疗诊断结论 · UGC · 真支付 · 用户侧硬熔断上线 + +## L2 Domain + +| 概念 | 语义 | +|------|------| +| `CrisisPolicy` | code 唯一;severity ∈ {high,critical};action ∈ {escalate,block,show_helpline};helpline_text 可选;本切片只读 | + +## L3 API + +| Method | Path | 权限 | 语义 | +|--------|------|------|------| +| GET | `/admin/crisis/policies` | `admin.crisis.read` | 列表 | +| GET | `/admin/crisis/policies/:id` | 同上 | 详情 | +| POST | `/admin/crisis/evaluate` | 同上 | 试匹配(不写 CrisisEvent) | + +## Migration + +`000023`:`crisis_policies` + 种子 + 授予权限 + +## L4 AC + +| ID | Then | +|----|------| +| AC-F-01 | list 含 system 种子 ≥1 | +| AC-F-02 | evaluate 命中 pattern → matches 非空 | +| AC-F-03 | 未知 id → 404 | +| AC-S-01 | 无 Admin → 401 | +| AC-S-02 | 无 crisis.read → 403 | +| AC-P-01 | list < 500ms | +| AC-O-01 | evaluate 不写 Audit/Event | + +contract_diff: `docs/CONTRACT_DIFF/ECR-022.yaml` diff --git a/apps/admin-h5/src/api/client.ts b/apps/admin-h5/src/api/client.ts index aafb0e8..69b7339 100644 --- a/apps/admin-h5/src/api/client.ts +++ b/apps/admin-h5/src/api/client.ts @@ -321,6 +321,44 @@ export const adminApi = { system: boolean updated_at: string }>('GET', `/ai/system-prompts/${id}`), + crisisPolicies: () => + request<{ + items: Array<{ + id: string + code: string + title: string + severity: string + pattern: string + action: string + helpline_text?: string + active: boolean + system: boolean + updated_at: string + }> + }>('GET', '/crisis/policies'), + crisisPolicy: (id: string) => + request<{ + id: string + code: string + title: string + severity: string + pattern: string + action: string + helpline_text?: string + active: boolean + system: boolean + updated_at: string + }>('GET', `/crisis/policies/${id}`), + evaluateCrisis: (text: string) => + request<{ + matches: Array<{ + code: string + title: string + severity: string + action: string + helpline_text?: string + }> + }>('POST', '/crisis/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 4268626..e44b610 100644 --- a/apps/admin-h5/src/layouts/AdminShell.vue +++ b/apps/admin-h5/src/layouts/AdminShell.vue @@ -33,6 +33,7 @@ async function onLogout() { 问答 安全 AI + 危机 订单 审计 diff --git a/apps/admin-h5/src/pages/CrisisPage.vue b/apps/admin-h5/src/pages/CrisisPage.vue new file mode 100644 index 0000000..d5d47d1 --- /dev/null +++ b/apps/admin-h5/src/pages/CrisisPage.vue @@ -0,0 +1,103 @@ + + + + + 危机策略 + CrisisPolicy 只读 · 试匹配不写 CrisisEvent · 非医疗诊断 + 加载中… + {{ error }} + + + 策略目录 + 暂无 + + + 代码严重度动作模式 + + + + {{ p.title }} {{ p.code }} + {{ p.severity }} + {{ p.action }} + {{ p.pattern }} + + + + + + 试匹配 + + + 试匹配 + {{ evalMsg }} + + + + {{ m.title }} · {{ m.severity }} · {{ m.action }} + {{ m.helpline_text }} + + + + + + + + diff --git a/apps/admin-h5/src/router/index.ts b/apps/admin-h5/src/router/index.ts index 0965630..880e272 100644 --- a/apps/admin-h5/src/router/index.ts +++ b/apps/admin-h5/src/router/index.ts @@ -20,6 +20,7 @@ const router = createRouter({ { path: 'ask', name: 'ask', component: () => import('@/pages/AskPage.vue') }, { path: 'safety', name: 'safety', component: () => import('@/pages/SafetyPage.vue') }, { path: 'ai', name: 'ai', component: () => import('@/pages/AIConfigPage.vue') }, + { path: 'crisis', name: 'crisis', component: () => import('@/pages/CrisisPage.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 d86b383..59cfb15 100644 --- a/apps/api/internal/handler/admin.go +++ b/apps/api/internal/handler/admin.go @@ -52,6 +52,7 @@ func (h *AdminHandler) Register(api *gin.RouterGroup) { h.registerEntitlement(authed) h.registerContentSafety(authed) h.registerAIConfig(authed) + h.registerCrisis(authed) } func (h *AdminHandler) Login(c *gin.Context) { diff --git a/apps/api/internal/handler/admin_crisis.go b/apps/api/internal/handler/admin_crisis.go new file mode 100644 index 0000000..d67e6f2 --- /dev/null +++ b/apps/api/internal/handler/admin_crisis.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) registerCrisis(authed *gin.RouterGroup) { + g := authed.Group("/crisis") + g.GET("/policies", middleware.RequireAdminPermission(h.Svc, admin.PermCrisisRead), h.ListCrisisPolicies) + g.GET("/policies/:id", middleware.RequireAdminPermission(h.Svc, admin.PermCrisisRead), h.GetCrisisPolicy) + g.POST("/evaluate", middleware.RequireAdminPermission(h.Svc, admin.PermCrisisRead), h.EvaluateCrisis) +} + +func (h *AdminHandler) ListCrisisPolicies(c *gin.Context) { + items, err := h.Svc.ListCrisisPolicies(c.Request.Context()) + if err != nil { + response.Fail(c, http.StatusInternalServerError, 50029, "list crisis policies failed") + return + } + response.OK(c, gin.H{"items": items}) +} + +func (h *AdminHandler) GetCrisisPolicy(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.GetCrisisPolicy(c.Request.Context(), id) + if errors.Is(err, admin.ErrCrisisPolicyNotFound) { + response.Fail(c, http.StatusNotFound, 40405, "crisis policy not found") + return + } + if err != nil { + response.Fail(c, http.StatusInternalServerError, 50030, "get crisis policy failed") + return + } + response.OK(c, row) +} + +func (h *AdminHandler) EvaluateCrisis(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.EvaluateCrisis(c.Request.Context(), body.Text) + if err != nil { + response.Fail(c, http.StatusInternalServerError, 50031, "evaluate failed") + return + } + response.OK(c, gin.H{"matches": matches}) +} diff --git a/apps/api/internal/integration/crisis_test.go b/apps/api/internal/integration/crisis_test.go new file mode 100644 index 0000000..251c367 --- /dev/null +++ b/apps/api/internal/integration/crisis_test.go @@ -0,0 +1,103 @@ +package integration_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "testing" + "time" + + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" +) + +func TestCrisisCarePolicies(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/crisis/policies", 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, "cr_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("crlim_%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/crisis/policies", 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/crisis/policies", 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 crisis policies") + } + firstID := list.Items[0].ID + + env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/crisis/policies/"+firstID, nil, tok) + if code != 200 { + t.Fatalf("get %d", code) + } + + _, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/crisis/policies/"+fakeUUID(), nil, tok) + if code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", code) + } + + env, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/crisis/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/crisis_repo.go b/apps/api/internal/repository/crisis_repo.go new file mode 100644 index 0000000..5b7ff09 --- /dev/null +++ b/apps/api/internal/repository/crisis_repo.go @@ -0,0 +1,102 @@ +package repository + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +// CrisisPolicyRow is CrisisCare CrisisPolicy catalog. +type CrisisPolicyRow struct { + ID uuid.UUID `json:"id"` + Code string `json:"code"` + Title string `json:"title"` + Severity string `json:"severity"` + Pattern string `json:"pattern"` + Action string `json:"action"` + HelplineText *string `json:"helpline_text,omitempty"` + Active bool `json:"active"` + System bool `json:"system"` + UpdatedAt time.Time `json:"updated_at"` +} + +// CrisisMatch is one evaluate hit. +type CrisisMatch struct { + Code string `json:"code"` + Title string `json:"title"` + Severity string `json:"severity"` + Action string `json:"action"` + HelplineText *string `json:"helpline_text,omitempty"` +} + +// ListCrisisPolicies returns policies active-first. +func (r *AdminRepo) ListCrisisPolicies(ctx context.Context) ([]CrisisPolicyRow, error) { + rows, err := r.Pool.Query(ctx, ` + SELECT id, code, title, severity, pattern, action, helpline_text, active, system, updated_at + FROM crisis_policies + ORDER BY active DESC, severity DESC, code ASC`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []CrisisPolicyRow + for rows.Next() { + var p CrisisPolicyRow + if err := rows.Scan( + &p.ID, &p.Code, &p.Title, &p.Severity, &p.Pattern, &p.Action, &p.HelplineText, + &p.Active, &p.System, &p.UpdatedAt, + ); err != nil { + return nil, err + } + out = append(out, p) + } + return out, rows.Err() +} + +// GetCrisisPolicy loads one policy. +func (r *AdminRepo) GetCrisisPolicy(ctx context.Context, id uuid.UUID) (*CrisisPolicyRow, error) { + var p CrisisPolicyRow + err := r.Pool.QueryRow(ctx, ` + SELECT id, code, title, severity, pattern, action, helpline_text, active, system, updated_at + FROM crisis_policies WHERE id=$1`, id, + ).Scan( + &p.ID, &p.Code, &p.Title, &p.Severity, &p.Pattern, &p.Action, &p.HelplineText, + &p.Active, &p.System, &p.UpdatedAt, + ) + if errors.Is(err, pgx.ErrNoRows) { + return nil, err + } + if err != nil { + return nil, err + } + return &p, nil +} + +// EvaluateCrisisPolicies runs substring match preview (ops only). +func (r *AdminRepo) EvaluateCrisisPolicies(ctx context.Context, text string) ([]CrisisMatch, error) { + policies, err := r.ListCrisisPolicies(ctx) + if err != nil { + return nil, err + } + lower := strings.ToLower(text) + var out []CrisisMatch + for _, p := range policies { + if !p.Active || p.Pattern == "" { + continue + } + if strings.Contains(lower, strings.ToLower(p.Pattern)) { + out = append(out, CrisisMatch{ + Code: p.Code, Title: p.Title, Severity: p.Severity, + Action: p.Action, HelplineText: p.HelplineText, + }) + } + } + if out == nil { + out = []CrisisMatch{} + } + return out, nil +} diff --git a/apps/api/internal/service/admin/crisis.go b/apps/api/internal/service/admin/crisis.go new file mode 100644 index 0000000..a3f91de --- /dev/null +++ b/apps/api/internal/service/admin/crisis.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 ErrCrisisPolicyNotFound = errString("crisis policy not found") + +// ListCrisisPolicies returns CrisisPolicy catalog. +func (s *Service) ListCrisisPolicies(ctx context.Context) ([]repository.CrisisPolicyRow, error) { + items, err := s.Repo.ListCrisisPolicies(ctx) + if err != nil { + return nil, err + } + if items == nil { + items = []repository.CrisisPolicyRow{} + } + return items, nil +} + +// GetCrisisPolicy loads one policy. +func (s *Service) GetCrisisPolicy(ctx context.Context, id uuid.UUID) (*repository.CrisisPolicyRow, error) { + row, err := s.Repo.GetCrisisPolicy(ctx, id) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrCrisisPolicyNotFound + } + return row, err +} + +// EvaluateCrisis runs read-only policy preview. +func (s *Service) EvaluateCrisis(ctx context.Context, text string) ([]repository.CrisisMatch, error) { + return s.Repo.EvaluateCrisisPolicies(ctx, text) +} diff --git a/apps/api/internal/service/admin/rbac.go b/apps/api/internal/service/admin/rbac.go index 4e513fe..1912d84 100644 --- a/apps/api/internal/service/admin/rbac.go +++ b/apps/api/internal/service/admin/rbac.go @@ -28,6 +28,7 @@ const ( PermAskFeedbackWrite = "admin.ask.feedback.write" PermContentSafetyRead = "admin.content_safety.read" PermAIConfigRead = "admin.ai_config.read" + PermCrisisRead = "admin.crisis.read" ) var knownPermissions = map[string]struct{}{ @@ -37,7 +38,7 @@ var knownPermissions = map[string]struct{}{ PermUsersStatusWrite: {}, PermMembershipPlansRead: {}, PermMembershipPlansWrite: {}, PermMembershipCodesRead: {}, PermMembershipCodesWrite: {}, PermAskRead: {}, PermAskFeedbackWrite: {}, PermContentSafetyRead: {}, - PermAIConfigRead: {}, + PermAIConfigRead: {}, PermCrisisRead: {}, } var ( diff --git a/apps/api/migrations/000023_crisis_policies.down.sql b/apps/api/migrations/000023_crisis_policies.down.sql new file mode 100644 index 0000000..e1bd60f --- /dev/null +++ b/apps/api/migrations/000023_crisis_policies.down.sql @@ -0,0 +1,4 @@ +-- ECR-022 down + +DELETE FROM admin_role_permissions WHERE code = 'admin.crisis.read'; +DROP TABLE IF EXISTS crisis_policies; diff --git a/apps/api/migrations/000023_crisis_policies.up.sql b/apps/api/migrations/000023_crisis_policies.up.sql new file mode 100644 index 0000000..e8b3162 --- /dev/null +++ b/apps/api/migrations/000023_crisis_policies.up.sql @@ -0,0 +1,49 @@ +-- ECR-022 CrisisCare CrisisPolicy + +CREATE TABLE IF NOT EXISTS crisis_policies ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + code varchar(64) NOT NULL UNIQUE, + title varchar(128) NOT NULL, + severity varchar(16) NOT NULL + CHECK (severity IN ('high','critical')), + pattern text NOT NULL, + action varchar(32) NOT NULL + CHECK (action IN ('escalate','block','show_helpline')), + helpline_text text NULL, + 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_crisis_policies_active ON crisis_policies(active); + +INSERT INTO crisis_policies(code, title, severity, pattern, action, helpline_text, active, system) +VALUES + ( + 'self_harm_critical', + '自伤/轻生危机', + 'critical', + '不想活了', + 'show_helpline', + '若你正处于危机,请立即联系身边可信的人,或拨打当地紧急援助/心理援助热线。愈心谷不能替代急救与专业干预。', + true, + true + ), + ( + 'violence_threat', + '暴力伤害威胁', + 'high', + '弄死你', + 'escalate', + NULL, + true, + true + ) +ON CONFLICT (code) DO NOTHING; + +INSERT INTO admin_role_permissions(role_id, code) +SELECT r.id, 'admin.crisis.read' +FROM admin_roles r +WHERE r.name = 'super_admin' +ON CONFLICT DO NOTHING; diff --git a/docs/BACKEND_DESIGN/BD-2026-022-crisis-care.md b/docs/BACKEND_DESIGN/BD-2026-022-crisis-care.md new file mode 100644 index 0000000..88839e8 --- /dev/null +++ b/docs/BACKEND_DESIGN/BD-2026-022-crisis-care.md @@ -0,0 +1,25 @@ +# Backend Design: ECR-022 CrisisCare + +| ID | BD-2026-022 | +| Status | Approved | +| Coding | Loop authorized | +| Level | L2 | +| Migration | YES 000023 | + +## Backend Change Boundary + +```text +Domain: CrisisPolicy (read) + evaluate (no CrisisEvent write) +App: AdminHandler → admin.Service → AdminRepo +API: GET /admin/crisis/policies[+/:id] + POST /admin/crisis/evaluate +Permission: admin.crisis.read +Migration: 000023 +UI: admin-h5 /crisis +``` + +## Out of boundary + +CrisisEvent write · InterventionOutcome · policy publish · medical diagnosis · UGC · Payment + +Rollback: down migration + remove routes/UI diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 5d021a4..701739c 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,7 @@ ## 2026-08-08 +- **ECR-022 Closed**:CrisisCare CrisisPolicy(`crisis_policies` · evaluate · admin-h5 `/crisis` · migration 000023) - **ECR-021 Closed**:AICoreConfig SystemPrompt(`system_prompts` · admin-h5 `/ai` · migration 000022 · 只读) ## 2026-08-07 diff --git a/docs/CODE_REVIEW/ECR-022.md b/docs/CODE_REVIEW/ECR-022.md new file mode 100644 index 0000000..ad0087d --- /dev/null +++ b/docs/CODE_REVIEW/ECR-022.md @@ -0,0 +1,8 @@ +# CODE_REVIEW — ECR-022 + +**Verdict:** Approve → Closed + +Date: 2026-08-08 · Loop continuous + +- CrisisPolicy 只读 + evaluate;无 CrisisEvent 写 +- Integration AC mapped · OpenAPI updated diff --git a/docs/CONTRACT_DIFF/ECR-022.yaml b/docs/CONTRACT_DIFF/ECR-022.yaml new file mode 100644 index 0000000..e29b9cd --- /dev/null +++ b/docs/CONTRACT_DIFF/ECR-022.yaml @@ -0,0 +1,26 @@ +ecr: ECR-022 +capability: CrisisCare +bounded_context: Content_Safety +parent: WAVE0-FROZEN +predecessor: ECR-021 +change: + type: additive +breaking_change: false +migration_required: true +compatibility_notes: > + Adds crisis_policies catalog with system seeds and read/evaluate admin APIs. + Does not write CrisisEvent. + +apis: + - method: GET + path: /api/v1/admin/crisis/policies + change: added + - method: GET + path: /api/v1/admin/crisis/policies/{id} + change: added + - method: POST + path: /api/v1/admin/crisis/evaluate + change: added +perms: + - code: admin.crisis.read + change: added diff --git a/docs/ECR/ECR-022-crisis-care.md b/docs/ECR/ECR-022-crisis-care.md new file mode 100644 index 0000000..26c0443 --- /dev/null +++ b/docs/ECR/ECR-022-crisis-care.md @@ -0,0 +1,19 @@ +# ECR-022 + +**Title:** CrisisCare · CrisisPolicy(只读薄切片) +**Status:** **Closed** +**Closed:** 2026-08-08(Loop continuous) +**Parent:** WAVE0-FROZEN · **Predecessor:** ECR-021 Closed +**Change Level:** L2 + +## Change + +`crisis_policies` + `GET /admin/crisis/policies*` · `POST .../evaluate`;权限 `admin.crisis.read`;admin-h5「危机」页。 + +## Forbidden + +CrisisEvent 写 · 策略写发布 · 医疗诊断 · UGC · 真支付 + +## Linked + +Spec `ops-crisis-care.md` · BD-2026-022 · CONTRACT_DIFF/ECR-022.yaml · TEST_REPORT/ECR-022.md diff --git a/docs/ENGINEERING_SPEC/ECR-022-crisis-care.md b/docs/ENGINEERING_SPEC/ECR-022-crisis-care.md new file mode 100644 index 0000000..0420412 --- /dev/null +++ b/docs/ENGINEERING_SPEC/ECR-022-crisis-care.md @@ -0,0 +1,7 @@ +# ENGINEERING_SPEC — ECR-022 + +1. migration 000023 crisis_policies + perm +2. AdminRepo list/get/evaluate +3. Admin API + OpenAPI +4. admin-h5 /crisis +5. Integration · Closed diff --git a/docs/HANDOFF/ECR-022-architect-to-engineer.md b/docs/HANDOFF/ECR-022-architect-to-engineer.md new file mode 100644 index 0000000..cbd4e4a --- /dev/null +++ b/docs/HANDOFF/ECR-022-architect-to-engineer.md @@ -0,0 +1,3 @@ +# HANDOFF — ECR-022 Architect → Engineer + +Loop continuous · Approved + Coding. Migration 000023. Forbidden: CrisisEvent写/UGC/真支付. diff --git a/docs/HANDOFF/ECR-022-engineer-to-reviewer.md b/docs/HANDOFF/ECR-022-engineer-to-reviewer.md new file mode 100644 index 0000000..b8386b9 --- /dev/null +++ b/docs/HANDOFF/ECR-022-engineer-to-reviewer.md @@ -0,0 +1,3 @@ +# HANDOFF — ECR-022 Engineer → Reviewer + +TestCrisisCarePolicies PASS · /crisis · Ready for Closed. diff --git a/docs/PRODUCT_SPEC/ECR-022-crisis-care.md b/docs/PRODUCT_SPEC/ECR-022-crisis-care.md new file mode 100644 index 0000000..296dd0d --- /dev/null +++ b/docs/PRODUCT_SPEC/ECR-022-crisis-care.md @@ -0,0 +1,3 @@ +# PRODUCT_SPEC — ECR-022 + +对齐 ops-crisis-care.md · Approved · Loop · L2 · CrisisPolicy 只读 diff --git a/docs/STATE/ECR-022.md b/docs/STATE/ECR-022.md new file mode 100644 index 0000000..f9c36c9 --- /dev/null +++ b/docs/STATE/ECR-022.md @@ -0,0 +1,6 @@ +# STATE — ECR-022 + +| Status | **Closed** | +| Phase | closed | +| Spec | ops-crisis-care.md | +| Updated | 2026-08-08 | diff --git a/docs/TASKS/TASK-022-ECR022.yaml b/docs/TASKS/TASK-022-ECR022.yaml new file mode 100644 index 0000000..8c5422f --- /dev/null +++ b/docs/TASKS/TASK-022-ECR022.yaml @@ -0,0 +1,12 @@ +id: TASK-022-ECR022 +ecr: ECR-022 +title: CrisisCare CrisisPolicy read +role: engineer +status: closed +change_level: L2 +parent: WAVE0-FROZEN +predecessor: ECR-021 +acceptance: + - Spec AC mapped + - CrisisPolicy read + evaluate only + - No CrisisEvent write / UGC / payment diff --git a/docs/TEST_REPORT/ECR-022.md b/docs/TEST_REPORT/ECR-022.md new file mode 100644 index 0000000..7069cc4 --- /dev/null +++ b/docs/TEST_REPORT/ECR-022.md @@ -0,0 +1,32 @@ +# TEST_REPORT — ECR-022 CrisisCare + +Date: 2026-08-08 · Loop continuous · commit: (pending) + +## Commands + +```bash +cd apps/api && go test ./internal/integration/ -run TestCrisisCarePolicies -count=1 +npm run build:admin +python3 scripts/ess-validate.py --phase review --ecr ECR-022 +python3 scripts/ess-gate-check.py --ecr ECR-022 +``` + +## Results + +| Check | Result | +|-------|--------| +| TestCrisisCarePolicies | 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 不写 Event | diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index df3582c..9a2f13f 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -26,3 +26,4 @@ | ECR-019 | ContentSafety | **Closed** | Spec ops-content-safety · BD-2026-019 · migration 000020 · TEST_REPORT · CODE_REVIEW · Loop continuous | | ECR-020 | QualityFeedback | **Closed** | Spec ops-quality-feedback · BD-2026-020 · migration 000021 · TEST_REPORT · CODE_REVIEW · Loop continuous | | ECR-021 | AICoreConfig | **Closed** | Spec ops-ai-core-config · BD-2026-021 · migration 000022 · TEST_REPORT · CODE_REVIEW · Loop continuous | +| ECR-022 | CrisisCare | **Closed** | Spec ops-crisis-care · BD-2026-022 · migration 000023 · TEST_REPORT · CODE_REVIEW · Loop continuous | diff --git a/docs/WAVE0/LOOP_AUTHORIZATION.md b/docs/WAVE0/LOOP_AUTHORIZATION.md index 003c7d9..8a32dcd 100644 --- a/docs/WAVE0/LOOP_AUTHORIZATION.md +++ b/docs/WAVE0/LOOP_AUTHORIZATION.md @@ -30,4 +30,4 @@ Human 明文:**直接用 Loop,不用人工确认。** | Done | Next | |------|------| -| ECR-013A…021 Closed | **ECR-022** CrisisCare 薄切片(CrisisPolicy 只读优先);禁真支付/UGC | +| ECR-013A…022 Closed | **ECR-023** KnowledgeSource 薄切片(只读优先)或 Banner/OpsCMS;禁真支付/UGC | diff --git a/proto/openapi.yaml b/proto/openapi.yaml index 431d275..09716eb 100644 --- a/proto/openapi.yaml +++ b/proto/openapi.yaml @@ -555,6 +555,51 @@ paths: '404': description: Not found + /api/v1/admin/crisis/policies: + get: + tags: [admin] + summary: List CrisisPolicy + description: Requires admin.crisis.read + responses: + '200': + description: OK + '401': + description: Unauthorized + '403': + description: Forbidden + + /api/v1/admin/crisis/policies/{id}: + get: + tags: [admin] + summary: Get CrisisPolicy + parameters: + - in: path + name: id + required: true + schema: { type: string, format: uuid } + responses: + '200': + description: OK + '404': + description: Not found + + /api/v1/admin/crisis/evaluate: + post: + tags: [admin] + summary: Preview CrisisPolicy matches (no CrisisEvent 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]
CrisisPolicy 只读 · 试匹配不写 CrisisEvent · 非医疗诊断
加载中…
{{ error }}
暂无
{{ p.code }}
{{ m.helpline_text }}