chore: 合入 stash Ops hardening 与 migration 000041

Ask/catalog 权限与审计加固、量表读权限统一,以及未提交的 ops hardening 变更。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-13 01:46:34 +08:00
co-authored by Cursor
parent 4889ff5916
commit 62cd8c45dd
70 changed files with 848 additions and 69 deletions
@@ -21,7 +21,8 @@
| Method | Path | 权限 | 语义 |
|--------|------|------|------|
| GET | `/admin/ask/threads` | `admin.ask.read` | 列表(可选 `user_id` |
| GET | `/admin/ask/threads/:id` | `admin.ask.read` | 详情含消息 |
| GET | `/admin/ask/threads/:id` | `admin.ask.read` | 会话 meta(不含原文) |
| GET | `/admin/ask/threads/:id/messages` | `admin.ask.transcript.read` | 对话原文(审计 `ask.transcript.read` |
## Migration
@@ -19,8 +19,8 @@
| Method | Path | 权限 | 语义 |
|--------|------|------|------|
| GET | `/admin/analytics/funnel-definitions` | `admin.analytics.read` | 只读 |
| GET | `/admin/analytics/funnel-definitions/{id}` | `admin.analytics.read` | 只读 |
| GET | `/admin/analytics/funnel-definitions` | `admin.growth.read` | 只读 |
| GET | `/admin/analytics/funnel-definitions/{id}` | `admin.growth.read` | 只读 |
## Migration
@@ -19,8 +19,9 @@ PATCH status(已有 content.write)· 题干编辑 · UGC · 真支付
| Method | Path | 权限 | 语义 |
|--------|------|------|------|
| GET | `/admin/explore/scales` | `admin.explore.read` | 只读 |
| GET | `/admin/explore/scales/{id}` | `admin.explore.read` | 只读 |
| GET | `/admin/explore/scales` | `admin.explore.read``admin.content.write` | 只读 |
| GET | `/admin/explore/scales/{id}` | `admin.explore.read``admin.content.write` | 只读 |
| GET | `/admin/scales` | `admin.explore.read``admin.content.write` | 只读(与 explore 同源) |
## Migration
+53
View File
@@ -0,0 +1,53 @@
{
"ecr": "ECR-012",
"result": "BLOCK",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": false,
"tests": false
},
"violations": [
{
"rule": "TR-REVIEW-001",
"description": "Backend change requires CODE_REVIEW / code-review handoff artifact",
"fix_required": [
"Write reviewer decision artifact",
"Re-run gate"
],
"detail": "ECR-012",
"check": "traceability-check"
},
{
"rule": "TR-COMMIT-001",
"description": "Trace chain requires implementation commit evidence",
"fix_required": [
"Record commit sha in TRACEABILITY or TEST_REPORT",
"Re-run gate"
],
"detail": "ECR-012",
"check": "traceability-check"
},
{
"rule": "TE-MISSING-001",
"description": "Missing TEST_REPORT for implemented change",
"fix_required": [
"Add docs/TEST_REPORT/ECR-xxx.md",
"Re-run gate"
],
"detail": "ECR-012",
"check": "test-evidence-check"
}
],
"errors": [
"[traceability-check] BLOCK [TR-REVIEW-001]: Backend change requires CODE_REVIEW / code-review handoff artifact \u2014 ECR-012",
"[traceability-check] BLOCK [TR-COMMIT-001]: Trace chain requires implementation commit evidence \u2014 ECR-012",
"[test-evidence-check] BLOCK [TE-MISSING-001]: Missing TEST_REPORT for implemented change \u2014 ECR-012"
],
"warnings": [],
"timestamp": "2026-08-07T06:51:10Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"ecr": "ECR-016",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-07T10:30:16Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"ecr": "ECR-017",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-07T10:39:01Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"ecr": "ECR-018",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-07T11:26:01Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"ecr": "ECR-019",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-07T14:13:03Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"ecr": "ECR-020",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-07T15:01:53Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"ecr": "ECR-021",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-07T16:26:53Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"ecr": "ECR-022",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-07T17:28:27Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"ecr": "ECR-023",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-07T17:48:29Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"ecr": "ECR-024",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-07T19:05:41Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"ecr": "ECR-025",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-07T19:08:40Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"ecr": "ECR-026",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-07T19:13:36Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"ecr": "ECR-027",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-07T19:14:17Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"ecr": "ECR-028",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-07T19:14:30Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"ecr": "ECR-029",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-07T19:14:44Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"ecr": "ECR-030",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-07T19:15:00Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"ecr": "ECR-031",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-07T19:15:15Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"ecr": "ECR-032",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-07T19:15:30Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"ecr": "ECR-033",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-07T19:15:45Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"ecr": "ECR-034",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-07T19:15:59Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"ecr": "ECR-035",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-07T19:16:12Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"ecr": "ECR-036",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-07T19:16:26Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"ecr": "ECR-037",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-07T19:16:42Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"ecr": "ECR-038",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-07T19:16:57Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"ecr": "ECR-039",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-07T19:17:12Z"
}
+18
View File
@@ -0,0 +1,18 @@
{
"ecr": "ECR-040",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-07T19:20:29Z"
}
+12 -2
View File
@@ -272,8 +272,18 @@ export const adminApi = {
message_count: number
created_at: string
updated_at: string
messages: Array<{ id: string; role: string; content: string; created_at: string }>
}>('GET', `/ask/threads/${id}`),
askThreadMessages: (id: string) =>
request<{
id: string
user_id: string
profile_id: string
scene?: string
message_count: number
created_at: string
updated_at: string
messages: Array<{ id: string; role: string; content: string; created_at: string }>
}>('GET', `/ask/threads/${id}/messages`),
filterRules: () =>
request<{
items: Array<{
@@ -482,7 +492,7 @@ export const adminApi = {
request<Record<string, unknown>>('GET', `/ai/tools/${id}`),
blockPolicies: () =>
request<{ items: Array<Record<string, unknown>> }>('GET', '/content-safety/block-policies'),
blockPolicie: (id: string) =>
blockPolicy: (id: string) =>
request<Record<string, unknown>>('GET', `/content-safety/block-policies/${id}`),
cases: () =>
request<{ items: Array<Record<string, unknown>> }>('GET', '/content-safety/cases'),
+17 -4
View File
@@ -5,7 +5,9 @@ import { adminApi } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
type Thread = Awaited<ReturnType<typeof adminApi.askThreads>>['items'][number]
type Detail = Awaited<ReturnType<typeof adminApi.askThread>>
type Detail = Awaited<ReturnType<typeof adminApi.askThread>> & {
messages?: Array<{ id: string; role: string; content: string; created_at: string }>
}
type Feedback = Awaited<ReturnType<typeof adminApi.askFeedback>>['items'][number]
const auth = useAuthStore()
@@ -20,8 +22,10 @@ const rating = ref(4)
const tag = ref('helpful')
const note = ref('')
const fbMsg = ref('')
const transcriptDenied = ref(false)
const canWriteFeedback = () => auth.can('admin.ask.feedback.write')
const canReadTranscript = () => auth.can('admin.ask.transcript.read')
async function load() {
loading.value = true
@@ -42,8 +46,16 @@ async function openThread(id: string) {
detailErr.value = ''
selected.value = null
fbMsg.value = ''
transcriptDenied.value = false
try {
selected.value = await adminApi.askThread(id)
const meta = await adminApi.askThread(id)
selected.value = { ...meta, messages: [] }
if (canReadTranscript()) {
const full = await adminApi.askThreadMessages(id)
selected.value = full
} else {
transcriptDenied.value = true
}
} catch (e) {
detailErr.value = e instanceof Error ? e.message : '详情失败'
} finally {
@@ -123,8 +135,9 @@ onMounted(load)
{{ selected.scene || '—' }} · {{ selected.message_count }} ·
<RouterLink :to="`/users/${selected.user_id}`">用户</RouterLink>
</p>
<ul class="msgs">
<li v-for="m in selected.messages" :key="m.id" :class="m.role">
<p v-if="transcriptDenied" class="muted">无对话原文权限admin.ask.transcript.read</p>
<ul v-else class="msgs">
<li v-for="m in selected.messages || []" :key="m.id" :class="m.role">
<span class="role">{{ m.role }}</span>
<p>{{ m.content }}</p>
<time>{{ fmtTime(m.created_at) }}</time>
+10 -10
View File
@@ -5,15 +5,15 @@ import { adminApi } from '@/api/client'
type Row = { id: string; code?: string; slug?: string; title?: string; status?: string }
const catalogs = [
{ key: 'publications', label: '定时发布', load: () => adminApi.publications() },
{ key: 'knowledgeChunks', label: '知识块', load: () => adminApi.knowledgeChunks() },
{ key: 'tools', label: '工具定义', load: () => adminApi.tools() },
{ key: 'blockPolicies', label: '拦截策略', load: () => adminApi.blockPolicies() },
{ key: 'cases', label: '审核', load: () => adminApi.cases() },
{ key: 'events', label: '危机事件', load: () => adminApi.events() },
{ key: 'interventions', label: '干预结果', load: () => adminApi.interventions() },
{ key: 'handoffs', label: '转接', load: () => adminApi.handoffs() },
{ key: 'privacy', label: '隐私请求', load: () => adminApi.requests() },
{ key: 'publications', label: '定时发布(目录)', load: () => adminApi.publications() },
{ key: 'knowledgeChunks', label: '知识块(目录)', load: () => adminApi.knowledgeChunks() },
{ key: 'tools', label: '工具定义(目录)', load: () => adminApi.tools() },
{ key: 'blockPolicies', label: '拦截策略(目录)', load: () => adminApi.blockPolicies() },
{ key: 'cases', label: '审核类型(目录)', load: () => adminApi.cases() },
{ key: 'events', label: '危机类型(目录)', load: () => adminApi.events() },
{ key: 'interventions', label: '干预类型(目录)', load: () => adminApi.interventions() },
{ key: 'handoffs', label: '转接类型(目录)', load: () => adminApi.handoffs() },
{ key: 'privacy', label: '隐私类型(目录)', load: () => adminApi.requests() },
{ key: 'star', label: '星座配置', load: () => adminApi.starConfigs() },
{ key: 'rhythm', label: '节律配置', load: () => adminApi.rhythmConfigs() },
{ key: 'decks', label: '意象牌组', load: () => adminApi.imageCardDecks() },
@@ -50,7 +50,7 @@ onMounted(() => {
<template>
<section>
<h1>目录只读仓</h1>
<p class="muted">ECR-026040 运营只读目录聚合不可写发布</p>
<p class="muted">ECR-026040 运营只读目录聚合类型占位非用户个案 docs/WAVE0/CATALOG_VS_CASE.md</p>
<div class="tabs">
<button
v-for="(c, i) in catalogs"
+25 -1
View File
@@ -16,6 +16,7 @@ import (
func (h *AdminHandler) registerAskOps(authed *gin.RouterGroup) {
authed.GET("/ask/threads", middleware.RequireAdminPermission(h.Svc, admin.PermAskRead), h.ListAskThreads)
authed.GET("/ask/threads/:id", middleware.RequireAdminPermission(h.Svc, admin.PermAskRead), h.GetAskThread)
authed.GET("/ask/threads/:id/messages", middleware.RequireAdminPermission(h.Svc, admin.PermAskTranscriptRead), h.GetAskThreadMessages)
}
func (h *AdminHandler) ListAskThreads(c *gin.Context) {
@@ -44,7 +45,7 @@ func (h *AdminHandler) GetAskThread(c *gin.Context) {
response.Fail(c, http.StatusBadRequest, 40002, "invalid thread id")
return
}
detail, err := h.Svc.GetAskSessionDetail(c.Request.Context(), id)
meta, err := h.Svc.GetAskSessionMeta(c.Request.Context(), id)
if errors.Is(err, admin.ErrAskThreadNotFound) {
response.Fail(c, http.StatusNotFound, 40402, "ask thread not found")
return
@@ -53,5 +54,28 @@ func (h *AdminHandler) GetAskThread(c *gin.Context) {
response.Fail(c, http.StatusInternalServerError, 50020, "get ask thread failed")
return
}
response.OK(c, meta)
}
func (h *AdminHandler) GetAskThreadMessages(c *gin.Context) {
adminID, ok := middleware.AdminIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40102, "admin session invalid")
return
}
id, err := uuid.Parse(c.Param("id"))
if err != nil {
response.Fail(c, http.StatusBadRequest, 40002, "invalid thread id")
return
}
detail, err := h.Svc.GetAskSessionTranscript(c.Request.Context(), adminID, id)
if errors.Is(err, admin.ErrAskThreadNotFound) {
response.Fail(c, http.StatusNotFound, 40402, "ask thread not found")
return
}
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50021, "get ask transcript failed")
return
}
response.OK(c, detail)
}
+1 -1
View File
@@ -16,7 +16,7 @@ import (
func (h *AdminHandler) registerContent(authed *gin.RouterGroup) {
authed.GET("/home/tools", middleware.RequireAdminPermission(h.Svc, admin.PermContentWrite), h.ListHomeTools)
authed.PUT("/home/tools", middleware.RequireAdminPermission(h.Svc, admin.PermContentWrite), h.ReplaceHomeTools)
authed.GET("/scales", middleware.RequireAdminPermission(h.Svc, admin.PermContentWrite), h.ListScales)
authed.GET("/scales", middleware.RequireAnyAdminPermission(h.Svc, admin.PermExploreRead, admin.PermContentWrite), h.ListScales)
authed.PATCH("/scales/:id", middleware.RequireAdminPermission(h.Svc, admin.PermContentWrite), h.PatchScale)
}
@@ -14,8 +14,8 @@ import (
func (h *AdminHandler) registerExploreScales(authed *gin.RouterGroup) {
g := authed.Group("/explore")
g.GET("/scales", middleware.RequireAdminPermission(h.Svc, admin.PermExploreRead), h.ListExploreScales)
g.GET("/scales/:id", middleware.RequireAdminPermission(h.Svc, admin.PermExploreRead), h.GetExploreScale)
g.GET("/scales", middleware.RequireAnyAdminPermission(h.Svc, admin.PermExploreRead, admin.PermContentWrite), h.ListExploreScales)
g.GET("/scales/:id", middleware.RequireAnyAdminPermission(h.Svc, admin.PermExploreRead, admin.PermContentWrite), h.GetExploreScale)
}
func (h *AdminHandler) ListExploreScales(c *gin.Context) {
@@ -14,8 +14,8 @@ import (
func (h *AdminHandler) registerFunnelDefinitions(authed *gin.RouterGroup) {
g := authed.Group("/analytics")
g.GET("/funnel-definitions", middleware.RequireAdminPermission(h.Svc, admin.PermAnalyticsRead), h.ListFunnelDefinitions)
g.GET("/funnel-definitions/:id", middleware.RequireAdminPermission(h.Svc, admin.PermAnalyticsRead), h.GetFunnelDefinition)
g.GET("/funnel-definitions", middleware.RequireAdminPermission(h.Svc, admin.PermGrowthRead), h.ListFunnelDefinitions)
g.GET("/funnel-definitions/:id", middleware.RequireAdminPermission(h.Svc, admin.PermGrowthRead), h.GetFunnelDefinition)
}
func (h *AdminHandler) ListFunnelDefinitions(c *gin.Context) {
@@ -68,6 +68,10 @@ func (h *AdminHandler) CreateAskFeedback(c *gin.Context) {
response.Fail(c, http.StatusNotFound, 40402, "ask thread not found")
return
}
if errors.Is(err, admin.ErrMessageNotInThread) {
response.Fail(c, http.StatusBadRequest, 40030, "message not in thread")
return
}
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50026, "create feedback failed")
return
@@ -60,6 +60,10 @@ func (h *AskHandler) SubmitFeedback(c *gin.Context) {
response.Fail(c, http.StatusNotFound, 40410, msg)
return
}
if strings.Contains(msg, "message not in thread") {
response.Fail(c, http.StatusBadRequest, 40030, msg)
return
}
response.Fail(c, http.StatusInternalServerError, 50000, msg)
return
}
+62 -1
View File
@@ -99,7 +99,47 @@ func TestAskOperations(t *testing.T) {
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ask/threads/"+threadID, nil, tok)
if code != 200 {
t.Fatalf("detail http=%d msg=%s", code, env.Message)
t.Fatalf("meta http=%d msg=%s", code, env.Message)
}
var meta map[string]any
_ = json.Unmarshal(env.Data, &meta)
if _, has := meta["messages"]; has {
t.Fatalf("meta must not include messages: %#v", meta)
}
if meta["id"] != threadID {
t.Fatalf("meta id mismatch: %#v", meta)
}
metaOnlyRole := uuid.New()
_, err = pool.Exec(ctx, `INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`,
metaOnlyRole, "ask_meta_"+metaOnlyRole.String()[:8])
if err != nil {
t.Fatal(err)
}
_, err = pool.Exec(ctx, `INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.ask.read')`, metaOnlyRole)
if err != nil {
t.Fatal(err)
}
metaUser := fmt.Sprintf("askmeta_%d", time.Now().UnixNano())
hash2, _ := bcrypt.GenerateFromPassword([]byte("meta-pass"), bcrypt.DefaultCost)
_, err = pool.Exec(ctx, `INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1,$2,$3)`,
metaUser, string(hash2), metaOnlyRole)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, metaUser)
_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, metaOnlyRole)
})
metaTok := adminLogin(t, r, metaUser, "meta-pass")
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ask/threads/"+threadID+"/messages", nil, metaTok)
if code != http.StatusForbidden {
t.Fatalf("expected 403 without transcript.read, got %d", code)
}
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ask/threads/"+threadID+"/messages", nil, tok)
if code != 200 {
t.Fatalf("transcript http=%d msg=%s", code, env.Message)
}
var detail struct {
Messages []struct {
@@ -111,5 +151,26 @@ func TestAskOperations(t *testing.T) {
if len(detail.Messages) < 2 {
t.Fatalf("expected user+assistant, got %#v", detail.Messages)
}
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/audit-logs", nil, tok)
if code != 200 {
t.Fatalf("audit %d", code)
}
var audit struct {
Items []struct {
Action string `json:"action"`
} `json:"items"`
}
_ = json.Unmarshal(env.Data, &audit)
audited := false
for _, it := range audit.Items {
if it.Action == "ask.transcript.read" {
audited = true
break
}
}
if !audited {
t.Fatalf("missing ask.transcript.read audit: %#v", audit.Items)
}
_ = key
}
@@ -132,5 +132,34 @@ func TestQualityFeedback(t *testing.T) {
if !okAudit {
t.Fatal("missing ask.feedback.create audit")
}
// cross-thread message_id must be rejected
env, key = doJSON(t, r, http.MethodPost, "/api/v1/ask/threads", map[string]any{
"profile_id": profileID, "scene": "self",
}, key)
otherThread := decodeData[map[string]any](t, env.Data)["id"].(string)
env, key = doJSON(t, r, http.MethodPost, "/api/v1/ask/threads/"+otherThread+"/messages", map[string]any{
"content": "另一线程",
}, key)
// fetch a message id from other thread via admin transcript
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ask/threads/"+otherThread+"/messages", nil, tok)
if code != 200 {
t.Fatalf("other transcript http=%d", code)
}
var otherDetail struct {
Messages []struct {
ID string `json:"id"`
} `json:"messages"`
}
_ = json.Unmarshal(env.Data, &otherDetail)
if len(otherDetail.Messages) == 0 {
t.Fatal("expected messages on other thread")
}
foreignMsg := otherDetail.Messages[0].ID
_, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/ask/threads/"+threadID+"/feedback",
map[string]any{"rating": 2, "tag": "other", "message_id": foreignMsg}, tok)
if code != http.StatusBadRequest {
t.Fatalf("expected 400 for foreign message_id, got %d", code)
}
_ = key
}
@@ -18,6 +18,11 @@ type AdminPermissionChecker interface {
// RequireAdminPermission aborts with 403 when the admin lacks code.
func RequireAdminPermission(checker AdminPermissionChecker, code string) gin.HandlerFunc {
return RequireAnyAdminPermission(checker, code)
}
// RequireAnyAdminPermission aborts with 403 when the admin lacks all of codes.
func RequireAnyAdminPermission(checker AdminPermissionChecker, codes ...string) gin.HandlerFunc {
return func(c *gin.Context) {
adminID, ok := AdminIDFromContext(c)
if !ok {
@@ -25,18 +30,27 @@ func RequireAdminPermission(checker AdminPermissionChecker, code string) gin.Han
c.Abort()
return
}
okPerm, err := checker.HasPermission(c.Request.Context(), adminID, code)
if err != nil {
if len(codes) == 0 {
response.Fail(c, http.StatusInternalServerError, 50000, "permission check failed")
c.Abort()
return
}
if !okPerm {
checker.DenyPermission(c.Request.Context(), adminID, code, c.FullPath())
response.Fail(c, http.StatusForbidden, 40301, "forbidden")
c.Abort()
return
var lastCode string
for _, code := range codes {
lastCode = code
okPerm, err := checker.HasPermission(c.Request.Context(), adminID, code)
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50000, "permission check failed")
c.Abort()
return
}
if okPerm {
c.Next()
return
}
}
c.Next()
checker.DenyPermission(c.Request.Context(), adminID, lastCode, c.FullPath())
response.Fail(c, http.StatusForbidden, 40301, "forbidden")
c.Abort()
}
}
@@ -27,7 +27,7 @@ func (r *AdminRepo) ListSystemPrompts(ctx context.Context) ([]SystemPromptRow, e
rows, err := r.Pool.Query(ctx, `
SELECT id, code, title, scene, body, version, active, system, updated_at
FROM system_prompts
ORDER BY active DESC, code ASC`)
ORDER BY active DESC, code ASC LIMIT 500`)
if err != nil {
return nil, err
}
@@ -79,7 +79,7 @@ func (r *AdminRepo) ListKnowledgeSources(ctx context.Context) ([]KnowledgeSource
rows, err := r.Pool.Query(ctx, `
SELECT id, code, title, description, source_kind, version, active, system, updated_at
FROM knowledge_sources
ORDER BY active DESC, code ASC`)
ORDER BY active DESC, code ASC LIMIT 500`)
if err != nil {
return nil, err
}
@@ -25,7 +25,7 @@ func (r *AdminRepo) ListBlockPolicies(ctx context.Context) ([]BlockPolicyRow, er
rows, err := r.Pool.Query(ctx, `
SELECT id, code, title, action, active, system, updated_at
FROM block_policies
ORDER BY active DESC, code ASC`)
ORDER BY active DESC, code ASC LIMIT 500`)
if err != nil {
return nil, err
}
+2 -2
View File
@@ -28,7 +28,7 @@ func (r *AdminRepo) ListBanners(ctx context.Context) ([]BannerRow, error) {
rows, err := r.Pool.Query(ctx, `
SELECT id, code, title, placement, image_url, link_path, sort_order, active, system, updated_at
FROM ops_banners
ORDER BY active DESC, sort_order ASC, code ASC`)
ORDER BY active DESC, sort_order ASC, code ASC LIMIT 500`)
if err != nil {
return nil, err
}
@@ -83,7 +83,7 @@ func (r *AdminRepo) ListFeedSlots(ctx context.Context) ([]FeedSlotRow, error) {
rows, err := r.Pool.Query(ctx, `
SELECT id, code, title, slot_key, placement, active, system, updated_at
FROM ops_feed_slots
ORDER BY active DESC, code ASC`)
ORDER BY active DESC, code ASC LIMIT 500`)
if err != nil {
return nil, err
}
@@ -36,7 +36,7 @@ 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`)
ORDER BY active DESC, category ASC, code ASC LIMIT 500`)
if err != nil {
return nil, err
}
@@ -25,7 +25,7 @@ func (r *AdminRepo) ListCrisisEvents(ctx context.Context) ([]CrisisEventRow, err
rows, err := r.Pool.Query(ctx, `
SELECT id, code, title, severity, active, system, updated_at
FROM crisis_events
ORDER BY active DESC, code ASC`)
ORDER BY active DESC, code ASC LIMIT 500`)
if err != nil {
return nil, err
}
+1 -1
View File
@@ -38,7 +38,7 @@ func (r *AdminRepo) ListCrisisPolicies(ctx context.Context) ([]CrisisPolicyRow,
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`)
ORDER BY active DESC, severity DESC, code ASC LIMIT 500`)
if err != nil {
return nil, err
}
@@ -24,7 +24,7 @@ func (r *AdminRepo) ListFunnelDefinitions(ctx context.Context) ([]FunnelDefiniti
rows, err := r.Pool.Query(ctx, `
SELECT id, code, title, active, system, updated_at
FROM funnel_definitions
ORDER BY active DESC, code ASC`)
ORDER BY active DESC, code ASC LIMIT 500`)
if err != nil {
return nil, err
}
@@ -25,7 +25,7 @@ func (r *AdminRepo) ListHandoffCases(ctx context.Context) ([]HandoffCaseRow, err
rows, err := r.Pool.Query(ctx, `
SELECT id, code, title, status, active, system, updated_at
FROM ask_handoff_cases
ORDER BY active DESC, code ASC`)
ORDER BY active DESC, code ASC LIMIT 500`)
if err != nil {
return nil, err
}
@@ -24,7 +24,7 @@ func (r *AdminRepo) ListImageCardDecks(ctx context.Context) ([]ImageCardDeckRow,
rows, err := r.Pool.Query(ctx, `
SELECT id, code, title, active, system, updated_at
FROM image_card_decks
ORDER BY active DESC, code ASC`)
ORDER BY active DESC, code ASC LIMIT 500`)
if err != nil {
return nil, err
}
@@ -25,7 +25,7 @@ func (r *AdminRepo) ListInterventionOutcomes(ctx context.Context) ([]Interventio
rows, err := r.Pool.Query(ctx, `
SELECT id, code, title, outcome, active, system, updated_at
FROM intervention_outcomes
ORDER BY active DESC, code ASC`)
ORDER BY active DESC, code ASC LIMIT 500`)
if err != nil {
return nil, err
}
@@ -26,7 +26,7 @@ func (r *AdminRepo) ListKnowledgeChunks(ctx context.Context) ([]KnowledgeChunkRo
rows, err := r.Pool.Query(ctx, `
SELECT id, code, source_code, title, body, active, system, updated_at
FROM knowledge_chunks
ORDER BY active DESC, code ASC`)
ORDER BY active DESC, code ASC LIMIT 500`)
if err != nil {
return nil, err
}
@@ -25,7 +25,7 @@ func (r *AdminRepo) ListModerationCases(ctx context.Context) ([]ModerationCaseRo
rows, err := r.Pool.Query(ctx, `
SELECT id, code, title, status, active, system, updated_at
FROM moderation_cases
ORDER BY active DESC, code ASC`)
ORDER BY active DESC, code ASC LIMIT 500`)
if err != nil {
return nil, err
}
@@ -26,7 +26,7 @@ func (r *AdminRepo) ListPrivacyRequests(ctx context.Context) ([]PrivacyRequestRo
rows, err := r.Pool.Query(ctx, `
SELECT id, code, title, kind, status, active, system, updated_at
FROM privacy_requests
ORDER BY active DESC, code ASC`)
ORDER BY active DESC, code ASC LIMIT 500`)
if err != nil {
return nil, err
}
@@ -76,6 +76,15 @@ func (r *AdminRepo) CreateAdminQualityFeedback(
if !ok {
return nil, errors.New("ask thread not found")
}
if messageID != nil {
inThread, err := r.askMessageInThread(ctx, threadID, *messageID)
if err != nil {
return nil, err
}
if !inThread {
return nil, errors.New("message not in thread")
}
}
tx, err := r.Pool.Begin(ctx)
if err != nil {
return nil, err
@@ -117,6 +126,18 @@ func (r *AskRepo) CreateUserQualityFeedback(
if err != nil {
return nil, errors.New("ask thread not found")
}
if messageID != nil {
var n int
err = r.Pool.QueryRow(ctx, `
SELECT 1 FROM ask_messages
WHERE id=$1 AND thread_id=$2 AND deleted_at IS NULL`, *messageID, threadID).Scan(&n)
if errors.Is(err, pgx.ErrNoRows) {
return nil, errors.New("message not in thread")
}
if err != nil {
return nil, err
}
}
var f QualityFeedbackRow
err = r.Pool.QueryRow(ctx, `
INSERT INTO ask_quality_feedback(thread_id, message_id, source, rating, tag, note, created_by_user)
@@ -138,6 +159,17 @@ func (r *AdminRepo) askThreadExists(ctx context.Context, threadID uuid.UUID) (bo
return err == nil, err
}
func (r *AdminRepo) askMessageInThread(ctx context.Context, threadID, messageID uuid.UUID) (bool, error) {
var n int
err := r.Pool.QueryRow(ctx, `
SELECT 1 FROM ask_messages
WHERE id=$1 AND thread_id=$2 AND deleted_at IS NULL`, messageID, threadID).Scan(&n)
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
return err == nil, err
}
func validateFeedback(rating int, tag, note *string) error {
if rating < 1 || rating > 5 {
return errors.New("rating must be 1-5")
@@ -25,7 +25,7 @@ func (r *AdminRepo) ListReportTemplates(ctx context.Context) ([]ReportTemplateRo
rows, err := r.Pool.Query(ctx, `
SELECT id, code, title, scene, active, system, updated_at
FROM report_templates
ORDER BY active DESC, code ASC`)
ORDER BY active DESC, code ASC LIMIT 500`)
if err != nil {
return nil, err
}
@@ -24,7 +24,7 @@ func (r *AdminRepo) ListRhythmConfigs(ctx context.Context) ([]RhythmConfigRow, e
rows, err := r.Pool.Query(ctx, `
SELECT id, code, title, active, system, updated_at
FROM rhythm_configs
ORDER BY active DESC, code ASC`)
ORDER BY active DESC, code ASC LIMIT 500`)
if err != nil {
return nil, err
}
+1 -1
View File
@@ -106,7 +106,7 @@ type ScaleAdminItem struct {
func (r *ScaleRepo) ListAllAdmin(ctx context.Context) ([]ScaleAdminItem, error) {
rows, err := r.Pool.Query(ctx, `
SELECT id, slug, title, description, status FROM scales
WHERE deleted_at IS NULL ORDER BY created_at`)
WHERE deleted_at IS NULL ORDER BY created_at LIMIT 500`)
if err != nil {
return nil, err
}
@@ -26,7 +26,7 @@ func (r *AdminRepo) ListScheduledPublications(ctx context.Context) ([]ScheduledP
rows, err := r.Pool.Query(ctx, `
SELECT id, code, title, target_kind, target_code, active, system, updated_at
FROM ops_scheduled_publications
ORDER BY active DESC, code ASC`)
ORDER BY active DESC, code ASC LIMIT 500`)
if err != nil {
return nil, err
}
@@ -24,7 +24,7 @@ func (r *AdminRepo) ListStarConfigs(ctx context.Context) ([]StarConfigRow, error
rows, err := r.Pool.Query(ctx, `
SELECT id, code, title, active, system, updated_at
FROM star_configs
ORDER BY active DESC, code ASC`)
ORDER BY active DESC, code ASC LIMIT 500`)
if err != nil {
return nil, err
}
@@ -25,7 +25,7 @@ func (r *AdminRepo) ListToolDefinitions(ctx context.Context) ([]ToolDefinitionRo
rows, err := r.Pool.Query(ctx, `
SELECT id, code, title, description, active, system, updated_at
FROM tool_definitions
ORDER BY active DESC, code ASC`)
ORDER BY active DESC, code ASC LIMIT 500`)
if err != nil {
return nil, err
}
+21 -3
View File
@@ -2,6 +2,7 @@ package admin
import (
"context"
"encoding/json"
"errors"
"github.com/google/uuid"
@@ -10,7 +11,7 @@ import (
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
// AskSessionDetail is AskSessionView plus messages.
// AskSessionDetail is AskSessionView plus messages (transcript).
type AskSessionDetail struct {
repository.AskSessionView
Messages []repository.AskMessageView `json:"messages"`
@@ -30,8 +31,20 @@ func (s *Service) ListAskSessions(ctx context.Context, userID *uuid.UUID, limit,
return items, nil
}
// GetAskSessionDetail loads meta + messages.
func (s *Service) GetAskSessionDetail(ctx context.Context, threadID uuid.UUID) (*AskSessionDetail, error) {
// GetAskSessionMeta loads thread meta without message bodies.
func (s *Service) GetAskSessionMeta(ctx context.Context, threadID uuid.UUID) (*repository.AskSessionView, error) {
view, err := s.Repo.GetAskSession(ctx, threadID)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrAskThreadNotFound
}
if err != nil {
return nil, err
}
return view, nil
}
// GetAskSessionTranscript loads message bodies and audits access.
func (s *Service) GetAskSessionTranscript(ctx context.Context, adminID, threadID uuid.UUID) (*AskSessionDetail, error) {
view, err := s.Repo.GetAskSession(ctx, threadID)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrAskThreadNotFound
@@ -46,5 +59,10 @@ func (s *Service) GetAskSessionDetail(ctx context.Context, threadID uuid.UUID) (
if msgs == nil {
msgs = []repository.AskMessageView{}
}
meta, _ := json.Marshal(map[string]any{
"message_count": len(msgs),
"user_id": view.UserID.String(),
})
_ = s.Repo.InsertAudit(ctx, adminID, "ask.transcript.read", "ask_thread", threadID.String(), meta)
return &AskSessionDetail{AskSessionView: *view, Messages: msgs}, nil
}
@@ -10,9 +10,10 @@ import (
)
var (
ErrBadFeedbackRating = errString("rating must be 1-5")
ErrBadFeedbackTag = errString("invalid tag")
ErrFeedbackNoteLong = errString("note too long")
ErrBadFeedbackRating = errString("rating must be 1-5")
ErrBadFeedbackTag = errString("invalid tag")
ErrFeedbackNoteLong = errString("note too long")
ErrMessageNotInThread = errString("message not in thread")
)
// ListQualityFeedback lists recent QualityFeedback.
@@ -54,6 +55,8 @@ func (s *Service) CreateQualityFeedback(
return nil, ErrFeedbackNoteLong
case strings.Contains(msg, "thread not found"):
return nil, ErrAskThreadNotFound
case strings.Contains(msg, "message not in thread"):
return nil, ErrMessageNotInThread
default:
return nil, err
}
+2 -1
View File
@@ -25,6 +25,7 @@ const (
PermMembershipCodesRead = "admin.membership.codes.read"
PermMembershipCodesWrite = "admin.membership.codes.write"
PermAskRead = "admin.ask.read"
PermAskTranscriptRead = "admin.ask.transcript.read"
PermAskFeedbackWrite = "admin.ask.feedback.write"
PermContentSafetyRead = "admin.content_safety.read"
PermAIConfigRead = "admin.ai_config.read"
@@ -41,7 +42,7 @@ var knownPermissions = map[string]struct{}{
PermContentWrite: {}, PermRolesRead: {}, PermRolesWrite: {},
PermUsersStatusWrite: {}, PermMembershipPlansRead: {}, PermMembershipPlansWrite: {},
PermMembershipCodesRead: {}, PermMembershipCodesWrite: {},
PermAskRead: {}, PermAskFeedbackWrite: {}, PermContentSafetyRead: {},
PermAskRead: {}, PermAskTranscriptRead: {}, PermAskFeedbackWrite: {}, PermContentSafetyRead: {},
PermAIConfigRead: {}, PermCrisisRead: {}, PermCMSRead: {}, PermGrowthRead: {}, PermExploreRead: {}, PermPrivacyRead: {},
}
@@ -38,5 +38,8 @@ func (s *Service) SubmitFeedback(ctx context.Context, userID, threadID uuid.UUID
if strings.Contains(msg, "thread not found") {
return nil, errors.New("ask thread not found")
}
if strings.Contains(msg, "message not in thread") {
return nil, errors.New("message not in thread")
}
return nil, err
}
@@ -0,0 +1,7 @@
DELETE FROM admin_role_permissions WHERE code = 'admin.ask.transcript.read';
COMMENT ON TABLE crisis_events IS NULL;
COMMENT ON TABLE intervention_outcomes IS NULL;
COMMENT ON TABLE privacy_requests IS NULL;
COMMENT ON TABLE ask_handoff_cases IS NULL;
COMMENT ON TABLE moderation_cases IS NULL;
@@ -0,0 +1,13 @@
-- Ops hardening: Ask transcript perm + catalog≠case comments (review follow-up)
INSERT INTO admin_role_permissions(role_id, code)
SELECT r.id, 'admin.ask.transcript.read'
FROM admin_roles r
WHERE r.name = 'super_admin'
ON CONFLICT DO NOTHING;
COMMENT ON TABLE crisis_events IS 'CATALOG ONLY: crisis event type placeholders. Not user crisis cases.';
COMMENT ON TABLE intervention_outcomes IS 'CATALOG ONLY: intervention outcome type placeholders. Not user case outcomes.';
COMMENT ON TABLE privacy_requests IS 'CATALOG ONLY: privacy request type placeholders. Not user privacy tickets.';
COMMENT ON TABLE ask_handoff_cases IS 'CATALOG ONLY: handoff case type placeholders. Not live handoff tickets.';
COMMENT ON TABLE moderation_cases IS 'CATALOG ONLY: moderation case type placeholders. Not live moderation tickets.';
+1
View File
@@ -19,6 +19,7 @@
## 2026-08-08
- **Ops hardeningcode review · stash):** Ask transcript 分权 + 审计;catalog≠个案;量表读权限;feedback 归属校验;Funnel→`growth.read`catalog LIMITadmin-h5 目录文案
- **ECR-040…021 Closedlocal Ops loop):** ExploreConfig / GrowthInsights / Crisis / CMS / AI / Safety 只读目录扩展
## 2026-08-07
+17
View File
@@ -0,0 +1,17 @@
# Catalog vs Case — 冻结说明
Wave0 Ops 薄切片中下列表/路径是 **类型目录(catalog****不是**用户个案:
| 表 | Admin 路径 | 语义 |
|----|------------|------|
| `crisis_events` | `/admin/crisis/events` | 危机事件**类型**占位 |
| `intervention_outcomes` | `/admin/crisis/interventions` | 干预结果**类型**占位 |
| `moderation_cases` | `/admin/content-safety/cases` | 审核案**类型**占位 |
| `ask_handoff_cases` | `/admin/ask/handoffs` | 转接案**类型**占位 |
| `privacy_requests` | `/admin/privacy/requests` | 隐私请求**类型**占位 |
**规则**
1. 禁止在同表写入 `user_id`、原文、联系方式等个案字段。
2. 真实个案须新表 + 新路径(例如 `crisis_case_records` / `/admin/crisis/cases`)。
3. OpenAPI / Admin 文案须标明「目录占位」。
+25 -5
View File
@@ -566,8 +566,8 @@ paths:
/api/v1/admin/ask/threads/{id}:
get:
tags: [admin]
summary: Ask session detail with messages
description: Requires admin.ask.read; no message mutation
summary: Ask session meta (no message bodies)
description: Requires admin.ask.read; transcript via /messages
parameters:
- in: path
name: id
@@ -579,6 +579,26 @@ paths:
'404':
description: Not found
/api/v1/admin/ask/threads/{id}/messages:
get:
tags: [admin]
summary: Ask session transcript (audited)
description: Requires admin.ask.transcript.read; writes ask.transcript.read audit
parameters:
- in: path
name: id
required: true
schema: { type: string, format: uuid }
responses:
'200':
description: OK
'401':
description: Unauthorized
'403':
description: Forbidden
'404':
description: Not found
/api/v1/admin/content-safety/filter-rules:
get:
tags: [admin]
@@ -918,7 +938,7 @@ paths:
get:
tags: [admin]
summary: List ModerationCase catalog
description: Requires admin.content_safety.read
description: Requires admin.content_safety.read; TYPE CATALOG only — not live moderation tickets
responses:
'200':
description: OK
@@ -946,7 +966,7 @@ paths:
get:
tags: [admin]
summary: List CrisisEvent catalog
description: Requires admin.crisis.read
description: Requires admin.crisis.read; TYPE CATALOG only — not user crisis cases
responses:
'200':
description: OK
@@ -1198,7 +1218,7 @@ paths:
get:
tags: [admin]
summary: List FunnelDefinition catalog
description: Requires admin.analytics.read
description: Requires admin.growth.read; catalog only
responses:
'200':
description: OK