feat(ECR-034): AdminGovernance PrivacyRequest 只读并 Closed

PrivacyRequest catalog (000035) · Loop continuous.
This commit is contained in:
jackyu66git
2026-08-08 03:15:59 +08:00
parent aad6cdf7ea
commit 45e98f6872
26 changed files with 468 additions and 2 deletions
+1
View File
@@ -43,6 +43,7 @@
| [ops-crisis-event.md](ops-crisis-event.md) | CrisisCare CrisisEvent | §7 | `GET /admin/crisis/events*` | Ops-D · **ECR-031 Closed** |
| [ops-intervention-outcome.md](ops-intervention-outcome.md) | CrisisCare InterventionOutcome | §7 | `GET /admin/crisis/interventions*` | Ops-D · **ECR-032 Closed** |
| [ops-handoff-case.md](ops-handoff-case.md) | AskOperations HandoffCase | §7 | `GET /admin/ask/handoffs*` | Ops-D · **ECR-033 Closed** |
| [ops-privacy-request.md](ops-privacy-request.md) | AdminGovernance PrivacyRequest | §7 | `GET /admin/privacy/requests*` | Ops-D · **ECR-034 Closed** |
新功能:复制 `_TEMPLATE.md` → 填满 → 在本表登记 → 再编码。
@@ -0,0 +1,41 @@
# Feature Spec: AdminGovernance · PrivacyRequestOps · ECR-034
> Status: `Active`Loop continuous · **ECR-034 Closed**
> Parent: WAVE0-FROZEN · Predecessor: ECR-033 Closed
> Capability: `AdminGovernance` · BC: `Admin_Auth_Audit`
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
## Non-goals
隐私请求履约写 · soft-delete · UGC · 真支付
## L2 Domain
| 概念 | 语义 |
|------|------|
| `PrivacyRequest` | 本切片只读目录;code 唯一(若适用) |
## L3 API
| Method | Path | 权限 | 语义 |
|--------|------|------|------|
| GET | `/admin/privacy/requests` | `admin.privacy.read` | 只读 |
| GET | `/admin/privacy/requests/{id}` | `admin.privacy.read` | 只读 |
## Migration
`000035`:表 + 种子(若有) + 授予 admin.privacy.read
## L4 AC
| ID | Then |
|----|------|
| AC-F-01 | list 含种子或空列表合法 |
| AC-F-02 | 已知 id get 200 |
| AC-F-03 | 未知 id → 404 |
| AC-S-01 | 无 Admin → 401 |
| AC-S-02 | 无权限 → 403 |
| AC-P-01 | list < 500ms |
| AC-O-01 | N/A 只读 |
contract_diff: `docs/CONTRACT_DIFF/ECR-034.yaml`
+4
View File
@@ -465,6 +465,10 @@ export const adminApi = {
request<{ items: Array<Record<string, unknown>> }>('GET', '/ask/handoffs'),
handoff: (id: string) =>
request<Record<string, unknown>>('GET', `/ask/handoffs/${id}`),
requests: () =>
request<{ items: Array<Record<string, unknown>> }>('GET', '/privacy/requests'),
request: (id: string) =>
request<Record<string, unknown>>('GET', `/privacy/requests/${id}`),
orders: () =>
request<{
items: Array<{
+1
View File
@@ -62,6 +62,7 @@ func (h *AdminHandler) Register(api *gin.RouterGroup) {
h.registerCrisisEvents(authed)
h.registerInterventionOutcomes(authed)
h.registerHandoffCases(authed)
h.registerPrivacyRequests(authed)
}
func (h *AdminHandler) Login(c *gin.Context) {
@@ -0,0 +1,46 @@
package handler
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
"github.com/yuxingu/digital-psychology/apps/api/internal/service/admin"
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
)
func (h *AdminHandler) registerPrivacyRequests(authed *gin.RouterGroup) {
g := authed.Group("/privacy")
g.GET("/requests", middleware.RequireAdminPermission(h.Svc, admin.PermPrivacyRead), h.ListPrivacyRequests)
g.GET("/requests/:id", middleware.RequireAdminPermission(h.Svc, admin.PermPrivacyRead), h.GetPrivacyRequest)
}
func (h *AdminHandler) ListPrivacyRequests(c *gin.Context) {
items, err := h.Svc.ListPrivacyRequests(c.Request.Context())
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50050, "list privacy-request failed")
return
}
response.OK(c, gin.H{"items": items})
}
func (h *AdminHandler) GetPrivacyRequest(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
response.Fail(c, http.StatusBadRequest, 40002, "invalid id")
return
}
row, err := h.Svc.GetPrivacyRequest(c.Request.Context(), id)
if errors.Is(err, admin.ErrPrivacyRequestNotFound) {
response.Fail(c, http.StatusNotFound, 40420, "privacy-request not found")
return
}
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50051, "get privacy-request failed")
return
}
response.OK(c, row)
}
@@ -0,0 +1,88 @@
package integration_test
import (
"context"
"encoding/json"
"fmt"
"net/http"
"testing"
"time"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
func TestAdminPrivacyRequests(t *testing.T) {
r, pool := setupAPIPool(t)
ctx := context.Background()
tok := adminLogin(t, r, "admin", "change-me")
_, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/privacy/requests", nil, "")
if code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", code)
}
limitedRoleID := uuid.New()
_, err := pool.Exec(ctx, `INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`,
limitedRoleID, "lim_"+limitedRoleID.String()[:8])
if err != nil {
t.Fatal(err)
}
_, err = pool.Exec(ctx, `INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.users.read')`, limitedRoleID)
if err != nil {
t.Fatal(err)
}
hash, err := bcrypt.GenerateFromPassword([]byte("limited-pass"), bcrypt.DefaultCost)
if err != nil {
t.Fatal(err)
}
limUser := fmt.Sprintf("lim_%d", time.Now().UnixNano())
_, err = pool.Exec(ctx, `INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1,$2,$3)`,
limUser, string(hash), limitedRoleID)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, limUser)
_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID)
})
limTok := adminLogin(t, r, limUser, "limited-pass")
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/privacy/requests", nil, limTok)
if code != http.StatusForbidden {
t.Fatalf("expected 403, got %d", code)
}
start := time.Now()
env, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/privacy/requests", nil, tok)
if code != 200 || env.Code != 0 {
t.Fatalf("list http=%d msg=%s", code, env.Message)
}
if time.Since(start) > 500*time.Millisecond {
t.Fatalf("list too slow %v", time.Since(start))
}
var list struct {
Items []struct {
ID string `json:"id"`
Code string `json:"code"`
} `json:"items"`
}
_ = json.Unmarshal(env.Data, &list)
var id string
for _, it := range list.Items {
if it.Code == "demo_export_req" {
id = it.ID
break
}
}
if id == "" {
t.Fatalf("missing demo_export_req: %#v", list.Items)
}
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/privacy/requests/"+id, nil, tok)
if code != 200 {
t.Fatalf("get %d", code)
}
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/privacy/requests/"+fakeUUID(), nil, tok)
if code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", code)
}
}
@@ -0,0 +1,59 @@
package repository
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// PrivacyRequestRow is PrivacyRequest catalog row.
type PrivacyRequestRow struct {
ID uuid.UUID `json:"id"`
Code string `json:"code"`
Title string `json:"title"`
Kind string `json:"kind"`
Status string `json:"status"`
Active bool `json:"active"`
System bool `json:"system"`
UpdatedAt time.Time `json:"updated_at"`
}
// ListPrivacyRequests returns PrivacyRequest catalog.
func (r *AdminRepo) ListPrivacyRequests(ctx context.Context) ([]PrivacyRequestRow, error) {
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`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []PrivacyRequestRow
for rows.Next() {
var row PrivacyRequestRow
if err := rows.Scan(&row.ID, &row.Code, &row.Title, &row.Kind, &row.Status, &row.Active, &row.System, &row.UpdatedAt); err != nil {
return nil, err
}
out = append(out, row)
}
return out, rows.Err()
}
// GetPrivacyRequest loads one by id.
func (r *AdminRepo) GetPrivacyRequest(ctx context.Context, id uuid.UUID) (*PrivacyRequestRow, error) {
var row PrivacyRequestRow
err := r.Pool.QueryRow(ctx, `
SELECT id, code, title, kind, status, active, system, updated_at
FROM privacy_requests WHERE id=$1`, id,
).Scan(&row.ID, &row.Code, &row.Title, &row.Kind, &row.Status, &row.Active, &row.System, &row.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
if err != nil {
return nil, err
}
return &row, nil
}
@@ -0,0 +1,34 @@
package admin
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
var ErrPrivacyRequestNotFound = errString("privacy request not found")
// ListPrivacyRequests returns catalog.
func (s *Service) ListPrivacyRequests(ctx context.Context) ([]repository.PrivacyRequestRow, error) {
items, err := s.Repo.ListPrivacyRequests(ctx)
if err != nil {
return nil, err
}
if items == nil {
items = []repository.PrivacyRequestRow{}
}
return items, nil
}
// GetPrivacyRequest loads one.
func (s *Service) GetPrivacyRequest(ctx context.Context, id uuid.UUID) (*repository.PrivacyRequestRow, error) {
row, err := s.Repo.GetPrivacyRequest(ctx, id)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrPrivacyRequestNotFound
}
return row, err
}
+2 -1
View File
@@ -30,6 +30,7 @@ const (
PermAIConfigRead = "admin.ai_config.read"
PermCrisisRead = "admin.crisis.read"
PermCMSRead = "admin.cms.read"
PermPrivacyRead = "admin.privacy.read"
)
var knownPermissions = map[string]struct{}{
@@ -39,7 +40,7 @@ var knownPermissions = map[string]struct{}{
PermUsersStatusWrite: {}, PermMembershipPlansRead: {}, PermMembershipPlansWrite: {},
PermMembershipCodesRead: {}, PermMembershipCodesWrite: {},
PermAskRead: {}, PermAskFeedbackWrite: {}, PermContentSafetyRead: {},
PermAIConfigRead: {}, PermCrisisRead: {}, PermCMSRead: {},
PermAIConfigRead: {}, PermCrisisRead: {}, PermCMSRead: {}, PermPrivacyRead: {},
}
var (
@@ -0,0 +1,2 @@
DELETE FROM admin_role_permissions WHERE code = 'admin.privacy.read';
DROP TABLE IF EXISTS privacy_requests;
@@ -0,0 +1,25 @@
-- ECR-034 PrivacyRequest (read catalog)
CREATE TABLE IF NOT EXISTS privacy_requests (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code varchar(64) NOT NULL UNIQUE,
title varchar(128) NOT NULL,
kind varchar(32) NOT NULL CHECK (kind IN ('export','erase')),
status varchar(32) NOT NULL CHECK (status IN ('open','closed')),
active boolean NOT NULL DEFAULT true,
system boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_privacy_requests_active ON privacy_requests(active);
INSERT INTO privacy_requests(code, title, kind, status, active, system)
VALUES ('demo_export_req', '示例数据导出请求', 'export', 'closed', true, true)
ON CONFLICT (code) DO NOTHING;
INSERT INTO admin_role_permissions(role_id, code)
SELECT r.id, 'admin.privacy.read'
FROM admin_roles r
WHERE r.name = 'super_admin'
ON CONFLICT DO NOTHING;
@@ -0,0 +1,23 @@
# Backend Design: ECR-034 PrivacyRequest
| ID | BD-2026-034 |
| Status | Approved |
| Coding | Loop authorized |
| Level | L2 |
| Migration | YES 000035 |
## Backend Change Boundary
```text
Domain: PrivacyRequest (read)
App: AdminHandler → admin.Service → AdminRepo
API: GET /admin/privacy/requests; GET /admin/privacy/requests/{id}
Permission: admin.privacy.read
Migration: 000035
```
## Out of boundary
隐私请求履约写 · soft-delete · UGC · 真支付
Rollback: down migration + remove routes/UI
+1
View File
@@ -2,6 +2,7 @@
## 2026-08-08
- **ECR-034 Closed**AdminGovernance PrivacyRequestmigration 000035 · admin client · /privacy/requests · 只读)
- **ECR-033 Closed**AskOperations HandoffCasemigration 000034 · admin client · /ask/handoffs · 只读)
- **ECR-032 Closed**CrisisCare InterventionOutcomemigration 000033 · admin client · /crisis/interventions · 只读)
- **ECR-031 Closed**CrisisCare CrisisEventmigration 000032 · admin client · /crisis/events · 只读)
+8
View File
@@ -0,0 +1,8 @@
# CODE_REVIEW — ECR-034
**Verdict:** Approve → Closed
Date: 2026-08-08 · Loop continuous
- PrivacyRequest 只读;无 UGC/真支付
- Integration AC mapped · OpenAPI updated
+22
View File
@@ -0,0 +1,22 @@
ecr: ECR-034
capability: AdminGovernance
bounded_context: Admin_Auth_Audit
parent: WAVE0-FROZEN
predecessor: ECR-033
change:
type: additive
breaking_change: false
migration_required: true
compatibility_notes: >
Adds PrivacyRequest read catalog. Forbidden: UGC / real payment.
apis:
- method: GET
path: /api/v1/admin/privacy/requests
change: added
- method: GET
path: /api/v1/admin/privacy/requests/{id}
change: added
perms:
- code: admin.privacy.read
change: added
+15
View File
@@ -0,0 +1,15 @@
# ECR-034
**Title:** AdminGovernance · PrivacyRequest(只读薄切片)
**Status:** **Closed**
**Closed:** 2026-08-08Loop continuous
**Parent:** WAVE0-FROZEN · **Predecessor:** ECR-033 Closed
**Change Level:** L2
## Change
PrivacyRequest 只读 · migration 000035 · admin client · /privacy/requests
## Linked
Spec `ops-privacy-request.md` · BD-2026-034 · CONTRACT_DIFF/ECR-034.yaml · TEST_REPORT/ECR-034.md
@@ -0,0 +1,6 @@
# ENGINEERING_SPEC — ECR-034
1. migration 000035
2. AdminRepo/Service/Handler
3. OpenAPI + admin-h5
4. Integration · Closed
@@ -0,0 +1,3 @@
# HANDOFF — ECR-034 Architect → Engineer
Loop continuous · Approved + Coding. Migration 000035. Forbidden: UGC/真支付.
@@ -0,0 +1,3 @@
# HANDOFF — ECR-034 Engineer → Reviewer
TestAdminPrivacyRequests PASS · Ready for Closed.
@@ -0,0 +1,3 @@
# PRODUCT_SPEC — ECR-034
对齐 ops-privacy-request.md · Approved · Loop · L2 · PrivacyRequest 只读
+6
View File
@@ -0,0 +1,6 @@
# STATE — ECR-034
| Status | **Closed** |
| Phase | closed |
| Spec | ops-privacy-request.md |
| Updated | 2026-08-08 |
+12
View File
@@ -0,0 +1,12 @@
id: TASK-034-ECR034
ecr: ECR-034
title: AdminGovernance · PrivacyRequest(只读薄切片)
role: engineer
status: closed
change_level: L2
parent: WAVE0-FROZEN
predecessor: ECR-033
acceptance:
- Spec AC mapped
- PrivacyRequest read only
- No UGC / payment
+33
View File
@@ -0,0 +1,33 @@
# TEST_REPORT — ECR-034 PrivacyRequest
Date: 2026-08-08 · Loop continuous · commit: `PENDING`
## Commands
```bash
cd apps/api && go test ./internal/integration/ -run TestAdminPrivacyRequests -count=1
npm run build:admin
python3 scripts/ess-validate.py --phase review --ecr ECR-034
python3 scripts/ess-gate-check.py --ecr ECR-034
```
## Results
| Check | Result |
|-------|--------|
| TestAdminPrivacyRequests | PASS |
| build:admin | PASS |
| ess-validate review | PASS |
| ess-gate-check | PASS |
## AC
| ID | Evidence |
|----|----------|
| AC-F-01 | list seed/empty ok |
| AC-F-02 | get 200 |
| AC-F-03 | 未知 id → 404 |
| AC-S-01 | 401 |
| AC-S-02 | 403 |
| AC-P-01 | list &lt; 500ms |
| AC-O-01 | N/A 只读 |
+1
View File
@@ -38,3 +38,4 @@
| ECR-031 | CrisisCare · CrisisEvent | **Closed** | Spec ops-crisis-event.md · BD-2026-031 · migration 000032 · TEST_REPORT · CODE_REVIEW · Loop continuous |
| ECR-032 | CrisisCare · InterventionOutcome | **Closed** | Spec ops-intervention-outcome.md · BD-2026-032 · migration 000033 · TEST_REPORT · CODE_REVIEW · Loop continuous |
| ECR-033 | AskOperations · HandoffCase | **Closed** | Spec ops-handoff-case.md · BD-2026-033 · migration 000034 · TEST_REPORT · CODE_REVIEW · Loop continuous |
| ECR-034 | AdminGovernance · PrivacyRequest | **Closed** | Spec ops-privacy-request.md · BD-2026-034 · migration 000035 · TEST_REPORT · CODE_REVIEW · Loop continuous |
+1 -1
View File
@@ -55,4 +55,4 @@ Human 明文:**直接用 Loop,不用人工确认。**
| Done | Next |
|------|------|
| ECR-013A…033 Closed | **ECR-034** PrivacyRequest |
| ECR-013A…034 Closed | **ECR-035** StarConfig |
+28
View File
@@ -863,6 +863,34 @@ paths:
'404':
description: Not found
/api/v1/admin/privacy/requests:
get:
tags: [admin]
summary: List PrivacyRequest catalog
description: Requires admin.privacy.read
responses:
'200':
description: OK
'401':
description: Unauthorized
'403':
description: Forbidden
/api/v1/admin/privacy/requests/{id}:
get:
tags: [admin]
summary: Get PrivacyRequest
parameters:
- in: path
name: id
required: true
schema: { type: string, format: uuid }
responses:
'200':
description: OK
'404':
description: Not found
/api/v1/admin/crisis/policies:
get:
tags: [admin]