chore: 合入 stash Ops hardening 与 migration 000041
Ask/catalog 权限与审计加固、量表读权限统一,以及未提交的 ops hardening 变更。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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-026…040 运营只读目录聚合(不可写发布)</p>
|
||||
<p class="muted">ECR-026…040 运营只读目录聚合(类型占位,非用户个案;见 docs/WAVE0/CATALOG_VS_CASE.md)</p>
|
||||
<div class="tabs">
|
||||
<button
|
||||
v-for="(c, i) in catalogs"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
if len(codes) == 0 {
|
||||
response.Fail(c, http.StatusInternalServerError, 50000, "permission check failed")
|
||||
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 {
|
||||
checker.DenyPermission(c.Request.Context(), adminID, code, c.FullPath())
|
||||
response.Fail(c, http.StatusForbidden, 40301, "forbidden")
|
||||
c.Abort()
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ var (
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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.';
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
## 2026-08-08
|
||||
|
||||
- **Ops hardening(code review · stash):** Ask transcript 分权 + 审计;catalog≠个案;量表读权限;feedback 归属校验;Funnel→`growth.read`;catalog LIMIT;admin-h5 目录文案
|
||||
- **ECR-040…021 Closed(local Ops loop):** ExploreConfig / GrowthInsights / Crisis / CMS / AI / Safety 只读目录扩展
|
||||
|
||||
## 2026-08-07
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user