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 | 权限 | 语义 |
|
| Method | Path | 权限 | 语义 |
|
||||||
|--------|------|------|------|
|
|--------|------|------|------|
|
||||||
| GET | `/admin/ask/threads` | `admin.ask.read` | 列表(可选 `user_id`) |
|
| 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
|
## Migration
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,8 @@
|
|||||||
|
|
||||||
| Method | Path | 权限 | 语义 |
|
| Method | Path | 权限 | 语义 |
|
||||||
|--------|------|------|------|
|
|--------|------|------|------|
|
||||||
| GET | `/admin/analytics/funnel-definitions` | `admin.analytics.read` | 只读 |
|
| GET | `/admin/analytics/funnel-definitions` | `admin.growth.read` | 只读 |
|
||||||
| GET | `/admin/analytics/funnel-definitions/{id}` | `admin.analytics.read` | 只读 |
|
| GET | `/admin/analytics/funnel-definitions/{id}` | `admin.growth.read` | 只读 |
|
||||||
|
|
||||||
## Migration
|
## Migration
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,9 @@ PATCH status(已有 content.write)· 题干编辑 · UGC · 真支付
|
|||||||
|
|
||||||
| Method | Path | 权限 | 语义 |
|
| Method | Path | 权限 | 语义 |
|
||||||
|--------|------|------|------|
|
|--------|------|------|------|
|
||||||
| GET | `/admin/explore/scales` | `admin.explore.read` | 只读 |
|
| GET | `/admin/explore/scales` | `admin.explore.read` 或 `admin.content.write` | 只读 |
|
||||||
| GET | `/admin/explore/scales/{id}` | `admin.explore.read` | 只读 |
|
| GET | `/admin/explore/scales/{id}` | `admin.explore.read` 或 `admin.content.write` | 只读 |
|
||||||
|
| GET | `/admin/scales` | `admin.explore.read` 或 `admin.content.write` | 只读(与 explore 同源) |
|
||||||
|
|
||||||
## Migration
|
## 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
|
message_count: number
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
messages: Array<{ id: string; role: string; content: string; created_at: string }>
|
|
||||||
}>('GET', `/ask/threads/${id}`),
|
}>('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: () =>
|
filterRules: () =>
|
||||||
request<{
|
request<{
|
||||||
items: Array<{
|
items: Array<{
|
||||||
@@ -482,7 +492,7 @@ export const adminApi = {
|
|||||||
request<Record<string, unknown>>('GET', `/ai/tools/${id}`),
|
request<Record<string, unknown>>('GET', `/ai/tools/${id}`),
|
||||||
blockPolicies: () =>
|
blockPolicies: () =>
|
||||||
request<{ items: Array<Record<string, unknown>> }>('GET', '/content-safety/block-policies'),
|
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}`),
|
request<Record<string, unknown>>('GET', `/content-safety/block-policies/${id}`),
|
||||||
cases: () =>
|
cases: () =>
|
||||||
request<{ items: Array<Record<string, unknown>> }>('GET', '/content-safety/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'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
type Thread = Awaited<ReturnType<typeof adminApi.askThreads>>['items'][number]
|
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]
|
type Feedback = Awaited<ReturnType<typeof adminApi.askFeedback>>['items'][number]
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
@@ -20,8 +22,10 @@ const rating = ref(4)
|
|||||||
const tag = ref('helpful')
|
const tag = ref('helpful')
|
||||||
const note = ref('')
|
const note = ref('')
|
||||||
const fbMsg = ref('')
|
const fbMsg = ref('')
|
||||||
|
const transcriptDenied = ref(false)
|
||||||
|
|
||||||
const canWriteFeedback = () => auth.can('admin.ask.feedback.write')
|
const canWriteFeedback = () => auth.can('admin.ask.feedback.write')
|
||||||
|
const canReadTranscript = () => auth.can('admin.ask.transcript.read')
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -42,8 +46,16 @@ async function openThread(id: string) {
|
|||||||
detailErr.value = ''
|
detailErr.value = ''
|
||||||
selected.value = null
|
selected.value = null
|
||||||
fbMsg.value = ''
|
fbMsg.value = ''
|
||||||
|
transcriptDenied.value = false
|
||||||
try {
|
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) {
|
} catch (e) {
|
||||||
detailErr.value = e instanceof Error ? e.message : '详情失败'
|
detailErr.value = e instanceof Error ? e.message : '详情失败'
|
||||||
} finally {
|
} finally {
|
||||||
@@ -123,8 +135,9 @@ onMounted(load)
|
|||||||
{{ selected.scene || '—' }} · {{ selected.message_count }} 条 ·
|
{{ selected.scene || '—' }} · {{ selected.message_count }} 条 ·
|
||||||
<RouterLink :to="`/users/${selected.user_id}`">用户</RouterLink>
|
<RouterLink :to="`/users/${selected.user_id}`">用户</RouterLink>
|
||||||
</p>
|
</p>
|
||||||
<ul class="msgs">
|
<p v-if="transcriptDenied" class="muted">无对话原文权限(admin.ask.transcript.read)</p>
|
||||||
<li v-for="m in selected.messages" :key="m.id" :class="m.role">
|
<ul v-else class="msgs">
|
||||||
|
<li v-for="m in selected.messages || []" :key="m.id" :class="m.role">
|
||||||
<span class="role">{{ m.role }}</span>
|
<span class="role">{{ m.role }}</span>
|
||||||
<p>{{ m.content }}</p>
|
<p>{{ m.content }}</p>
|
||||||
<time>{{ fmtTime(m.created_at) }}</time>
|
<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 }
|
type Row = { id: string; code?: string; slug?: string; title?: string; status?: string }
|
||||||
|
|
||||||
const catalogs = [
|
const catalogs = [
|
||||||
{ key: 'publications', label: '定时发布', load: () => adminApi.publications() },
|
{ key: 'publications', label: '定时发布(目录)', load: () => adminApi.publications() },
|
||||||
{ key: 'knowledgeChunks', label: '知识块', load: () => adminApi.knowledgeChunks() },
|
{ key: 'knowledgeChunks', label: '知识块(目录)', load: () => adminApi.knowledgeChunks() },
|
||||||
{ key: 'tools', label: '工具定义', load: () => adminApi.tools() },
|
{ key: 'tools', label: '工具定义(目录)', load: () => adminApi.tools() },
|
||||||
{ key: 'blockPolicies', label: '拦截策略', load: () => adminApi.blockPolicies() },
|
{ key: 'blockPolicies', label: '拦截策略(目录)', load: () => adminApi.blockPolicies() },
|
||||||
{ key: 'cases', label: '审核案', load: () => adminApi.cases() },
|
{ key: 'cases', label: '审核类型(目录)', load: () => adminApi.cases() },
|
||||||
{ key: 'events', label: '危机事件', load: () => adminApi.events() },
|
{ key: 'events', label: '危机类型(目录)', load: () => adminApi.events() },
|
||||||
{ key: 'interventions', label: '干预结果', load: () => adminApi.interventions() },
|
{ key: 'interventions', label: '干预类型(目录)', load: () => adminApi.interventions() },
|
||||||
{ key: 'handoffs', label: '转接案', load: () => adminApi.handoffs() },
|
{ key: 'handoffs', label: '转接类型(目录)', load: () => adminApi.handoffs() },
|
||||||
{ key: 'privacy', label: '隐私请求', load: () => adminApi.requests() },
|
{ key: 'privacy', label: '隐私类型(目录)', load: () => adminApi.requests() },
|
||||||
{ key: 'star', label: '星座配置', load: () => adminApi.starConfigs() },
|
{ key: 'star', label: '星座配置', load: () => adminApi.starConfigs() },
|
||||||
{ key: 'rhythm', label: '节律配置', load: () => adminApi.rhythmConfigs() },
|
{ key: 'rhythm', label: '节律配置', load: () => adminApi.rhythmConfigs() },
|
||||||
{ key: 'decks', label: '意象牌组', load: () => adminApi.imageCardDecks() },
|
{ key: 'decks', label: '意象牌组', load: () => adminApi.imageCardDecks() },
|
||||||
@@ -50,7 +50,7 @@ onMounted(() => {
|
|||||||
<template>
|
<template>
|
||||||
<section>
|
<section>
|
||||||
<h1>目录只读仓</h1>
|
<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">
|
<div class="tabs">
|
||||||
<button
|
<button
|
||||||
v-for="(c, i) in catalogs"
|
v-for="(c, i) in catalogs"
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
func (h *AdminHandler) registerAskOps(authed *gin.RouterGroup) {
|
func (h *AdminHandler) registerAskOps(authed *gin.RouterGroup) {
|
||||||
authed.GET("/ask/threads", middleware.RequireAdminPermission(h.Svc, admin.PermAskRead), h.ListAskThreads)
|
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", 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) {
|
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")
|
response.Fail(c, http.StatusBadRequest, 40002, "invalid thread id")
|
||||||
return
|
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) {
|
if errors.Is(err, admin.ErrAskThreadNotFound) {
|
||||||
response.Fail(c, http.StatusNotFound, 40402, "ask thread not found")
|
response.Fail(c, http.StatusNotFound, 40402, "ask thread not found")
|
||||||
return
|
return
|
||||||
@@ -53,5 +54,28 @@ func (h *AdminHandler) GetAskThread(c *gin.Context) {
|
|||||||
response.Fail(c, http.StatusInternalServerError, 50020, "get ask thread failed")
|
response.Fail(c, http.StatusInternalServerError, 50020, "get ask thread failed")
|
||||||
return
|
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)
|
response.OK(c, detail)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
func (h *AdminHandler) registerContent(authed *gin.RouterGroup) {
|
func (h *AdminHandler) registerContent(authed *gin.RouterGroup) {
|
||||||
authed.GET("/home/tools", middleware.RequireAdminPermission(h.Svc, admin.PermContentWrite), h.ListHomeTools)
|
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.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)
|
authed.PATCH("/scales/:id", middleware.RequireAdminPermission(h.Svc, admin.PermContentWrite), h.PatchScale)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import (
|
|||||||
|
|
||||||
func (h *AdminHandler) registerExploreScales(authed *gin.RouterGroup) {
|
func (h *AdminHandler) registerExploreScales(authed *gin.RouterGroup) {
|
||||||
g := authed.Group("/explore")
|
g := authed.Group("/explore")
|
||||||
g.GET("/scales", middleware.RequireAdminPermission(h.Svc, admin.PermExploreRead), h.ListExploreScales)
|
g.GET("/scales", middleware.RequireAnyAdminPermission(h.Svc, admin.PermExploreRead, admin.PermContentWrite), h.ListExploreScales)
|
||||||
g.GET("/scales/:id", middleware.RequireAdminPermission(h.Svc, admin.PermExploreRead), h.GetExploreScale)
|
g.GET("/scales/:id", middleware.RequireAnyAdminPermission(h.Svc, admin.PermExploreRead, admin.PermContentWrite), h.GetExploreScale)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AdminHandler) ListExploreScales(c *gin.Context) {
|
func (h *AdminHandler) ListExploreScales(c *gin.Context) {
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import (
|
|||||||
|
|
||||||
func (h *AdminHandler) registerFunnelDefinitions(authed *gin.RouterGroup) {
|
func (h *AdminHandler) registerFunnelDefinitions(authed *gin.RouterGroup) {
|
||||||
g := authed.Group("/analytics")
|
g := authed.Group("/analytics")
|
||||||
g.GET("/funnel-definitions", middleware.RequireAdminPermission(h.Svc, admin.PermAnalyticsRead), h.ListFunnelDefinitions)
|
g.GET("/funnel-definitions", middleware.RequireAdminPermission(h.Svc, admin.PermGrowthRead), h.ListFunnelDefinitions)
|
||||||
g.GET("/funnel-definitions/:id", middleware.RequireAdminPermission(h.Svc, admin.PermAnalyticsRead), h.GetFunnelDefinition)
|
g.GET("/funnel-definitions/:id", middleware.RequireAdminPermission(h.Svc, admin.PermGrowthRead), h.GetFunnelDefinition)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AdminHandler) ListFunnelDefinitions(c *gin.Context) {
|
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")
|
response.Fail(c, http.StatusNotFound, 40402, "ask thread not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if errors.Is(err, admin.ErrMessageNotInThread) {
|
||||||
|
response.Fail(c, http.StatusBadRequest, 40030, "message not in thread")
|
||||||
|
return
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Fail(c, http.StatusInternalServerError, 50026, "create feedback failed")
|
response.Fail(c, http.StatusInternalServerError, 50026, "create feedback failed")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -60,6 +60,10 @@ func (h *AskHandler) SubmitFeedback(c *gin.Context) {
|
|||||||
response.Fail(c, http.StatusNotFound, 40410, msg)
|
response.Fail(c, http.StatusNotFound, 40410, msg)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if strings.Contains(msg, "message not in thread") {
|
||||||
|
response.Fail(c, http.StatusBadRequest, 40030, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
response.Fail(c, http.StatusInternalServerError, 50000, msg)
|
response.Fail(c, http.StatusInternalServerError, 50000, msg)
|
||||||
return
|
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)
|
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ask/threads/"+threadID, nil, tok)
|
||||||
if code != 200 {
|
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 {
|
var detail struct {
|
||||||
Messages []struct {
|
Messages []struct {
|
||||||
@@ -111,5 +151,26 @@ func TestAskOperations(t *testing.T) {
|
|||||||
if len(detail.Messages) < 2 {
|
if len(detail.Messages) < 2 {
|
||||||
t.Fatalf("expected user+assistant, got %#v", detail.Messages)
|
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
|
_ = key
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,5 +132,34 @@ func TestQualityFeedback(t *testing.T) {
|
|||||||
if !okAudit {
|
if !okAudit {
|
||||||
t.Fatal("missing ask.feedback.create audit")
|
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
|
_ = key
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ type AdminPermissionChecker interface {
|
|||||||
|
|
||||||
// RequireAdminPermission aborts with 403 when the admin lacks code.
|
// RequireAdminPermission aborts with 403 when the admin lacks code.
|
||||||
func RequireAdminPermission(checker AdminPermissionChecker, code string) gin.HandlerFunc {
|
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) {
|
return func(c *gin.Context) {
|
||||||
adminID, ok := AdminIDFromContext(c)
|
adminID, ok := AdminIDFromContext(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -25,18 +30,27 @@ func RequireAdminPermission(checker AdminPermissionChecker, code string) gin.Han
|
|||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
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)
|
okPerm, err := checker.HasPermission(c.Request.Context(), adminID, code)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Fail(c, http.StatusInternalServerError, 50000, "permission check failed")
|
response.Fail(c, http.StatusInternalServerError, 50000, "permission check failed")
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !okPerm {
|
if okPerm {
|
||||||
checker.DenyPermission(c.Request.Context(), adminID, code, c.FullPath())
|
c.Next()
|
||||||
response.Fail(c, http.StatusForbidden, 40301, "forbidden")
|
|
||||||
c.Abort()
|
|
||||||
return
|
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, `
|
rows, err := r.Pool.Query(ctx, `
|
||||||
SELECT id, code, title, scene, body, version, active, system, updated_at
|
SELECT id, code, title, scene, body, version, active, system, updated_at
|
||||||
FROM system_prompts
|
FROM system_prompts
|
||||||
ORDER BY active DESC, code ASC`)
|
ORDER BY active DESC, code ASC LIMIT 500`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -79,7 +79,7 @@ func (r *AdminRepo) ListKnowledgeSources(ctx context.Context) ([]KnowledgeSource
|
|||||||
rows, err := r.Pool.Query(ctx, `
|
rows, err := r.Pool.Query(ctx, `
|
||||||
SELECT id, code, title, description, source_kind, version, active, system, updated_at
|
SELECT id, code, title, description, source_kind, version, active, system, updated_at
|
||||||
FROM knowledge_sources
|
FROM knowledge_sources
|
||||||
ORDER BY active DESC, code ASC`)
|
ORDER BY active DESC, code ASC LIMIT 500`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func (r *AdminRepo) ListBlockPolicies(ctx context.Context) ([]BlockPolicyRow, er
|
|||||||
rows, err := r.Pool.Query(ctx, `
|
rows, err := r.Pool.Query(ctx, `
|
||||||
SELECT id, code, title, action, active, system, updated_at
|
SELECT id, code, title, action, active, system, updated_at
|
||||||
FROM block_policies
|
FROM block_policies
|
||||||
ORDER BY active DESC, code ASC`)
|
ORDER BY active DESC, code ASC LIMIT 500`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func (r *AdminRepo) ListBanners(ctx context.Context) ([]BannerRow, error) {
|
|||||||
rows, err := r.Pool.Query(ctx, `
|
rows, err := r.Pool.Query(ctx, `
|
||||||
SELECT id, code, title, placement, image_url, link_path, sort_order, active, system, updated_at
|
SELECT id, code, title, placement, image_url, link_path, sort_order, active, system, updated_at
|
||||||
FROM ops_banners
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -83,7 +83,7 @@ func (r *AdminRepo) ListFeedSlots(ctx context.Context) ([]FeedSlotRow, error) {
|
|||||||
rows, err := r.Pool.Query(ctx, `
|
rows, err := r.Pool.Query(ctx, `
|
||||||
SELECT id, code, title, slot_key, placement, active, system, updated_at
|
SELECT id, code, title, slot_key, placement, active, system, updated_at
|
||||||
FROM ops_feed_slots
|
FROM ops_feed_slots
|
||||||
ORDER BY active DESC, code ASC`)
|
ORDER BY active DESC, code ASC LIMIT 500`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func (r *AdminRepo) ListFilterRules(ctx context.Context) ([]FilterRuleRow, error
|
|||||||
rows, err := r.Pool.Query(ctx, `
|
rows, err := r.Pool.Query(ctx, `
|
||||||
SELECT id, code, title, category, pattern, action, active, system, updated_at
|
SELECT id, code, title, category, pattern, action, active, system, updated_at
|
||||||
FROM filter_rules
|
FROM filter_rules
|
||||||
ORDER BY active DESC, category ASC, code ASC`)
|
ORDER BY active DESC, category ASC, code ASC LIMIT 500`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func (r *AdminRepo) ListCrisisEvents(ctx context.Context) ([]CrisisEventRow, err
|
|||||||
rows, err := r.Pool.Query(ctx, `
|
rows, err := r.Pool.Query(ctx, `
|
||||||
SELECT id, code, title, severity, active, system, updated_at
|
SELECT id, code, title, severity, active, system, updated_at
|
||||||
FROM crisis_events
|
FROM crisis_events
|
||||||
ORDER BY active DESC, code ASC`)
|
ORDER BY active DESC, code ASC LIMIT 500`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func (r *AdminRepo) ListCrisisPolicies(ctx context.Context) ([]CrisisPolicyRow,
|
|||||||
rows, err := r.Pool.Query(ctx, `
|
rows, err := r.Pool.Query(ctx, `
|
||||||
SELECT id, code, title, severity, pattern, action, helpline_text, active, system, updated_at
|
SELECT id, code, title, severity, pattern, action, helpline_text, active, system, updated_at
|
||||||
FROM crisis_policies
|
FROM crisis_policies
|
||||||
ORDER BY active DESC, severity DESC, code ASC`)
|
ORDER BY active DESC, severity DESC, code ASC LIMIT 500`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ func (r *AdminRepo) ListFunnelDefinitions(ctx context.Context) ([]FunnelDefiniti
|
|||||||
rows, err := r.Pool.Query(ctx, `
|
rows, err := r.Pool.Query(ctx, `
|
||||||
SELECT id, code, title, active, system, updated_at
|
SELECT id, code, title, active, system, updated_at
|
||||||
FROM funnel_definitions
|
FROM funnel_definitions
|
||||||
ORDER BY active DESC, code ASC`)
|
ORDER BY active DESC, code ASC LIMIT 500`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func (r *AdminRepo) ListHandoffCases(ctx context.Context) ([]HandoffCaseRow, err
|
|||||||
rows, err := r.Pool.Query(ctx, `
|
rows, err := r.Pool.Query(ctx, `
|
||||||
SELECT id, code, title, status, active, system, updated_at
|
SELECT id, code, title, status, active, system, updated_at
|
||||||
FROM ask_handoff_cases
|
FROM ask_handoff_cases
|
||||||
ORDER BY active DESC, code ASC`)
|
ORDER BY active DESC, code ASC LIMIT 500`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ func (r *AdminRepo) ListImageCardDecks(ctx context.Context) ([]ImageCardDeckRow,
|
|||||||
rows, err := r.Pool.Query(ctx, `
|
rows, err := r.Pool.Query(ctx, `
|
||||||
SELECT id, code, title, active, system, updated_at
|
SELECT id, code, title, active, system, updated_at
|
||||||
FROM image_card_decks
|
FROM image_card_decks
|
||||||
ORDER BY active DESC, code ASC`)
|
ORDER BY active DESC, code ASC LIMIT 500`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func (r *AdminRepo) ListInterventionOutcomes(ctx context.Context) ([]Interventio
|
|||||||
rows, err := r.Pool.Query(ctx, `
|
rows, err := r.Pool.Query(ctx, `
|
||||||
SELECT id, code, title, outcome, active, system, updated_at
|
SELECT id, code, title, outcome, active, system, updated_at
|
||||||
FROM intervention_outcomes
|
FROM intervention_outcomes
|
||||||
ORDER BY active DESC, code ASC`)
|
ORDER BY active DESC, code ASC LIMIT 500`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ func (r *AdminRepo) ListKnowledgeChunks(ctx context.Context) ([]KnowledgeChunkRo
|
|||||||
rows, err := r.Pool.Query(ctx, `
|
rows, err := r.Pool.Query(ctx, `
|
||||||
SELECT id, code, source_code, title, body, active, system, updated_at
|
SELECT id, code, source_code, title, body, active, system, updated_at
|
||||||
FROM knowledge_chunks
|
FROM knowledge_chunks
|
||||||
ORDER BY active DESC, code ASC`)
|
ORDER BY active DESC, code ASC LIMIT 500`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func (r *AdminRepo) ListModerationCases(ctx context.Context) ([]ModerationCaseRo
|
|||||||
rows, err := r.Pool.Query(ctx, `
|
rows, err := r.Pool.Query(ctx, `
|
||||||
SELECT id, code, title, status, active, system, updated_at
|
SELECT id, code, title, status, active, system, updated_at
|
||||||
FROM moderation_cases
|
FROM moderation_cases
|
||||||
ORDER BY active DESC, code ASC`)
|
ORDER BY active DESC, code ASC LIMIT 500`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ func (r *AdminRepo) ListPrivacyRequests(ctx context.Context) ([]PrivacyRequestRo
|
|||||||
rows, err := r.Pool.Query(ctx, `
|
rows, err := r.Pool.Query(ctx, `
|
||||||
SELECT id, code, title, kind, status, active, system, updated_at
|
SELECT id, code, title, kind, status, active, system, updated_at
|
||||||
FROM privacy_requests
|
FROM privacy_requests
|
||||||
ORDER BY active DESC, code ASC`)
|
ORDER BY active DESC, code ASC LIMIT 500`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,15 @@ func (r *AdminRepo) CreateAdminQualityFeedback(
|
|||||||
if !ok {
|
if !ok {
|
||||||
return nil, errors.New("ask thread not found")
|
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)
|
tx, err := r.Pool.Begin(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -117,6 +126,18 @@ func (r *AskRepo) CreateUserQualityFeedback(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.New("ask thread not found")
|
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
|
var f QualityFeedbackRow
|
||||||
err = r.Pool.QueryRow(ctx, `
|
err = r.Pool.QueryRow(ctx, `
|
||||||
INSERT INTO ask_quality_feedback(thread_id, message_id, source, rating, tag, note, created_by_user)
|
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
|
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 {
|
func validateFeedback(rating int, tag, note *string) error {
|
||||||
if rating < 1 || rating > 5 {
|
if rating < 1 || rating > 5 {
|
||||||
return errors.New("rating must be 1-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, `
|
rows, err := r.Pool.Query(ctx, `
|
||||||
SELECT id, code, title, scene, active, system, updated_at
|
SELECT id, code, title, scene, active, system, updated_at
|
||||||
FROM report_templates
|
FROM report_templates
|
||||||
ORDER BY active DESC, code ASC`)
|
ORDER BY active DESC, code ASC LIMIT 500`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ func (r *AdminRepo) ListRhythmConfigs(ctx context.Context) ([]RhythmConfigRow, e
|
|||||||
rows, err := r.Pool.Query(ctx, `
|
rows, err := r.Pool.Query(ctx, `
|
||||||
SELECT id, code, title, active, system, updated_at
|
SELECT id, code, title, active, system, updated_at
|
||||||
FROM rhythm_configs
|
FROM rhythm_configs
|
||||||
ORDER BY active DESC, code ASC`)
|
ORDER BY active DESC, code ASC LIMIT 500`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ type ScaleAdminItem struct {
|
|||||||
func (r *ScaleRepo) ListAllAdmin(ctx context.Context) ([]ScaleAdminItem, error) {
|
func (r *ScaleRepo) ListAllAdmin(ctx context.Context) ([]ScaleAdminItem, error) {
|
||||||
rows, err := r.Pool.Query(ctx, `
|
rows, err := r.Pool.Query(ctx, `
|
||||||
SELECT id, slug, title, description, status FROM scales
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ func (r *AdminRepo) ListScheduledPublications(ctx context.Context) ([]ScheduledP
|
|||||||
rows, err := r.Pool.Query(ctx, `
|
rows, err := r.Pool.Query(ctx, `
|
||||||
SELECT id, code, title, target_kind, target_code, active, system, updated_at
|
SELECT id, code, title, target_kind, target_code, active, system, updated_at
|
||||||
FROM ops_scheduled_publications
|
FROM ops_scheduled_publications
|
||||||
ORDER BY active DESC, code ASC`)
|
ORDER BY active DESC, code ASC LIMIT 500`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ func (r *AdminRepo) ListStarConfigs(ctx context.Context) ([]StarConfigRow, error
|
|||||||
rows, err := r.Pool.Query(ctx, `
|
rows, err := r.Pool.Query(ctx, `
|
||||||
SELECT id, code, title, active, system, updated_at
|
SELECT id, code, title, active, system, updated_at
|
||||||
FROM star_configs
|
FROM star_configs
|
||||||
ORDER BY active DESC, code ASC`)
|
ORDER BY active DESC, code ASC LIMIT 500`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func (r *AdminRepo) ListToolDefinitions(ctx context.Context) ([]ToolDefinitionRo
|
|||||||
rows, err := r.Pool.Query(ctx, `
|
rows, err := r.Pool.Query(ctx, `
|
||||||
SELECT id, code, title, description, active, system, updated_at
|
SELECT id, code, title, description, active, system, updated_at
|
||||||
FROM tool_definitions
|
FROM tool_definitions
|
||||||
ORDER BY active DESC, code ASC`)
|
ORDER BY active DESC, code ASC LIMIT 500`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package admin
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -10,7 +11,7 @@ import (
|
|||||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AskSessionDetail is AskSessionView plus messages.
|
// AskSessionDetail is AskSessionView plus messages (transcript).
|
||||||
type AskSessionDetail struct {
|
type AskSessionDetail struct {
|
||||||
repository.AskSessionView
|
repository.AskSessionView
|
||||||
Messages []repository.AskMessageView `json:"messages"`
|
Messages []repository.AskMessageView `json:"messages"`
|
||||||
@@ -30,8 +31,20 @@ func (s *Service) ListAskSessions(ctx context.Context, userID *uuid.UUID, limit,
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAskSessionDetail loads meta + messages.
|
// GetAskSessionMeta loads thread meta without message bodies.
|
||||||
func (s *Service) GetAskSessionDetail(ctx context.Context, threadID uuid.UUID) (*AskSessionDetail, error) {
|
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)
|
view, err := s.Repo.GetAskSession(ctx, threadID)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, ErrAskThreadNotFound
|
return nil, ErrAskThreadNotFound
|
||||||
@@ -46,5 +59,10 @@ func (s *Service) GetAskSessionDetail(ctx context.Context, threadID uuid.UUID) (
|
|||||||
if msgs == nil {
|
if msgs == nil {
|
||||||
msgs = []repository.AskMessageView{}
|
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
|
return &AskSessionDetail{AskSessionView: *view, Messages: msgs}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ var (
|
|||||||
ErrBadFeedbackRating = errString("rating must be 1-5")
|
ErrBadFeedbackRating = errString("rating must be 1-5")
|
||||||
ErrBadFeedbackTag = errString("invalid tag")
|
ErrBadFeedbackTag = errString("invalid tag")
|
||||||
ErrFeedbackNoteLong = errString("note too long")
|
ErrFeedbackNoteLong = errString("note too long")
|
||||||
|
ErrMessageNotInThread = errString("message not in thread")
|
||||||
)
|
)
|
||||||
|
|
||||||
// ListQualityFeedback lists recent QualityFeedback.
|
// ListQualityFeedback lists recent QualityFeedback.
|
||||||
@@ -54,6 +55,8 @@ func (s *Service) CreateQualityFeedback(
|
|||||||
return nil, ErrFeedbackNoteLong
|
return nil, ErrFeedbackNoteLong
|
||||||
case strings.Contains(msg, "thread not found"):
|
case strings.Contains(msg, "thread not found"):
|
||||||
return nil, ErrAskThreadNotFound
|
return nil, ErrAskThreadNotFound
|
||||||
|
case strings.Contains(msg, "message not in thread"):
|
||||||
|
return nil, ErrMessageNotInThread
|
||||||
default:
|
default:
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const (
|
|||||||
PermMembershipCodesRead = "admin.membership.codes.read"
|
PermMembershipCodesRead = "admin.membership.codes.read"
|
||||||
PermMembershipCodesWrite = "admin.membership.codes.write"
|
PermMembershipCodesWrite = "admin.membership.codes.write"
|
||||||
PermAskRead = "admin.ask.read"
|
PermAskRead = "admin.ask.read"
|
||||||
|
PermAskTranscriptRead = "admin.ask.transcript.read"
|
||||||
PermAskFeedbackWrite = "admin.ask.feedback.write"
|
PermAskFeedbackWrite = "admin.ask.feedback.write"
|
||||||
PermContentSafetyRead = "admin.content_safety.read"
|
PermContentSafetyRead = "admin.content_safety.read"
|
||||||
PermAIConfigRead = "admin.ai_config.read"
|
PermAIConfigRead = "admin.ai_config.read"
|
||||||
@@ -41,7 +42,7 @@ var knownPermissions = map[string]struct{}{
|
|||||||
PermContentWrite: {}, PermRolesRead: {}, PermRolesWrite: {},
|
PermContentWrite: {}, PermRolesRead: {}, PermRolesWrite: {},
|
||||||
PermUsersStatusWrite: {}, PermMembershipPlansRead: {}, PermMembershipPlansWrite: {},
|
PermUsersStatusWrite: {}, PermMembershipPlansRead: {}, PermMembershipPlansWrite: {},
|
||||||
PermMembershipCodesRead: {}, PermMembershipCodesWrite: {},
|
PermMembershipCodesRead: {}, PermMembershipCodesWrite: {},
|
||||||
PermAskRead: {}, PermAskFeedbackWrite: {}, PermContentSafetyRead: {},
|
PermAskRead: {}, PermAskTranscriptRead: {}, PermAskFeedbackWrite: {}, PermContentSafetyRead: {},
|
||||||
PermAIConfigRead: {}, PermCrisisRead: {}, PermCMSRead: {}, PermGrowthRead: {}, PermExploreRead: {}, PermPrivacyRead: {},
|
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") {
|
if strings.Contains(msg, "thread not found") {
|
||||||
return nil, errors.New("ask 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
|
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
|
## 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 只读目录扩展
|
- **ECR-040…021 Closed(local Ops loop):** ExploreConfig / GrowthInsights / Crisis / CMS / AI / Safety 只读目录扩展
|
||||||
|
|
||||||
## 2026-08-07
|
## 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}:
|
/api/v1/admin/ask/threads/{id}:
|
||||||
get:
|
get:
|
||||||
tags: [admin]
|
tags: [admin]
|
||||||
summary: Ask session detail with messages
|
summary: Ask session meta (no message bodies)
|
||||||
description: Requires admin.ask.read; no message mutation
|
description: Requires admin.ask.read; transcript via /messages
|
||||||
parameters:
|
parameters:
|
||||||
- in: path
|
- in: path
|
||||||
name: id
|
name: id
|
||||||
@@ -579,6 +579,26 @@ paths:
|
|||||||
'404':
|
'404':
|
||||||
description: Not found
|
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:
|
/api/v1/admin/content-safety/filter-rules:
|
||||||
get:
|
get:
|
||||||
tags: [admin]
|
tags: [admin]
|
||||||
@@ -918,7 +938,7 @@ paths:
|
|||||||
get:
|
get:
|
||||||
tags: [admin]
|
tags: [admin]
|
||||||
summary: List ModerationCase catalog
|
summary: List ModerationCase catalog
|
||||||
description: Requires admin.content_safety.read
|
description: Requires admin.content_safety.read; TYPE CATALOG only — not live moderation tickets
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: OK
|
description: OK
|
||||||
@@ -946,7 +966,7 @@ paths:
|
|||||||
get:
|
get:
|
||||||
tags: [admin]
|
tags: [admin]
|
||||||
summary: List CrisisEvent catalog
|
summary: List CrisisEvent catalog
|
||||||
description: Requires admin.crisis.read
|
description: Requires admin.crisis.read; TYPE CATALOG only — not user crisis cases
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: OK
|
description: OK
|
||||||
@@ -1198,7 +1218,7 @@ paths:
|
|||||||
get:
|
get:
|
||||||
tags: [admin]
|
tags: [admin]
|
||||||
summary: List FunnelDefinition catalog
|
summary: List FunnelDefinition catalog
|
||||||
description: Requires admin.analytics.read
|
description: Requires admin.growth.read; catalog only
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: OK
|
description: OK
|
||||||
|
|||||||
Reference in New Issue
Block a user