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
@@ -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;