feat(ECR-026): OpsCMS ScheduledPublication 只读并 Closed

定时发布目录(ops_scheduled_publications),并加固 catalog 生成器。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-08 03:13:36 +08:00
co-authored by Cursor
parent 8c50b3d925
commit b91806ba80
40 changed files with 1909 additions and 2 deletions
+2 -1
View File
@@ -34,6 +34,8 @@
| [ops-crisis-care.md](ops-crisis-care.md) | 危机关怀 CrisisCare | §7 | `admin-h5` `/crisis` · `GET /admin/crisis/policies*` | Ops-D · **ECR-022 Closed** |
| [ops-knowledge-source.md](ops-knowledge-source.md) | AI 知识源 KnowledgeSource | §7 | `admin-h5` `/ai` · `GET /admin/ai/knowledge-sources*` | Ops-D · **ECR-023 Closed** |
| [ops-banner.md](ops-banner.md) | OpsCMS Banner | §7 | `admin-h5` `/cms` · `GET /admin/cms/banners*` | Ops-D · **ECR-024 Closed** |
| [ops-feed-slot.md](ops-feed-slot.md) | OpsCMS FeedSlot | §7 | `admin-h5` `/cms` · `GET /admin/cms/feed-slots*` | Ops-D · **ECR-025 Closed** |
| [ops-scheduled-publication.md](ops-scheduled-publication.md) | OpsCMS ScheduledPublication | §7 | `GET /admin/cms/publications*` | Ops-D · **ECR-026 Closed** |
新功能:复制 `_TEMPLATE.md` → 填满 → 在本表登记 → 再编码。
@@ -41,4 +43,3 @@
**P2 三模块队列(设计立项 / 编码另批):** [P2-BACKLOG.md](P2-BACKLOG.md)
**竞品逆向(测测前端全量):** [cece-frontend-re/](cece-frontend-re/README.md) · **完整设计包:** [cece-frontend-re/complete-design/](cece-frontend-re/complete-design/README.md) · 方法见 [../../design/reverse-engineering-spec.md](../../design/reverse-engineering-spec.md)
| [ops-feed-slot.md](ops-feed-slot.md) | OpsCMS FeedSlot | §7 | `admin-h5` `/cms` · `GET /admin/cms/feed-slots*` | Ops-D · **ECR-025 Closed** |
@@ -0,0 +1,41 @@
# Feature Spec: OpsCMS · ScheduledPublicationOps · ECR-026
> Status: `Active`Loop continuous · **ECR-026 Closed**
> Parent: WAVE0-FROZEN · Predecessor: ECR-025 Closed
> Capability: `OpsCMS` · BC: `Ops_CMS_NoUGC`
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
## Non-goals
定时发布写操作 · UGC · 真支付 · Banner/FeedSlot 写
## L2 Domain
| 概念 | 语义 |
|------|------|
| `ScheduledPublication` | 本切片只读目录;code 唯一(若适用) |
## L3 API
| Method | Path | 权限 | 语义 |
|--------|------|------|------|
| GET | `/admin/cms/publications` | `admin.cms.read` | 只读 |
| GET | `/admin/cms/publications/{id}` | `admin.cms.read` | 只读 |
## Migration
`000027`:表 + 种子(若有)(权限复用)
## 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 &lt; 500ms |
| AC-O-01 | N/A 只读 |
contract_diff: `docs/CONTRACT_DIFF/ECR-026.yaml`
+4
View File
@@ -437,6 +437,10 @@ export const adminApi = {
system: boolean
updated_at: string
}>('GET', `/cms/feed-slots/${id}`),
publications: () =>
request<{ items: Array<Record<string, unknown>> }>('GET', '/cms/publications'),
publication: (id: string) =>
request<Record<string, unknown>>('GET', `/cms/publications/${id}`),
orders: () =>
request<{
items: Array<{
+1
View File
@@ -54,6 +54,7 @@ func (h *AdminHandler) Register(api *gin.RouterGroup) {
h.registerAIConfig(authed)
h.registerCrisis(authed)
h.registerCMS(authed)
h.registerCMSPublications(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) registerCMSPublications(authed *gin.RouterGroup) {
g := authed.Group("/cms")
g.GET("/publications", middleware.RequireAdminPermission(h.Svc, admin.PermCMSRead), h.ListScheduledPublications)
g.GET("/publications/:id", middleware.RequireAdminPermission(h.Svc, admin.PermCMSRead), h.GetScheduledPublication)
}
func (h *AdminHandler) ListScheduledPublications(c *gin.Context) {
items, err := h.Svc.ListScheduledPublications(c.Request.Context())
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50050, "list scheduled-publication failed")
return
}
response.OK(c, gin.H{"items": items})
}
func (h *AdminHandler) GetScheduledPublication(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.GetScheduledPublication(c.Request.Context(), id)
if errors.Is(err, admin.ErrScheduledPublicationNotFound) {
response.Fail(c, http.StatusNotFound, 40420, "scheduled-publication not found")
return
}
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50051, "get scheduled-publication 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 TestOpsCMSPublications(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/cms/publications", 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/cms/publications", 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/cms/publications", 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 == "home_banner_week" {
id = it.ID
break
}
}
if id == "" {
t.Fatalf("missing home_banner_week: %#v", list.Items)
}
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/cms/publications/"+id, nil, tok)
if code != 200 {
t.Fatalf("get %d", code)
}
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/cms/publications/"+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"
)
// ScheduledPublicationRow is ScheduledPublication catalog row.
type ScheduledPublicationRow struct {
ID uuid.UUID `json:"id"`
Code string `json:"code"`
Title string `json:"title"`
TargetKind string `json:"target_kind"`
TargetCode string `json:"target_code"`
Active bool `json:"active"`
System bool `json:"system"`
UpdatedAt time.Time `json:"updated_at"`
}
// ListScheduledPublications returns ScheduledPublication catalog.
func (r *AdminRepo) ListScheduledPublications(ctx context.Context) ([]ScheduledPublicationRow, error) {
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`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ScheduledPublicationRow
for rows.Next() {
var row ScheduledPublicationRow
if err := rows.Scan(&row.ID, &row.Code, &row.Title, &row.TargetKind, &row.TargetCode, &row.Active, &row.System, &row.UpdatedAt); err != nil {
return nil, err
}
out = append(out, row)
}
return out, rows.Err()
}
// GetScheduledPublication loads one by id.
func (r *AdminRepo) GetScheduledPublication(ctx context.Context, id uuid.UUID) (*ScheduledPublicationRow, error) {
var row ScheduledPublicationRow
err := r.Pool.QueryRow(ctx, `
SELECT id, code, title, target_kind, target_code, active, system, updated_at
FROM ops_scheduled_publications WHERE id=$1`, id,
).Scan(&row.ID, &row.Code, &row.Title, &row.TargetKind, &row.TargetCode, &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 ErrScheduledPublicationNotFound = errString("scheduled publication not found")
// ListScheduledPublications returns catalog.
func (s *Service) ListScheduledPublications(ctx context.Context) ([]repository.ScheduledPublicationRow, error) {
items, err := s.Repo.ListScheduledPublications(ctx)
if err != nil {
return nil, err
}
if items == nil {
items = []repository.ScheduledPublicationRow{}
}
return items, nil
}
// GetScheduledPublication loads one.
func (s *Service) GetScheduledPublication(ctx context.Context, id uuid.UUID) (*repository.ScheduledPublicationRow, error) {
row, err := s.Repo.GetScheduledPublication(ctx, id)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrScheduledPublicationNotFound
}
return row, err
}
@@ -0,0 +1 @@
DROP TABLE IF EXISTS ops_scheduled_publications;
@@ -0,0 +1,19 @@
-- ECR-026 ScheduledPublication (read catalog)
CREATE TABLE IF NOT EXISTS ops_scheduled_publications (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code varchar(64) NOT NULL UNIQUE,
title varchar(128) NOT NULL,
target_kind varchar(32) NOT NULL CHECK (target_kind IN ('banner','feed_slot')),
target_code varchar(64) NOT NULL,
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_ops_scheduled_publications_active ON ops_scheduled_publications(active);
INSERT INTO ops_scheduled_publications(code, title, target_kind, target_code, active, system)
VALUES ('home_banner_week', '首页横幅周排期占位', 'banner', 'home_promo', true, true)
ON CONFLICT (code) DO NOTHING;
@@ -0,0 +1,23 @@
# Backend Design: ECR-026 ScheduledPublication
| ID | BD-2026-026 |
| Status | Approved |
| Coding | Loop authorized |
| Level | L2 |
| Migration | YES 000027 |
## Backend Change Boundary
```text
Domain: ScheduledPublication (read)
App: AdminHandler → admin.Service → AdminRepo
API: GET /admin/cms/publications; GET /admin/cms/publications/{id}
Permission: admin.cms.read
Migration: 000027
```
## Out of boundary
定时发布写操作 · UGC · 真支付 · Banner/FeedSlot 写
Rollback: down migration + remove routes/UI
+1
View File
@@ -2,6 +2,7 @@
## 2026-08-08
- **ECR-026 Closed**OpsCMS ScheduledPublicationmigration 000027 · admin-h5 client + /cms · 只读)
- **ECR-025 Closed**OpsCMS FeedSlotmigration 000026 · admin-h5 /cms · 只读)
- **ECR-024 Closed**OpsCMS Banner`ops_banners` · admin-h5 `/cms` · migration 000025 · 只读)
- **ECR-023 Closed**AICoreConfig KnowledgeSource`knowledge_sources` · admin-h5 `/ai` · migration 000024 · 只读)
+8
View File
@@ -0,0 +1,8 @@
# CODE_REVIEW — ECR-026
**Verdict:** Approve → Closed
Date: 2026-08-08 · Loop continuous
- ScheduledPublication 只读;无 UGC/真支付
- Integration AC mapped · OpenAPI updated
+22
View File
@@ -0,0 +1,22 @@
ecr: ECR-026
capability: OpsCMS
bounded_context: Ops_CMS_NoUGC
parent: WAVE0-FROZEN
predecessor: ECR-025
change:
type: additive
breaking_change: false
migration_required: true
compatibility_notes: >
Adds ScheduledPublication read catalog. Forbidden: UGC / real payment.
apis:
- method: GET
path: /api/v1/admin/cms/publications
change: added
- method: GET
path: /api/v1/admin/cms/publications/{id}
change: added
perms:
- code: admin.cms.read
change: unchanged
+15
View File
@@ -0,0 +1,15 @@
# ECR-026
**Title:** OpsCMS · ScheduledPublication(只读薄切片)
**Status:** **Closed**
**Closed:** 2026-08-08Loop continuous
**Parent:** WAVE0-FROZEN · **Predecessor:** ECR-025 Closed
**Change Level:** L2
## Change
ScheduledPublication 只读 · migration 000027 · admin-h5 client + /cms
## Linked
Spec `ops-scheduled-publication.md` · BD-2026-026 · CONTRACT_DIFF/ECR-026.yaml · TEST_REPORT/ECR-026.md
@@ -0,0 +1,6 @@
# ENGINEERING_SPEC — ECR-026
1. migration 000027
2. AdminRepo/Service/Handler
3. OpenAPI + admin-h5
4. Integration · Closed
@@ -0,0 +1,3 @@
# HANDOFF — ECR-026 Architect → Engineer
Loop continuous · Approved + Coding. Migration 000027. Forbidden: UGC/真支付.
@@ -0,0 +1,3 @@
# HANDOFF — ECR-026 Engineer → Reviewer
TestOpsCMSPublications PASS · Ready for Closed.
@@ -0,0 +1,3 @@
# PRODUCT_SPEC — ECR-026
对齐 ops-scheduled-publication.md · Approved · Loop · L2 · ScheduledPublication 只读
+6
View File
@@ -0,0 +1,6 @@
# STATE — ECR-026
| Status | **Closed** |
| Phase | closed |
| Spec | ops-scheduled-publication.md |
| Updated | 2026-08-08 |
+12
View File
@@ -0,0 +1,12 @@
id: TASK-026-ECR026
ecr: ECR-026
title: OpsCMS · ScheduledPublication(只读薄切片)
role: engineer
status: closed
change_level: L2
parent: WAVE0-FROZEN
predecessor: ECR-025
acceptance:
- Spec AC mapped
- ScheduledPublication read only
- No UGC / payment
+33
View File
@@ -0,0 +1,33 @@
# TEST_REPORT — ECR-026 ScheduledPublication
Date: 2026-08-08 · Loop continuous · commit: `PENDING`
## Commands
```bash
cd apps/api && go test ./internal/integration/ -run TestOpsCMSPublications -count=1
npm run build:admin
python3 scripts/ess-validate.py --phase review --ecr ECR-026
python3 scripts/ess-gate-check.py --ecr ECR-026
```
## Results
| Check | Result |
|-------|--------|
| TestOpsCMSPublications | 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
@@ -30,3 +30,4 @@
| ECR-023 | AICoreConfig · KnowledgeSource | **Closed** | Spec ops-knowledge-source · BD-2026-023 · migration 000024 · TEST_REPORT · CODE_REVIEW · Loop continuous |
| ECR-024 | OpsCMS · Banner | **Closed** | Spec ops-banner · BD-2026-024 · migration 000025 · TEST_REPORT · CODE_REVIEW · Loop continuous |
| ECR-025 | OpsCMS · FeedSlot | **Closed** | Spec ops-feed-slot.md · BD-2026-025 · migration 000026 · TEST_REPORT · CODE_REVIEW · Loop continuous |
| ECR-026 | OpsCMS · ScheduledPublication | **Closed** | Spec ops-scheduled-publication.md · BD-2026-026 · migration 000027 · TEST_REPORT · CODE_REVIEW · Loop continuous |
+1 -1
View File
@@ -55,4 +55,4 @@ Human 明文:**直接用 Loop,不用人工确认。**
| Done | Next |
|------|------|
| ECR-013A…025 Closed | **ECR-026** ScheduledPublicationOpsCMS |
| ECR-013A…026 Closed | **ECR-027** KnowledgeChunk |
+73
View File
@@ -0,0 +1,73 @@
{
"ecr": "026",
"slug": "scheduled-publication",
"concept": "ScheduledPublication",
"list_method": "ScheduledPublications",
"get_method": "ScheduledPublication",
"table": "ops_scheduled_publications",
"migration": "000027",
"route_group": "/cms",
"route_resource": "publications",
"perm_const": "PermCMSRead",
"perm_code": "admin.cms.read",
"seed_code": "home_banner_week",
"register_fn": "registerCMSPublications",
"test_name": "TestOpsCMSPublications",
"row_type": "ScheduledPublicationRow",
"err_name": "ErrScheduledPublicationNotFound",
"capability": "OpsCMS",
"bc": "Ops_CMS_NoUGC",
"predecessor": "ECR-025",
"title": "OpsCMS · ScheduledPublication(只读薄切片)",
"non_goals": "定时发布写操作 · UGC · 真支付 · Banner/FeedSlot 写",
"columns": [
{
"name": "code",
"sql": "varchar(64) NOT NULL UNIQUE",
"go_name": "Code",
"go_type": "string",
"json": "code",
"seed": "home_banner_week"
},
{
"name": "title",
"sql": "varchar(128) NOT NULL",
"go_name": "Title",
"go_type": "string",
"json": "title",
"seed": "首页横幅周排期占位"
},
{
"name": "target_kind",
"sql": "varchar(32) NOT NULL CHECK (target_kind IN ('banner','feed_slot'))",
"go_name": "TargetKind",
"go_type": "string",
"json": "target_kind",
"seed": "banner"
},
{
"name": "target_code",
"sql": "varchar(64) NOT NULL",
"go_name": "TargetCode",
"go_type": "string",
"json": "target_code",
"seed": "home_promo"
},
{
"name": "active",
"sql": "boolean NOT NULL DEFAULT true",
"go_name": "Active",
"go_type": "bool",
"json": "active",
"seed": true
},
{
"name": "system",
"sql": "boolean NOT NULL DEFAULT false",
"go_name": "System",
"go_type": "bool",
"json": "system",
"seed": true
}
]
}
+73
View File
@@ -0,0 +1,73 @@
{
"ecr": "027",
"slug": "knowledge-chunk",
"concept": "KnowledgeChunk",
"list_method": "KnowledgeChunks",
"get_method": "KnowledgeChunk",
"table": "knowledge_chunks",
"migration": "000028",
"route_group": "/ai",
"route_resource": "knowledge-chunks",
"perm_const": "PermAIConfigRead",
"perm_code": "admin.ai_config.read",
"seed_code": "ask_grounding_intro",
"register_fn": "registerKnowledgeChunks",
"test_name": "TestAICoreKnowledgeChunks",
"row_type": "KnowledgeChunkRow",
"err_name": "ErrKnowledgeChunkNotFound",
"capability": "AICoreConfig",
"bc": "Ask_Ops",
"predecessor": "ECR-026",
"title": "AICoreConfig · KnowledgeChunk(只读薄切片)",
"non_goals": "Embedding · 上传切块 · 运行时 RAG 接线 · UGC · 真支付",
"columns": [
{
"name": "code",
"sql": "varchar(64) NOT NULL UNIQUE",
"go_name": "Code",
"go_type": "string",
"json": "code",
"seed": "ask_grounding_intro"
},
{
"name": "source_code",
"sql": "varchar(64) NOT NULL",
"go_name": "SourceCode",
"go_type": "string",
"json": "source_code",
"seed": "ask_grounding"
},
{
"name": "title",
"sql": "varchar(128) NOT NULL",
"go_name": "Title",
"go_type": "string",
"json": "title",
"seed": "问答 grounding 引言块"
},
{
"name": "body",
"sql": "text NOT NULL",
"go_name": "Body",
"go_type": "string",
"json": "body",
"seed": "愈心谷提供陪伴式成长对话,非医疗诊断。"
},
{
"name": "active",
"sql": "boolean NOT NULL DEFAULT true",
"go_name": "Active",
"go_type": "bool",
"json": "active",
"seed": true
},
{
"name": "system",
"sql": "boolean NOT NULL DEFAULT false",
"go_name": "System",
"go_type": "bool",
"json": "system",
"seed": true
}
]
}
+65
View File
@@ -0,0 +1,65 @@
{
"ecr": "028",
"slug": "tool-definition",
"concept": "ToolDefinition",
"list_method": "ToolDefinitions",
"get_method": "ToolDefinition",
"table": "tool_definitions",
"migration": "000029",
"route_group": "/ai",
"route_resource": "tools",
"perm_const": "PermAIConfigRead",
"perm_code": "admin.ai_config.read",
"seed_code": "fetch_profile_summary",
"register_fn": "registerToolDefinitions",
"test_name": "TestAICoreToolDefinitions",
"row_type": "ToolDefinitionRow",
"err_name": "ErrToolDefinitionNotFound",
"capability": "AICoreConfig",
"bc": "Ask_Ops",
"predecessor": "ECR-027",
"title": "AICoreConfig · ToolDefinition(只读薄切片)",
"non_goals": "工具在线编辑 · 运行时绑定 · UGC · 真支付",
"columns": [
{
"name": "code",
"sql": "varchar(64) NOT NULL UNIQUE",
"go_name": "Code",
"go_type": "string",
"json": "code",
"seed": "fetch_profile_summary"
},
{
"name": "title",
"sql": "varchar(128) NOT NULL",
"go_name": "Title",
"go_type": "string",
"json": "title",
"seed": "拉取档案摘要"
},
{
"name": "description",
"sql": "text NULL",
"go_name": "Description",
"go_type": "*string",
"json": "description,omitempty",
"seed": "只读工具定义占位"
},
{
"name": "active",
"sql": "boolean NOT NULL DEFAULT true",
"go_name": "Active",
"go_type": "bool",
"json": "active",
"seed": true
},
{
"name": "system",
"sql": "boolean NOT NULL DEFAULT false",
"go_name": "System",
"go_type": "bool",
"json": "system",
"seed": true
}
]
}
+65
View File
@@ -0,0 +1,65 @@
{
"ecr": "029",
"slug": "block-policy",
"concept": "BlockPolicy",
"list_method": "BlockPolicies",
"get_method": "BlockPolicy",
"table": "block_policies",
"migration": "000030",
"route_group": "/content-safety",
"route_resource": "block-policies",
"perm_const": "PermContentSafetyRead",
"perm_code": "admin.content_safety.read",
"seed_code": "block_spam_link",
"register_fn": "registerBlockPolicies",
"test_name": "TestContentSafetyBlockPolicies",
"row_type": "BlockPolicyRow",
"err_name": "ErrBlockPolicyNotFound",
"capability": "ContentSafety",
"bc": "Content_Safety",
"predecessor": "ECR-028",
"title": "ContentSafety · BlockPolicy(只读薄切片)",
"non_goals": "策略写发布 · 用户侧硬拦截上线 · UGC · 真支付",
"columns": [
{
"name": "code",
"sql": "varchar(64) NOT NULL UNIQUE",
"go_name": "Code",
"go_type": "string",
"json": "code",
"seed": "block_spam_link"
},
{
"name": "title",
"sql": "varchar(128) NOT NULL",
"go_name": "Title",
"go_type": "string",
"json": "title",
"seed": "外链垃圾拦截策略"
},
{
"name": "action",
"sql": "varchar(32) NOT NULL CHECK (action IN ('block','mask','review'))",
"go_name": "Action",
"go_type": "string",
"json": "action",
"seed": "block"
},
{
"name": "active",
"sql": "boolean NOT NULL DEFAULT true",
"go_name": "Active",
"go_type": "bool",
"json": "active",
"seed": true
},
{
"name": "system",
"sql": "boolean NOT NULL DEFAULT false",
"go_name": "System",
"go_type": "bool",
"json": "system",
"seed": true
}
]
}
+65
View File
@@ -0,0 +1,65 @@
{
"ecr": "030",
"slug": "moderation-case",
"concept": "ModerationCase",
"list_method": "ModerationCases",
"get_method": "ModerationCase",
"table": "moderation_cases",
"migration": "000031",
"route_group": "/content-safety",
"route_resource": "cases",
"perm_const": "PermContentSafetyRead",
"perm_code": "admin.content_safety.read",
"seed_code": "demo_case_seed",
"register_fn": "registerModerationCases",
"test_name": "TestContentSafetyModerationCases",
"row_type": "ModerationCaseRow",
"err_name": "ErrModerationCaseNotFound",
"capability": "ContentSafety",
"bc": "Content_Safety",
"predecessor": "ECR-029",
"title": "ContentSafety · ModerationCase(只读薄切片)",
"non_goals": "审核写回 · 真 NLP 厂商 · UGC · 真支付",
"columns": [
{
"name": "code",
"sql": "varchar(64) NOT NULL UNIQUE",
"go_name": "Code",
"go_type": "string",
"json": "code",
"seed": "demo_case_seed"
},
{
"name": "title",
"sql": "varchar(128) NOT NULL",
"go_name": "Title",
"go_type": "string",
"json": "title",
"seed": "示例审核案"
},
{
"name": "status",
"sql": "varchar(32) NOT NULL CHECK (status IN ('open','closed'))",
"go_name": "Status",
"go_type": "string",
"json": "status",
"seed": "closed"
},
{
"name": "active",
"sql": "boolean NOT NULL DEFAULT true",
"go_name": "Active",
"go_type": "bool",
"json": "active",
"seed": true
},
{
"name": "system",
"sql": "boolean NOT NULL DEFAULT false",
"go_name": "System",
"go_type": "bool",
"json": "system",
"seed": true
}
]
}
+65
View File
@@ -0,0 +1,65 @@
{
"ecr": "031",
"slug": "crisis-event",
"concept": "CrisisEvent",
"list_method": "CrisisEvents",
"get_method": "CrisisEvent",
"table": "crisis_events",
"migration": "000032",
"route_group": "/crisis",
"route_resource": "events",
"perm_const": "PermCrisisRead",
"perm_code": "admin.crisis.read",
"seed_code": "demo_crisis_event",
"register_fn": "registerCrisisEvents",
"test_name": "TestCrisisCareEvents",
"row_type": "CrisisEventRow",
"err_name": "ErrCrisisEventNotFound",
"capability": "CrisisCare",
"bc": "Content_Safety",
"predecessor": "ECR-030",
"title": "CrisisCare · CrisisEvent(只读薄切片)",
"non_goals": "事件写入工单流 · 医疗诊断 · UGC · 真支付",
"columns": [
{
"name": "code",
"sql": "varchar(64) NOT NULL UNIQUE",
"go_name": "Code",
"go_type": "string",
"json": "code",
"seed": "demo_crisis_event"
},
{
"name": "title",
"sql": "varchar(128) NOT NULL",
"go_name": "Title",
"go_type": "string",
"json": "title",
"seed": "示例危机事件占位"
},
{
"name": "severity",
"sql": "varchar(16) NOT NULL CHECK (severity IN ('high','critical'))",
"go_name": "Severity",
"go_type": "string",
"json": "severity",
"seed": "high"
},
{
"name": "active",
"sql": "boolean NOT NULL DEFAULT true",
"go_name": "Active",
"go_type": "bool",
"json": "active",
"seed": true
},
{
"name": "system",
"sql": "boolean NOT NULL DEFAULT false",
"go_name": "System",
"go_type": "bool",
"json": "system",
"seed": true
}
]
}
+65
View File
@@ -0,0 +1,65 @@
{
"ecr": "032",
"slug": "intervention-outcome",
"concept": "InterventionOutcome",
"list_method": "InterventionOutcomes",
"get_method": "InterventionOutcome",
"table": "intervention_outcomes",
"migration": "000033",
"route_group": "/crisis",
"route_resource": "interventions",
"perm_const": "PermCrisisRead",
"perm_code": "admin.crisis.read",
"seed_code": "demo_helpline_shown",
"register_fn": "registerInterventionOutcomes",
"test_name": "TestCrisisCareInterventions",
"row_type": "InterventionOutcomeRow",
"err_name": "ErrInterventionOutcomeNotFound",
"capability": "CrisisCare",
"bc": "Content_Safety",
"predecessor": "ECR-031",
"title": "CrisisCare · InterventionOutcome(只读薄切片)",
"non_goals": "干预写回 · 医疗诊断 · UGC · 真支付",
"columns": [
{
"name": "code",
"sql": "varchar(64) NOT NULL UNIQUE",
"go_name": "Code",
"go_type": "string",
"json": "code",
"seed": "demo_helpline_shown"
},
{
"name": "title",
"sql": "varchar(128) NOT NULL",
"go_name": "Title",
"go_type": "string",
"json": "title",
"seed": "示例展示热线结果"
},
{
"name": "outcome",
"sql": "varchar(32) NOT NULL CHECK (outcome IN ('helpline_shown','escalated','blocked'))",
"go_name": "Outcome",
"go_type": "string",
"json": "outcome",
"seed": "helpline_shown"
},
{
"name": "active",
"sql": "boolean NOT NULL DEFAULT true",
"go_name": "Active",
"go_type": "bool",
"json": "active",
"seed": true
},
{
"name": "system",
"sql": "boolean NOT NULL DEFAULT false",
"go_name": "System",
"go_type": "bool",
"json": "system",
"seed": true
}
]
}
+65
View File
@@ -0,0 +1,65 @@
{
"ecr": "033",
"slug": "handoff-case",
"concept": "HandoffCase",
"list_method": "HandoffCases",
"get_method": "HandoffCase",
"table": "ask_handoff_cases",
"migration": "000034",
"route_group": "/ask",
"route_resource": "handoffs",
"perm_const": "PermAskRead",
"perm_code": "admin.ask.read",
"seed_code": "demo_handoff",
"register_fn": "registerHandoffCases",
"test_name": "TestAskOpsHandoffs",
"row_type": "HandoffCaseRow",
"err_name": "ErrHandoffCaseNotFound",
"capability": "AskOperations",
"bc": "Ask_Ops",
"predecessor": "ECR-032",
"title": "AskOperations · HandoffCase(只读薄切片)",
"non_goals": "转人工写流 · 顾问执业 · UGC · 真支付",
"columns": [
{
"name": "code",
"sql": "varchar(64) NOT NULL UNIQUE",
"go_name": "Code",
"go_type": "string",
"json": "code",
"seed": "demo_handoff"
},
{
"name": "title",
"sql": "varchar(128) NOT NULL",
"go_name": "Title",
"go_type": "string",
"json": "title",
"seed": "示例转接案"
},
{
"name": "status",
"sql": "varchar(32) NOT NULL CHECK (status IN ('open','closed'))",
"go_name": "Status",
"go_type": "string",
"json": "status",
"seed": "closed"
},
{
"name": "active",
"sql": "boolean NOT NULL DEFAULT true",
"go_name": "Active",
"go_type": "bool",
"json": "active",
"seed": true
},
{
"name": "system",
"sql": "boolean NOT NULL DEFAULT false",
"go_name": "System",
"go_type": "bool",
"json": "system",
"seed": true
}
]
}
+74
View File
@@ -0,0 +1,74 @@
{
"ecr": "034",
"slug": "privacy-request",
"concept": "PrivacyRequest",
"list_method": "PrivacyRequests",
"get_method": "PrivacyRequest",
"table": "privacy_requests",
"migration": "000035",
"route_group": "/privacy",
"route_resource": "requests",
"perm_const": "PermPrivacyRead",
"perm_code": "admin.privacy.read",
"new_perm": true,
"seed_code": "demo_export_req",
"register_fn": "registerPrivacyRequests",
"test_name": "TestAdminPrivacyRequests",
"row_type": "PrivacyRequestRow",
"err_name": "ErrPrivacyRequestNotFound",
"capability": "AdminGovernance",
"bc": "Admin_Auth_Audit",
"predecessor": "ECR-033",
"title": "AdminGovernance · PrivacyRequest(只读薄切片)",
"non_goals": "隐私请求履约写 · soft-delete · UGC · 真支付",
"columns": [
{
"name": "code",
"sql": "varchar(64) NOT NULL UNIQUE",
"go_name": "Code",
"go_type": "string",
"json": "code",
"seed": "demo_export_req"
},
{
"name": "title",
"sql": "varchar(128) NOT NULL",
"go_name": "Title",
"go_type": "string",
"json": "title",
"seed": "示例数据导出请求"
},
{
"name": "kind",
"sql": "varchar(32) NOT NULL CHECK (kind IN ('export','erase'))",
"go_name": "Kind",
"go_type": "string",
"json": "kind",
"seed": "export"
},
{
"name": "status",
"sql": "varchar(32) NOT NULL CHECK (status IN ('open','closed'))",
"go_name": "Status",
"go_type": "string",
"json": "status",
"seed": "closed"
},
{
"name": "active",
"sql": "boolean NOT NULL DEFAULT true",
"go_name": "Active",
"go_type": "bool",
"json": "active",
"seed": true
},
{
"name": "system",
"sql": "boolean NOT NULL DEFAULT false",
"go_name": "System",
"go_type": "bool",
"json": "system",
"seed": true
}
]
}
+58
View File
@@ -0,0 +1,58 @@
{
"ecr": "035",
"slug": "star-config",
"concept": "StarConfig",
"list_method": "StarConfigs",
"get_method": "StarConfig",
"table": "star_configs",
"migration": "000036",
"route_group": "/explore",
"route_resource": "star-configs",
"perm_const": "PermExploreRead",
"perm_code": "admin.explore.read",
"new_perm": true,
"seed_code": "default_star",
"register_fn": "registerStarConfigs",
"test_name": "TestExploreStarConfigs",
"row_type": "StarConfigRow",
"err_name": "ErrStarConfigNotFound",
"capability": "ExploreConfig",
"bc": "Explore_Reports",
"predecessor": "ECR-034",
"title": "ExploreConfig · StarConfig(只读薄切片)",
"non_goals": "配置写发布 · 引擎改分层 · UGC · 真支付",
"columns": [
{
"name": "code",
"sql": "varchar(64) NOT NULL UNIQUE",
"go_name": "Code",
"go_type": "string",
"json": "code",
"seed": "default_star"
},
{
"name": "title",
"sql": "varchar(128) NOT NULL",
"go_name": "Title",
"go_type": "string",
"json": "title",
"seed": "默认星座配置"
},
{
"name": "active",
"sql": "boolean NOT NULL DEFAULT true",
"go_name": "Active",
"go_type": "bool",
"json": "active",
"seed": true
},
{
"name": "system",
"sql": "boolean NOT NULL DEFAULT false",
"go_name": "System",
"go_type": "bool",
"json": "system",
"seed": true
}
]
}
+58
View File
@@ -0,0 +1,58 @@
{
"ecr": "036",
"slug": "rhythm-config",
"concept": "RhythmConfig",
"list_method": "RhythmConfigs",
"get_method": "RhythmConfig",
"table": "rhythm_configs",
"migration": "000037",
"route_group": "/explore",
"route_resource": "rhythm-configs",
"perm_const": "PermExploreRead",
"perm_code": "admin.explore.read",
"new_perm": false,
"seed_code": "default_rhythm",
"register_fn": "registerRhythmConfigs",
"test_name": "TestExploreRhythmConfigs",
"row_type": "RhythmConfigRow",
"err_name": "ErrRhythmConfigNotFound",
"capability": "ExploreConfig",
"bc": "Explore_Reports",
"predecessor": "ECR-035",
"title": "ExploreConfig · RhythmConfig(只读薄切片)",
"non_goals": "配置写发布 · UGC · 真支付",
"columns": [
{
"name": "code",
"sql": "varchar(64) NOT NULL UNIQUE",
"go_name": "Code",
"go_type": "string",
"json": "code",
"seed": "default_rhythm"
},
{
"name": "title",
"sql": "varchar(128) NOT NULL",
"go_name": "Title",
"go_type": "string",
"json": "title",
"seed": "默认节律配置"
},
{
"name": "active",
"sql": "boolean NOT NULL DEFAULT true",
"go_name": "Active",
"go_type": "bool",
"json": "active",
"seed": true
},
{
"name": "system",
"sql": "boolean NOT NULL DEFAULT false",
"go_name": "System",
"go_type": "bool",
"json": "system",
"seed": true
}
]
}
+58
View File
@@ -0,0 +1,58 @@
{
"ecr": "037",
"slug": "image-card-deck",
"concept": "ImageCardDeck",
"list_method": "ImageCardDecks",
"get_method": "ImageCardDeck",
"table": "image_card_decks",
"migration": "000038",
"route_group": "/explore",
"route_resource": "image-card-decks",
"perm_const": "PermExploreRead",
"perm_code": "admin.explore.read",
"new_perm": false,
"seed_code": "default_deck",
"register_fn": "registerImageCardDecks",
"test_name": "TestExploreImageCardDecks",
"row_type": "ImageCardDeckRow",
"err_name": "ErrImageCardDeckNotFound",
"capability": "ExploreConfig",
"bc": "Explore_Reports",
"predecessor": "ECR-036",
"title": "ExploreConfig · ImageCardDeck(只读薄切片)",
"non_goals": "牌组写发布 · UGC · 真支付",
"columns": [
{
"name": "code",
"sql": "varchar(64) NOT NULL UNIQUE",
"go_name": "Code",
"go_type": "string",
"json": "code",
"seed": "default_deck"
},
{
"name": "title",
"sql": "varchar(128) NOT NULL",
"go_name": "Title",
"go_type": "string",
"json": "title",
"seed": "默认意象牌组"
},
{
"name": "active",
"sql": "boolean NOT NULL DEFAULT true",
"go_name": "Active",
"go_type": "bool",
"json": "active",
"seed": true
},
{
"name": "system",
"sql": "boolean NOT NULL DEFAULT false",
"go_name": "System",
"go_type": "bool",
"json": "system",
"seed": true
}
]
}
+66
View File
@@ -0,0 +1,66 @@
{
"ecr": "038",
"slug": "report-template",
"concept": "ReportTemplate",
"list_method": "ReportTemplates",
"get_method": "ReportTemplate",
"table": "report_templates",
"migration": "000039",
"route_group": "/growth",
"route_resource": "report-templates",
"perm_const": "PermGrowthRead",
"perm_code": "admin.growth.read",
"new_perm": true,
"seed_code": "portrait_default",
"register_fn": "registerReportTemplates",
"test_name": "TestGrowthReportTemplates",
"row_type": "ReportTemplateRow",
"err_name": "ErrReportTemplateNotFound",
"capability": "GrowthInsights",
"bc": "Explore_Reports",
"predecessor": "ECR-037",
"title": "GrowthInsights · ReportTemplate(只读薄切片)",
"non_goals": "模板写发布 · 广告投放 · UGC · 真支付",
"columns": [
{
"name": "code",
"sql": "varchar(64) NOT NULL UNIQUE",
"go_name": "Code",
"go_type": "string",
"json": "code",
"seed": "portrait_default"
},
{
"name": "title",
"sql": "varchar(128) NOT NULL",
"go_name": "Title",
"go_type": "string",
"json": "title",
"seed": "画像报告模板占位"
},
{
"name": "scene",
"sql": "varchar(32) NOT NULL",
"go_name": "Scene",
"go_type": "string",
"json": "scene",
"seed": "portrait"
},
{
"name": "active",
"sql": "boolean NOT NULL DEFAULT true",
"go_name": "Active",
"go_type": "bool",
"json": "active",
"seed": true
},
{
"name": "system",
"sql": "boolean NOT NULL DEFAULT false",
"go_name": "System",
"go_type": "bool",
"json": "system",
"seed": true
}
]
}
+58
View File
@@ -0,0 +1,58 @@
{
"ecr": "039",
"slug": "funnel-definition",
"concept": "FunnelDefinition",
"list_method": "FunnelDefinitions",
"get_method": "FunnelDefinition",
"table": "funnel_definitions",
"migration": "000040",
"route_group": "/analytics",
"route_resource": "funnel-definitions",
"perm_const": "PermAnalyticsRead",
"perm_code": "admin.analytics.read",
"new_perm": false,
"seed_code": "signup_to_ask",
"register_fn": "registerFunnelDefinitions",
"test_name": "TestGrowthFunnelDefinitions",
"row_type": "FunnelDefinitionRow",
"err_name": "ErrFunnelDefinitionNotFound",
"capability": "GrowthInsights",
"bc": "Analytics_OpsB",
"predecessor": "ECR-038",
"title": "GrowthInsights · FunnelDefinition(只读薄切片)",
"non_goals": "漏斗写配置 · UGC · 真支付",
"columns": [
{
"name": "code",
"sql": "varchar(64) NOT NULL UNIQUE",
"go_name": "Code",
"go_type": "string",
"json": "code",
"seed": "signup_to_ask"
},
{
"name": "title",
"sql": "varchar(128) NOT NULL",
"go_name": "Title",
"go_type": "string",
"json": "title",
"seed": "注册到问答漏斗占位"
},
{
"name": "active",
"sql": "boolean NOT NULL DEFAULT true",
"go_name": "Active",
"go_type": "bool",
"json": "active",
"seed": true
},
{
"name": "system",
"sql": "boolean NOT NULL DEFAULT false",
"go_name": "System",
"go_type": "bool",
"json": "system",
"seed": true
}
]
}
+28
View File
@@ -639,6 +639,34 @@ paths:
'404':
description: Not found
/api/v1/admin/cms/publications:
get:
tags: [admin]
summary: List ScheduledPublication catalog
description: Requires admin.cms.read
responses:
'200':
description: OK
'401':
description: Unauthorized
'403':
description: Forbidden
/api/v1/admin/cms/publications/{id}:
get:
tags: [admin]
summary: Get ScheduledPublication
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]
+541
View File
@@ -0,0 +1,541 @@
#!/usr/bin/env python3
"""Generate a standard Ops read-catalog slice (migration+repo+svc+handler+test+openapi+client stub)."""
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
OPENAPI_MARKER = " /api/v1/admin/crisis/policies:" # insert before crisis if cms; else append before end - configurable
def run(cmd: list[str]):
print("+", " ".join(cmd))
subprocess.check_call(cmd, cwd=ROOT)
def write(path: Path, text: str):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8")
print("wrote", path.relative_to(ROOT))
def ensure_perm(code: str, const: str):
rbac = ROOT / "apps/api/internal/service/admin/rbac.go"
t = rbac.read_text()
if f'{const} ' in t or f'{const}\t' in t or f'{const}=' in t.replace(' ', ''):
if code in t and const in t:
# still ensure knownPermissions
pass
if f'{const} ' not in t and f'{const}\t' not in t:
# insert before closing paren of const block
t = t.replace(
')\n\nvar knownPermissions',
f'\t{const} = "{code}"\n)\n\nvar knownPermissions',
1,
)
if f'{const}:' not in t:
t = t.replace(
'PermCMSRead: {},\n}',
f'PermCMSRead: {{}}, {const}: {{}},\n}}',
1,
)
if f'{const}:' not in t:
# append before knownPermissions closing
t = t.replace(
'PermCrisisRead: {}, PermCMSRead: {},',
f'PermCrisisRead: {{}}, PermCMSRead: {{}}, {const}: {{}},',
1,
)
if code not in t:
raise SystemExit(f'failed to inject perm {code}')
rbac.write_text(t)
print("updated rbac", code, const)
def append_register(fn: str):
admin = ROOT / "apps/api/internal/handler/admin.go"
t = admin.read_text()
if f"h.{fn}(" in t:
return
needle = "\th.registerCMS(authed)\n"
if needle in t and f"h.{fn}(" not in t:
# insert after last registerXXX line inside Register
import re
m = list(re.finditer(r"\th\.register\w+\(authed\)\n", t))
if m:
last = m[-1]
t = t[: last.end()] + f"\th.{fn}(authed)\n" + t[last.end() :]
admin.write_text(t)
return
admin.write_text(t)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--spec", required=True, help="path to json slice spec")
args = ap.parse_args()
cfg = json.loads(Path(args.spec).read_text())
ecr = cfg["ecr"] # "026"
slug = cfg["slug"]
concept = cfg["concept"]
list_method = cfg["list_method"]
get_method = cfg["get_method"]
table = cfg["table"]
mig = cfg["migration"]
route_group = cfg["route_group"] # /cms
route_res = cfg["route_resource"] # publications
perm_const = cfg["perm_const"] # PermCMSRead
perm_code = cfg["perm_code"]
seed_code = cfg["seed_code"]
columns = cfg["columns"] # list of {name, sql_type, go_type, json, seed?}
register_fn = cfg["register_fn"]
test_name = cfg["test_name"]
row_type = cfg["row_type"]
err_name = cfg["err_name"]
new_perm = cfg.get("new_perm", False)
capability = cfg["capability"]
bc = cfg["bc"]
predecessor = cfg["predecessor"]
title = cfg["title"]
non_goals = cfg["non_goals"]
openapi_before = cfg.get("openapi_before", OPENAPI_MARKER)
# 1) ESS scaffold
apis = f"GET /admin{route_group}/{route_res}|GET /admin{route_group}/{route_res}/{{id}}"
cmd = [
"python3", "scripts/ess-slice-scaffold.py",
"--ecr", ecr, "--slug", slug, "--title", title,
"--capability", capability, "--bc", bc, "--concept", concept,
"--predecessor", predecessor, "--migration", mig, "--perm", perm_code,
"--apis", apis, "--non-goals", non_goals,
]
if new_perm:
cmd.append("--new-perm")
run(cmd)
# 2) migration
col_sql = [f" {c['name']} {c['sql']}" for c in columns]
seed_cols = [c["name"] for c in columns if c.get("seed") is not None]
seed_vals = []
for c in columns:
if c.get("seed") is None:
continue
v = c["seed"]
if isinstance(v, bool):
seed_vals.append("true" if v else "false")
elif isinstance(v, int):
seed_vals.append(str(v))
elif v is None:
seed_vals.append("NULL")
else:
seed_vals.append("'" + str(v).replace("'", "''") + "'")
up = f"""-- ECR-{ecr} {concept} (read catalog)
CREATE TABLE IF NOT EXISTS {table} (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
{chr(10).join(c + "," for c in col_sql)}
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_{table}_active ON {table}(active);
INSERT INTO {table}({', '.join(seed_cols)})
VALUES ({', '.join(seed_vals)})
ON CONFLICT (code) DO NOTHING;
"""
if new_perm:
up += f"""
INSERT INTO admin_role_permissions(role_id, code)
SELECT r.id, '{perm_code}'
FROM admin_roles r
WHERE r.name = 'super_admin'
ON CONFLICT DO NOTHING;
"""
write(ROOT / f"apps/api/migrations/{mig}_{table}.up.sql", up)
down = f"DROP TABLE IF EXISTS {table};\n"
if new_perm:
down = f"DELETE FROM admin_role_permissions WHERE code = '{perm_code}';\n" + down
write(ROOT / f"apps/api/migrations/{mig}_{table}.down.sql", down)
if new_perm:
ensure_perm(perm_code, perm_const)
# 3) repository file
go_fields = []
scan_vars = []
for c in columns:
go_fields.append(f"\t{c['go_name']} {c['go_type']} `json:\"{c['json']}\"`")
scan_vars.append(f"&row.{c['go_name']}")
go_fields.append('\tUpdatedAt time.Time `json:"updated_at"`')
scan_vars.append("&row.UpdatedAt")
select_cols = ", ".join(["id"] + [c["name"] for c in columns] + ["updated_at"])
repo = f"""package repository
import (
\t"context"
\t"errors"
\t"time"
\t"github.com/google/uuid"
\t"github.com/jackc/pgx/v5"
)
// {row_type} is {concept} catalog row.
type {row_type} struct {{
\tID uuid.UUID `json:"id"`
{chr(10).join(go_fields)}
}}
// List{list_method} returns {concept} catalog.
func (r *AdminRepo) List{list_method}(ctx context.Context) ([]{row_type}, error) {{
\trows, err := r.Pool.Query(ctx, `
\t\tSELECT {select_cols}
\t\tFROM {table}
\t\tORDER BY active DESC, code ASC`)
\tif err != nil {{
\t\treturn nil, err
\t}}
\tdefer rows.Close()
\tvar out []{row_type}
\tfor rows.Next() {{
\t\tvar row {row_type}
\t\tif err := rows.Scan(&row.ID, {', '.join(scan_vars)}); err != nil {{
\t\t\treturn nil, err
\t\t}}
\t\tout = append(out, row)
\t}}
\treturn out, rows.Err()
}}
// Get{get_method} loads one by id.
func (r *AdminRepo) Get{get_method}(ctx context.Context, id uuid.UUID) (*{row_type}, error) {{
\tvar row {row_type}
\terr := r.Pool.QueryRow(ctx, `
\t\tSELECT {select_cols}
\t\tFROM {table} WHERE id=$1`, id,
\t).Scan(&row.ID, {', '.join(scan_vars)})
\tif errors.Is(err, pgx.ErrNoRows) {{
\t\treturn nil, err
\t}}
\tif err != nil {{
\t\treturn nil, err
\t}}
\treturn &row, nil
}}
"""
# Fix Scan - I duplicated &row incorrectly. scan_vars already have &row.X
# List scan should be: rows.Scan(&row.ID, &row.Code, ...)
scan_list = ", ".join(["&row.ID"] + [f"&row.{c['go_name']}" for c in columns] + ["&row.UpdatedAt"])
repo = f"""package repository
import (
\t"context"
\t"errors"
\t"time"
\t"github.com/google/uuid"
\t"github.com/jackc/pgx/v5"
)
// {row_type} is {concept} catalog row.
type {row_type} struct {{
\tID uuid.UUID `json:"id"`
{chr(10).join(go_fields)}
}}
// List{list_method} returns {concept} catalog.
func (r *AdminRepo) List{list_method}(ctx context.Context) ([]{row_type}, error) {{
\trows, err := r.Pool.Query(ctx, `
\t\tSELECT {select_cols}
\t\tFROM {table}
\t\tORDER BY active DESC, code ASC`)
\tif err != nil {{
\t\treturn nil, err
\t}}
\tdefer rows.Close()
\tvar out []{row_type}
\tfor rows.Next() {{
\t\tvar row {row_type}
\t\tif err := rows.Scan({scan_list}); err != nil {{
\t\t\treturn nil, err
\t\t}}
\t\tout = append(out, row)
\t}}
\treturn out, rows.Err()
}}
// Get{get_method} loads one by id.
func (r *AdminRepo) Get{get_method}(ctx context.Context, id uuid.UUID) (*{row_type}, error) {{
\tvar row {row_type}
\terr := r.Pool.QueryRow(ctx, `
\t\tSELECT {select_cols}
\t\tFROM {table} WHERE id=$1`, id,
\t).Scan({scan_list})
\tif errors.Is(err, pgx.ErrNoRows) {{
\t\treturn nil, err
\t}}
\tif err != nil {{
\t\treturn nil, err
\t}}
\treturn &row, nil
}}
"""
write(ROOT / f"apps/api/internal/repository/{slug.replace('-', '_')}_repo.go", repo)
svc = f"""package admin
import (
\t"context"
\t"errors"
\t"github.com/google/uuid"
\t"github.com/jackc/pgx/v5"
\t"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
var {err_name} = errString("{slug.replace('-', ' ')} not found")
// List{list_method} returns catalog.
func (s *Service) List{list_method}(ctx context.Context) ([]repository.{row_type}, error) {{
\titems, err := s.Repo.List{list_method}(ctx)
\tif err != nil {{
\t\treturn nil, err
\t}}
\tif items == nil {{
\t\titems = []repository.{row_type}{{}}
\t}}
\treturn items, nil
}}
// Get{get_method} loads one.
func (s *Service) Get{get_method}(ctx context.Context, id uuid.UUID) (*repository.{row_type}, error) {{
\trow, err := s.Repo.Get{get_method}(ctx, id)
\tif errors.Is(err, pgx.ErrNoRows) {{
\t\treturn nil, {err_name}
\t}}
\treturn row, err
}}
"""
write(ROOT / f"apps/api/internal/service/admin/{slug.replace('-', '_')}.go", svc)
handler = f"""package handler
import (
\t"errors"
\t"net/http"
\t"github.com/gin-gonic/gin"
\t"github.com/google/uuid"
\t"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
\t"github.com/yuxingu/digital-psychology/apps/api/internal/service/admin"
\t"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
)
func (h *AdminHandler) {register_fn}(authed *gin.RouterGroup) {{
\tg := authed.Group("{route_group}")
\tg.GET("/{route_res}", middleware.RequireAdminPermission(h.Svc, admin.{perm_const}), h.List{list_method})
\tg.GET("/{route_res}/:id", middleware.RequireAdminPermission(h.Svc, admin.{perm_const}), h.Get{get_method})
}}
func (h *AdminHandler) List{list_method}(c *gin.Context) {{
\titems, err := h.Svc.List{list_method}(c.Request.Context())
\tif err != nil {{
\t\tresponse.Fail(c, http.StatusInternalServerError, 50050, "list {slug} failed")
\t\treturn
\t}}
\tresponse.OK(c, gin.H{{"items": items}})
}}
func (h *AdminHandler) Get{get_method}(c *gin.Context) {{
\tid, err := uuid.Parse(c.Param("id"))
\tif err != nil {{
\t\tresponse.Fail(c, http.StatusBadRequest, 40002, "invalid id")
\t\treturn
\t}}
\trow, err := h.Svc.Get{get_method}(c.Request.Context(), id)
\tif errors.Is(err, admin.{err_name}) {{
\t\tresponse.Fail(c, http.StatusNotFound, 40420, "{slug} not found")
\t\treturn
\t}}
\tif err != nil {{
\t\tresponse.Fail(c, http.StatusInternalServerError, 50051, "get {slug} failed")
\t\treturn
\t}}
\tresponse.OK(c, row)
}}
"""
write(ROOT / f"apps/api/internal/handler/admin_{slug.replace('-', '_')}.go", handler)
append_register(register_fn)
# 4) integration test
path_list = f"/api/v1/admin{route_group}/{route_res}"
test = f"""package integration_test
import (
\t"context"
\t"encoding/json"
\t"fmt"
\t"net/http"
\t"testing"
\t"time"
\t"github.com/google/uuid"
\t"golang.org/x/crypto/bcrypt"
)
func {test_name}(t *testing.T) {{
\tr, pool := setupAPIPool(t)
\tctx := context.Background()
\ttok := adminLogin(t, r, "admin", "change-me")
\t_, code := doAdminJSON(t, r, http.MethodGet, "{path_list}", nil, "")
\tif code != http.StatusUnauthorized {{
\t\tt.Fatalf("expected 401, got %d", code)
\t}}
\tlimitedRoleID := uuid.New()
\t_, err := pool.Exec(ctx, `INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`,
\t\tlimitedRoleID, "lim_"+limitedRoleID.String()[:8])
\tif err != nil {{
\t\tt.Fatal(err)
\t}}
\t_, err = pool.Exec(ctx, `INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.users.read')`, limitedRoleID)
\tif err != nil {{
\t\tt.Fatal(err)
\t}}
\thash, err := bcrypt.GenerateFromPassword([]byte("limited-pass"), bcrypt.DefaultCost)
\tif err != nil {{
\t\tt.Fatal(err)
\t}}
\tlimUser := fmt.Sprintf("lim_%d", time.Now().UnixNano())
\t_, err = pool.Exec(ctx, `INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1,$2,$3)`,
\tlimUser, string(hash), limitedRoleID)
\tif err != nil {{
\t\tt.Fatal(err)
\t}}
\tt.Cleanup(func() {{
\t\t_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, limUser)
\t\t_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID)
\t}})
\tlimTok := adminLogin(t, r, limUser, "limited-pass")
\t_, code = doAdminJSON(t, r, http.MethodGet, "{path_list}", nil, limTok)
\tif code != http.StatusForbidden {{
\t\tt.Fatalf("expected 403, got %d", code)
\t}}
\tstart := time.Now()
\tenv, code := doAdminJSON(t, r, http.MethodGet, "{path_list}", nil, tok)
\tif code != 200 || env.Code != 0 {{
\t\tt.Fatalf("list http=%d msg=%s", code, env.Message)
\t}}
\tif time.Since(start) > 500*time.Millisecond {{
\t\tt.Fatalf("list too slow %v", time.Since(start))
\t}}
\tvar list struct {{
\t\tItems []struct {{
\t\t\tID string `json:"id"`
\t\t\tCode string `json:"code"`
\t\t}} `json:"items"`
\t}}
\t_ = json.Unmarshal(env.Data, &list)
\tvar id string
\tfor _, it := range list.Items {{
\t\tif it.Code == "{seed_code}" {{
\t\t\tid = it.ID
\t\t\tbreak
\t\t}}
\t}}
\tif id == "" {{
\t\tt.Fatalf("missing {seed_code}: %#v", list.Items)
\t}}
\tenv, code = doAdminJSON(t, r, http.MethodGet, "{path_list}/"+id, nil, tok)
\tif code != 200 {{
\t\tt.Fatalf("get %d", code)
\t}}
\t_, code = doAdminJSON(t, r, http.MethodGet, "{path_list}/"+fakeUUID(), nil, tok)
\tif code != http.StatusNotFound {{
\t\tt.Fatalf("expected 404, got %d", code)
\t}}
}}
"""
write(ROOT / f"apps/api/internal/integration/{slug.replace('-', '_')}_test.go", test)
# 5) openapi
oa = ROOT / "proto/openapi.yaml"
ot = oa.read_text()
block = f""" /api/v1/admin{route_group}/{route_res}:
get:
tags: [admin]
summary: List {concept} catalog
description: Requires {perm_code}
responses:
'200':
description: OK
'401':
description: Unauthorized
'403':
description: Forbidden
/api/v1/admin{route_group}/{route_res}/{{id}}:
get:
tags: [admin]
summary: Get {concept}
parameters:
- in: path
name: id
required: true
schema: {{ type: string, format: uuid }}
responses:
'200':
description: OK
'404':
description: Not found
"""
if f"/admin{route_group}/{route_res}:" in ot:
print("openapi already has route")
else:
if openapi_before in ot:
ot = ot.replace(openapi_before, block + openapi_before)
else:
ot = ot + "\n" + block
oa.write_text(ot)
print("updated openapi")
# 6) client.ts append before orders:
client = ROOT / "apps/admin-h5/src/api/client.ts"
ct = client.read_text()
method = cfg.get("client_list", route_res.replace("-", "_"))
# camelCase
def camel(s: str) -> str:
parts = s.replace("_", "-").split("-")
return parts[0] + "".join(p.title() for p in parts[1:])
list_fn = camel(route_res)
get_fn = camel(route_res.rstrip("s") if route_res.endswith("s") else route_res + "Item")
if route_res.endswith("s"):
get_fn = camel(route_res[:-1])
stub = f""" {list_fn}: () =>
request<{{ items: Array<Record<string, unknown>> }}>('GET', '{route_group}/{route_res}'),
{get_fn}: (id: string) =>
request<Record<string, unknown>>('GET', `{route_group}/{route_res}/${{id}}`),
"""
if f"{list_fn}:" not in ct:
ct = ct.replace(" orders: () =>", stub + " orders: () =>")
client.write_text(ct)
print("updated client")
print("GENERATED", ecr, concept)
if __name__ == "__main__":
main()