feat(ECR-026): OpsCMS ScheduledPublication 只读并 Closed
定时发布目录(ops_scheduled_publications),并加固 catalog 生成器。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 · 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`
|
||||
@@ -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<{
|
||||
|
||||
@@ -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
|
||||
@@ -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 · 只读)
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# CODE_REVIEW — ECR-026
|
||||
|
||||
**Verdict:** Approve → Closed
|
||||
|
||||
Date: 2026-08-08 · Loop continuous
|
||||
|
||||
- ScheduledPublication 只读;无 UGC/真支付
|
||||
- Integration AC mapped · OpenAPI updated
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 只读
|
||||
@@ -0,0 +1,6 @@
|
||||
# STATE — ECR-026
|
||||
|
||||
| Status | **Closed** |
|
||||
| Phase | closed |
|
||||
| Spec | ops-scheduled-publication.md |
|
||||
| Updated | 2026-08-08 |
|
||||
@@ -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
|
||||
@@ -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 只读 |
|
||||
@@ -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 |
|
||||
|
||||
@@ -55,4 +55,4 @@ Human 明文:**直接用 Loop,不用人工确认。**
|
||||
|
||||
| Done | Next |
|
||||
|------|------|
|
||||
| ECR-013A…025 Closed | **ECR-026** ScheduledPublication(OpsCMS) |
|
||||
| ECR-013A…026 Closed | **ECR-027** KnowledgeChunk |
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user