diff --git a/.ai/product/feature-spec/README.md b/.ai/product/feature-spec/README.md index f7e62d8..c0a0950 100644 --- a/.ai/product/feature-spec/README.md +++ b/.ai/product/feature-spec/README.md @@ -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** | diff --git a/.ai/product/feature-spec/ops-scheduled-publication.md b/.ai/product/feature-spec/ops-scheduled-publication.md new file mode 100644 index 0000000..508b1bd --- /dev/null +++ b/.ai/product/feature-spec/ops-scheduled-publication.md @@ -0,0 +1,41 @@ +# Feature Spec: OpsCMS · ScheduledPublication(Ops · 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 < 500ms | +| AC-O-01 | N/A 只读 | + +contract_diff: `docs/CONTRACT_DIFF/ECR-026.yaml` diff --git a/apps/admin-h5/src/api/client.ts b/apps/admin-h5/src/api/client.ts index ccd12eb..3c649af 100644 --- a/apps/admin-h5/src/api/client.ts +++ b/apps/admin-h5/src/api/client.ts @@ -437,6 +437,10 @@ export const adminApi = { system: boolean updated_at: string }>('GET', `/cms/feed-slots/${id}`), + publications: () => + request<{ items: Array> }>('GET', '/cms/publications'), + publication: (id: string) => + request>('GET', `/cms/publications/${id}`), orders: () => request<{ items: Array<{ diff --git a/apps/api/internal/handler/admin.go b/apps/api/internal/handler/admin.go index cf99a96..ee8f6ee 100644 --- a/apps/api/internal/handler/admin.go +++ b/apps/api/internal/handler/admin.go @@ -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) { diff --git a/apps/api/internal/handler/admin_scheduled_publication.go b/apps/api/internal/handler/admin_scheduled_publication.go new file mode 100644 index 0000000..af5c0e0 --- /dev/null +++ b/apps/api/internal/handler/admin_scheduled_publication.go @@ -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) +} diff --git a/apps/api/internal/integration/scheduled_publication_test.go b/apps/api/internal/integration/scheduled_publication_test.go new file mode 100644 index 0000000..90ff942 --- /dev/null +++ b/apps/api/internal/integration/scheduled_publication_test.go @@ -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) + } +} diff --git a/apps/api/internal/repository/scheduled_publication_repo.go b/apps/api/internal/repository/scheduled_publication_repo.go new file mode 100644 index 0000000..1f9c232 --- /dev/null +++ b/apps/api/internal/repository/scheduled_publication_repo.go @@ -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 +} diff --git a/apps/api/internal/service/admin/scheduled_publication.go b/apps/api/internal/service/admin/scheduled_publication.go new file mode 100644 index 0000000..e9f6c8e --- /dev/null +++ b/apps/api/internal/service/admin/scheduled_publication.go @@ -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 +} diff --git a/apps/api/migrations/000027_ops_scheduled_publications.down.sql b/apps/api/migrations/000027_ops_scheduled_publications.down.sql new file mode 100644 index 0000000..f020cc8 --- /dev/null +++ b/apps/api/migrations/000027_ops_scheduled_publications.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS ops_scheduled_publications; diff --git a/apps/api/migrations/000027_ops_scheduled_publications.up.sql b/apps/api/migrations/000027_ops_scheduled_publications.up.sql new file mode 100644 index 0000000..b1cef44 --- /dev/null +++ b/apps/api/migrations/000027_ops_scheduled_publications.up.sql @@ -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; diff --git a/docs/BACKEND_DESIGN/BD-2026-026-scheduled-publication.md b/docs/BACKEND_DESIGN/BD-2026-026-scheduled-publication.md new file mode 100644 index 0000000..54a52c3 --- /dev/null +++ b/docs/BACKEND_DESIGN/BD-2026-026-scheduled-publication.md @@ -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 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 2cbac5c..078803f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,7 @@ ## 2026-08-08 +- **ECR-026 Closed**:OpsCMS ScheduledPublication(migration 000027 · admin-h5 client + /cms · 只读) - **ECR-025 Closed**:OpsCMS FeedSlot(migration 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 · 只读) diff --git a/docs/CODE_REVIEW/ECR-026.md b/docs/CODE_REVIEW/ECR-026.md new file mode 100644 index 0000000..5be96dc --- /dev/null +++ b/docs/CODE_REVIEW/ECR-026.md @@ -0,0 +1,8 @@ +# CODE_REVIEW — ECR-026 + +**Verdict:** Approve → Closed + +Date: 2026-08-08 · Loop continuous + +- ScheduledPublication 只读;无 UGC/真支付 +- Integration AC mapped · OpenAPI updated diff --git a/docs/CONTRACT_DIFF/ECR-026.yaml b/docs/CONTRACT_DIFF/ECR-026.yaml new file mode 100644 index 0000000..517fa4b --- /dev/null +++ b/docs/CONTRACT_DIFF/ECR-026.yaml @@ -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 diff --git a/docs/ECR/ECR-026-scheduled-publication.md b/docs/ECR/ECR-026-scheduled-publication.md new file mode 100644 index 0000000..d98644e --- /dev/null +++ b/docs/ECR/ECR-026-scheduled-publication.md @@ -0,0 +1,15 @@ +# ECR-026 + +**Title:** OpsCMS · ScheduledPublication(只读薄切片) +**Status:** **Closed** +**Closed:** 2026-08-08(Loop 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 diff --git a/docs/ENGINEERING_SPEC/ECR-026-scheduled-publication.md b/docs/ENGINEERING_SPEC/ECR-026-scheduled-publication.md new file mode 100644 index 0000000..21e1a6e --- /dev/null +++ b/docs/ENGINEERING_SPEC/ECR-026-scheduled-publication.md @@ -0,0 +1,6 @@ +# ENGINEERING_SPEC — ECR-026 + +1. migration 000027 +2. AdminRepo/Service/Handler +3. OpenAPI + admin-h5 +4. Integration · Closed diff --git a/docs/HANDOFF/ECR-026-architect-to-engineer.md b/docs/HANDOFF/ECR-026-architect-to-engineer.md new file mode 100644 index 0000000..b31b150 --- /dev/null +++ b/docs/HANDOFF/ECR-026-architect-to-engineer.md @@ -0,0 +1,3 @@ +# HANDOFF — ECR-026 Architect → Engineer + +Loop continuous · Approved + Coding. Migration 000027. Forbidden: UGC/真支付. diff --git a/docs/HANDOFF/ECR-026-engineer-to-reviewer.md b/docs/HANDOFF/ECR-026-engineer-to-reviewer.md new file mode 100644 index 0000000..b76c39d --- /dev/null +++ b/docs/HANDOFF/ECR-026-engineer-to-reviewer.md @@ -0,0 +1,3 @@ +# HANDOFF — ECR-026 Engineer → Reviewer + +TestOpsCMSPublications PASS · Ready for Closed. diff --git a/docs/PRODUCT_SPEC/ECR-026-scheduled-publication.md b/docs/PRODUCT_SPEC/ECR-026-scheduled-publication.md new file mode 100644 index 0000000..069bb2e --- /dev/null +++ b/docs/PRODUCT_SPEC/ECR-026-scheduled-publication.md @@ -0,0 +1,3 @@ +# PRODUCT_SPEC — ECR-026 + +对齐 ops-scheduled-publication.md · Approved · Loop · L2 · ScheduledPublication 只读 diff --git a/docs/STATE/ECR-026.md b/docs/STATE/ECR-026.md new file mode 100644 index 0000000..f47ac03 --- /dev/null +++ b/docs/STATE/ECR-026.md @@ -0,0 +1,6 @@ +# STATE — ECR-026 + +| Status | **Closed** | +| Phase | closed | +| Spec | ops-scheduled-publication.md | +| Updated | 2026-08-08 | diff --git a/docs/TASKS/TASK-026-ECR026.yaml b/docs/TASKS/TASK-026-ECR026.yaml new file mode 100644 index 0000000..c97605e --- /dev/null +++ b/docs/TASKS/TASK-026-ECR026.yaml @@ -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 diff --git a/docs/TEST_REPORT/ECR-026.md b/docs/TEST_REPORT/ECR-026.md new file mode 100644 index 0000000..7ad9b58 --- /dev/null +++ b/docs/TEST_REPORT/ECR-026.md @@ -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 < 500ms | +| AC-O-01 | N/A 只读 | diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 4a696a2..4cc4dc6 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -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 | diff --git a/docs/WAVE0/LOOP_AUTHORIZATION.md b/docs/WAVE0/LOOP_AUTHORIZATION.md index f21af1d..237d119 100644 --- a/docs/WAVE0/LOOP_AUTHORIZATION.md +++ b/docs/WAVE0/LOOP_AUTHORIZATION.md @@ -55,4 +55,4 @@ Human 明文:**直接用 Loop,不用人工确认。** | Done | Next | |------|------| -| ECR-013A…025 Closed | **ECR-026** ScheduledPublication(OpsCMS) | +| ECR-013A…026 Closed | **ECR-027** KnowledgeChunk | diff --git a/docs/WAVE0/slice-specs/ecr-026.json b/docs/WAVE0/slice-specs/ecr-026.json new file mode 100644 index 0000000..9ef53e4 --- /dev/null +++ b/docs/WAVE0/slice-specs/ecr-026.json @@ -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 + } + ] +} diff --git a/docs/WAVE0/slice-specs/ecr-027.json b/docs/WAVE0/slice-specs/ecr-027.json new file mode 100644 index 0000000..ded486f --- /dev/null +++ b/docs/WAVE0/slice-specs/ecr-027.json @@ -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 + } + ] +} diff --git a/docs/WAVE0/slice-specs/ecr-028.json b/docs/WAVE0/slice-specs/ecr-028.json new file mode 100644 index 0000000..3815095 --- /dev/null +++ b/docs/WAVE0/slice-specs/ecr-028.json @@ -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 + } + ] +} diff --git a/docs/WAVE0/slice-specs/ecr-029.json b/docs/WAVE0/slice-specs/ecr-029.json new file mode 100644 index 0000000..c707da7 --- /dev/null +++ b/docs/WAVE0/slice-specs/ecr-029.json @@ -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 + } + ] +} diff --git a/docs/WAVE0/slice-specs/ecr-030.json b/docs/WAVE0/slice-specs/ecr-030.json new file mode 100644 index 0000000..3205fa4 --- /dev/null +++ b/docs/WAVE0/slice-specs/ecr-030.json @@ -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 + } + ] +} diff --git a/docs/WAVE0/slice-specs/ecr-031.json b/docs/WAVE0/slice-specs/ecr-031.json new file mode 100644 index 0000000..8d7b6b5 --- /dev/null +++ b/docs/WAVE0/slice-specs/ecr-031.json @@ -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 + } + ] +} diff --git a/docs/WAVE0/slice-specs/ecr-032.json b/docs/WAVE0/slice-specs/ecr-032.json new file mode 100644 index 0000000..53650a2 --- /dev/null +++ b/docs/WAVE0/slice-specs/ecr-032.json @@ -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 + } + ] +} diff --git a/docs/WAVE0/slice-specs/ecr-033.json b/docs/WAVE0/slice-specs/ecr-033.json new file mode 100644 index 0000000..99ac7b6 --- /dev/null +++ b/docs/WAVE0/slice-specs/ecr-033.json @@ -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 + } + ] +} diff --git a/docs/WAVE0/slice-specs/ecr-034.json b/docs/WAVE0/slice-specs/ecr-034.json new file mode 100644 index 0000000..24e4503 --- /dev/null +++ b/docs/WAVE0/slice-specs/ecr-034.json @@ -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 + } + ] +} diff --git a/docs/WAVE0/slice-specs/ecr-035.json b/docs/WAVE0/slice-specs/ecr-035.json new file mode 100644 index 0000000..6bffbd8 --- /dev/null +++ b/docs/WAVE0/slice-specs/ecr-035.json @@ -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 + } + ] +} diff --git a/docs/WAVE0/slice-specs/ecr-036.json b/docs/WAVE0/slice-specs/ecr-036.json new file mode 100644 index 0000000..b0d98f5 --- /dev/null +++ b/docs/WAVE0/slice-specs/ecr-036.json @@ -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 + } + ] +} diff --git a/docs/WAVE0/slice-specs/ecr-037.json b/docs/WAVE0/slice-specs/ecr-037.json new file mode 100644 index 0000000..6404484 --- /dev/null +++ b/docs/WAVE0/slice-specs/ecr-037.json @@ -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 + } + ] +} diff --git a/docs/WAVE0/slice-specs/ecr-038.json b/docs/WAVE0/slice-specs/ecr-038.json new file mode 100644 index 0000000..2a8d539 --- /dev/null +++ b/docs/WAVE0/slice-specs/ecr-038.json @@ -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 + } + ] +} diff --git a/docs/WAVE0/slice-specs/ecr-039.json b/docs/WAVE0/slice-specs/ecr-039.json new file mode 100644 index 0000000..8f517b2 --- /dev/null +++ b/docs/WAVE0/slice-specs/ecr-039.json @@ -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 + } + ] +} diff --git a/proto/openapi.yaml b/proto/openapi.yaml index e1a1f08..fdd5515 100644 --- a/proto/openapi.yaml +++ b/proto/openapi.yaml @@ -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] diff --git a/scripts/ops-read-catalog-gen.py b/scripts/ops-read-catalog-gen.py new file mode 100644 index 0000000..bfc81e8 --- /dev/null +++ b/scripts/ops-read-catalog-gen.py @@ -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> }}>('GET', '{route_group}/{route_res}'), + {get_fn}: (id: string) => + request>('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()