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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-08 03:13:36 +08:00
co-authored by Cursor
parent 8c50b3d925
commit b91806ba80
40 changed files with 1909 additions and 2 deletions
+541
View File
@@ -0,0 +1,541 @@
#!/usr/bin/env python3
"""Generate a standard Ops read-catalog slice (migration+repo+svc+handler+test+openapi+client stub)."""
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
OPENAPI_MARKER = " /api/v1/admin/crisis/policies:" # insert before crisis if cms; else append before end - configurable
def run(cmd: list[str]):
print("+", " ".join(cmd))
subprocess.check_call(cmd, cwd=ROOT)
def write(path: Path, text: str):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8")
print("wrote", path.relative_to(ROOT))
def ensure_perm(code: str, const: str):
rbac = ROOT / "apps/api/internal/service/admin/rbac.go"
t = rbac.read_text()
if f'{const} ' in t or f'{const}\t' in t or f'{const}=' in t.replace(' ', ''):
if code in t and const in t:
# still ensure knownPermissions
pass
if f'{const} ' not in t and f'{const}\t' not in t:
# insert before closing paren of const block
t = t.replace(
')\n\nvar knownPermissions',
f'\t{const} = "{code}"\n)\n\nvar knownPermissions',
1,
)
if f'{const}:' not in t:
t = t.replace(
'PermCMSRead: {},\n}',
f'PermCMSRead: {{}}, {const}: {{}},\n}}',
1,
)
if f'{const}:' not in t:
# append before knownPermissions closing
t = t.replace(
'PermCrisisRead: {}, PermCMSRead: {},',
f'PermCrisisRead: {{}}, PermCMSRead: {{}}, {const}: {{}},',
1,
)
if code not in t:
raise SystemExit(f'failed to inject perm {code}')
rbac.write_text(t)
print("updated rbac", code, const)
def append_register(fn: str):
admin = ROOT / "apps/api/internal/handler/admin.go"
t = admin.read_text()
if f"h.{fn}(" in t:
return
needle = "\th.registerCMS(authed)\n"
if needle in t and f"h.{fn}(" not in t:
# insert after last registerXXX line inside Register
import re
m = list(re.finditer(r"\th\.register\w+\(authed\)\n", t))
if m:
last = m[-1]
t = t[: last.end()] + f"\th.{fn}(authed)\n" + t[last.end() :]
admin.write_text(t)
return
admin.write_text(t)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--spec", required=True, help="path to json slice spec")
args = ap.parse_args()
cfg = json.loads(Path(args.spec).read_text())
ecr = cfg["ecr"] # "026"
slug = cfg["slug"]
concept = cfg["concept"]
list_method = cfg["list_method"]
get_method = cfg["get_method"]
table = cfg["table"]
mig = cfg["migration"]
route_group = cfg["route_group"] # /cms
route_res = cfg["route_resource"] # publications
perm_const = cfg["perm_const"] # PermCMSRead
perm_code = cfg["perm_code"]
seed_code = cfg["seed_code"]
columns = cfg["columns"] # list of {name, sql_type, go_type, json, seed?}
register_fn = cfg["register_fn"]
test_name = cfg["test_name"]
row_type = cfg["row_type"]
err_name = cfg["err_name"]
new_perm = cfg.get("new_perm", False)
capability = cfg["capability"]
bc = cfg["bc"]
predecessor = cfg["predecessor"]
title = cfg["title"]
non_goals = cfg["non_goals"]
openapi_before = cfg.get("openapi_before", OPENAPI_MARKER)
# 1) ESS scaffold
apis = f"GET /admin{route_group}/{route_res}|GET /admin{route_group}/{route_res}/{{id}}"
cmd = [
"python3", "scripts/ess-slice-scaffold.py",
"--ecr", ecr, "--slug", slug, "--title", title,
"--capability", capability, "--bc", bc, "--concept", concept,
"--predecessor", predecessor, "--migration", mig, "--perm", perm_code,
"--apis", apis, "--non-goals", non_goals,
]
if new_perm:
cmd.append("--new-perm")
run(cmd)
# 2) migration
col_sql = [f" {c['name']} {c['sql']}" for c in columns]
seed_cols = [c["name"] for c in columns if c.get("seed") is not None]
seed_vals = []
for c in columns:
if c.get("seed") is None:
continue
v = c["seed"]
if isinstance(v, bool):
seed_vals.append("true" if v else "false")
elif isinstance(v, int):
seed_vals.append(str(v))
elif v is None:
seed_vals.append("NULL")
else:
seed_vals.append("'" + str(v).replace("'", "''") + "'")
up = f"""-- ECR-{ecr} {concept} (read catalog)
CREATE TABLE IF NOT EXISTS {table} (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
{chr(10).join(c + "," for c in col_sql)}
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_{table}_active ON {table}(active);
INSERT INTO {table}({', '.join(seed_cols)})
VALUES ({', '.join(seed_vals)})
ON CONFLICT (code) DO NOTHING;
"""
if new_perm:
up += f"""
INSERT INTO admin_role_permissions(role_id, code)
SELECT r.id, '{perm_code}'
FROM admin_roles r
WHERE r.name = 'super_admin'
ON CONFLICT DO NOTHING;
"""
write(ROOT / f"apps/api/migrations/{mig}_{table}.up.sql", up)
down = f"DROP TABLE IF EXISTS {table};\n"
if new_perm:
down = f"DELETE FROM admin_role_permissions WHERE code = '{perm_code}';\n" + down
write(ROOT / f"apps/api/migrations/{mig}_{table}.down.sql", down)
if new_perm:
ensure_perm(perm_code, perm_const)
# 3) repository file
go_fields = []
scan_vars = []
for c in columns:
go_fields.append(f"\t{c['go_name']} {c['go_type']} `json:\"{c['json']}\"`")
scan_vars.append(f"&row.{c['go_name']}")
go_fields.append('\tUpdatedAt time.Time `json:"updated_at"`')
scan_vars.append("&row.UpdatedAt")
select_cols = ", ".join(["id"] + [c["name"] for c in columns] + ["updated_at"])
repo = f"""package repository
import (
\t"context"
\t"errors"
\t"time"
\t"github.com/google/uuid"
\t"github.com/jackc/pgx/v5"
)
// {row_type} is {concept} catalog row.
type {row_type} struct {{
\tID uuid.UUID `json:"id"`
{chr(10).join(go_fields)}
}}
// List{list_method} returns {concept} catalog.
func (r *AdminRepo) List{list_method}(ctx context.Context) ([]{row_type}, error) {{
\trows, err := r.Pool.Query(ctx, `
\t\tSELECT {select_cols}
\t\tFROM {table}
\t\tORDER BY active DESC, code ASC`)
\tif err != nil {{
\t\treturn nil, err
\t}}
\tdefer rows.Close()
\tvar out []{row_type}
\tfor rows.Next() {{
\t\tvar row {row_type}
\t\tif err := rows.Scan(&row.ID, {', '.join(scan_vars)}); err != nil {{
\t\t\treturn nil, err
\t\t}}
\t\tout = append(out, row)
\t}}
\treturn out, rows.Err()
}}
// Get{get_method} loads one by id.
func (r *AdminRepo) Get{get_method}(ctx context.Context, id uuid.UUID) (*{row_type}, error) {{
\tvar row {row_type}
\terr := r.Pool.QueryRow(ctx, `
\t\tSELECT {select_cols}
\t\tFROM {table} WHERE id=$1`, id,
\t).Scan(&row.ID, {', '.join(scan_vars)})
\tif errors.Is(err, pgx.ErrNoRows) {{
\t\treturn nil, err
\t}}
\tif err != nil {{
\t\treturn nil, err
\t}}
\treturn &row, nil
}}
"""
# Fix Scan - I duplicated &row incorrectly. scan_vars already have &row.X
# List scan should be: rows.Scan(&row.ID, &row.Code, ...)
scan_list = ", ".join(["&row.ID"] + [f"&row.{c['go_name']}" for c in columns] + ["&row.UpdatedAt"])
repo = f"""package repository
import (
\t"context"
\t"errors"
\t"time"
\t"github.com/google/uuid"
\t"github.com/jackc/pgx/v5"
)
// {row_type} is {concept} catalog row.
type {row_type} struct {{
\tID uuid.UUID `json:"id"`
{chr(10).join(go_fields)}
}}
// List{list_method} returns {concept} catalog.
func (r *AdminRepo) List{list_method}(ctx context.Context) ([]{row_type}, error) {{
\trows, err := r.Pool.Query(ctx, `
\t\tSELECT {select_cols}
\t\tFROM {table}
\t\tORDER BY active DESC, code ASC`)
\tif err != nil {{
\t\treturn nil, err
\t}}
\tdefer rows.Close()
\tvar out []{row_type}
\tfor rows.Next() {{
\t\tvar row {row_type}
\t\tif err := rows.Scan({scan_list}); err != nil {{
\t\t\treturn nil, err
\t\t}}
\t\tout = append(out, row)
\t}}
\treturn out, rows.Err()
}}
// Get{get_method} loads one by id.
func (r *AdminRepo) Get{get_method}(ctx context.Context, id uuid.UUID) (*{row_type}, error) {{
\tvar row {row_type}
\terr := r.Pool.QueryRow(ctx, `
\t\tSELECT {select_cols}
\t\tFROM {table} WHERE id=$1`, id,
\t).Scan({scan_list})
\tif errors.Is(err, pgx.ErrNoRows) {{
\t\treturn nil, err
\t}}
\tif err != nil {{
\t\treturn nil, err
\t}}
\treturn &row, nil
}}
"""
write(ROOT / f"apps/api/internal/repository/{slug.replace('-', '_')}_repo.go", repo)
svc = f"""package admin
import (
\t"context"
\t"errors"
\t"github.com/google/uuid"
\t"github.com/jackc/pgx/v5"
\t"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
var {err_name} = errString("{slug.replace('-', ' ')} not found")
// List{list_method} returns catalog.
func (s *Service) List{list_method}(ctx context.Context) ([]repository.{row_type}, error) {{
\titems, err := s.Repo.List{list_method}(ctx)
\tif err != nil {{
\t\treturn nil, err
\t}}
\tif items == nil {{
\t\titems = []repository.{row_type}{{}}
\t}}
\treturn items, nil
}}
// Get{get_method} loads one.
func (s *Service) Get{get_method}(ctx context.Context, id uuid.UUID) (*repository.{row_type}, error) {{
\trow, err := s.Repo.Get{get_method}(ctx, id)
\tif errors.Is(err, pgx.ErrNoRows) {{
\t\treturn nil, {err_name}
\t}}
\treturn row, err
}}
"""
write(ROOT / f"apps/api/internal/service/admin/{slug.replace('-', '_')}.go", svc)
handler = f"""package handler
import (
\t"errors"
\t"net/http"
\t"github.com/gin-gonic/gin"
\t"github.com/google/uuid"
\t"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
\t"github.com/yuxingu/digital-psychology/apps/api/internal/service/admin"
\t"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
)
func (h *AdminHandler) {register_fn}(authed *gin.RouterGroup) {{
\tg := authed.Group("{route_group}")
\tg.GET("/{route_res}", middleware.RequireAdminPermission(h.Svc, admin.{perm_const}), h.List{list_method})
\tg.GET("/{route_res}/:id", middleware.RequireAdminPermission(h.Svc, admin.{perm_const}), h.Get{get_method})
}}
func (h *AdminHandler) List{list_method}(c *gin.Context) {{
\titems, err := h.Svc.List{list_method}(c.Request.Context())
\tif err != nil {{
\t\tresponse.Fail(c, http.StatusInternalServerError, 50050, "list {slug} failed")
\t\treturn
\t}}
\tresponse.OK(c, gin.H{{"items": items}})
}}
func (h *AdminHandler) Get{get_method}(c *gin.Context) {{
\tid, err := uuid.Parse(c.Param("id"))
\tif err != nil {{
\t\tresponse.Fail(c, http.StatusBadRequest, 40002, "invalid id")
\t\treturn
\t}}
\trow, err := h.Svc.Get{get_method}(c.Request.Context(), id)
\tif errors.Is(err, admin.{err_name}) {{
\t\tresponse.Fail(c, http.StatusNotFound, 40420, "{slug} not found")
\t\treturn
\t}}
\tif err != nil {{
\t\tresponse.Fail(c, http.StatusInternalServerError, 50051, "get {slug} failed")
\t\treturn
\t}}
\tresponse.OK(c, row)
}}
"""
write(ROOT / f"apps/api/internal/handler/admin_{slug.replace('-', '_')}.go", handler)
append_register(register_fn)
# 4) integration test
path_list = f"/api/v1/admin{route_group}/{route_res}"
test = f"""package integration_test
import (
\t"context"
\t"encoding/json"
\t"fmt"
\t"net/http"
\t"testing"
\t"time"
\t"github.com/google/uuid"
\t"golang.org/x/crypto/bcrypt"
)
func {test_name}(t *testing.T) {{
\tr, pool := setupAPIPool(t)
\tctx := context.Background()
\ttok := adminLogin(t, r, "admin", "change-me")
\t_, code := doAdminJSON(t, r, http.MethodGet, "{path_list}", nil, "")
\tif code != http.StatusUnauthorized {{
\t\tt.Fatalf("expected 401, got %d", code)
\t}}
\tlimitedRoleID := uuid.New()
\t_, err := pool.Exec(ctx, `INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`,
\t\tlimitedRoleID, "lim_"+limitedRoleID.String()[:8])
\tif err != nil {{
\t\tt.Fatal(err)
\t}}
\t_, err = pool.Exec(ctx, `INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.users.read')`, limitedRoleID)
\tif err != nil {{
\t\tt.Fatal(err)
\t}}
\thash, err := bcrypt.GenerateFromPassword([]byte("limited-pass"), bcrypt.DefaultCost)
\tif err != nil {{
\t\tt.Fatal(err)
\t}}
\tlimUser := fmt.Sprintf("lim_%d", time.Now().UnixNano())
\t_, err = pool.Exec(ctx, `INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1,$2,$3)`,
\tlimUser, string(hash), limitedRoleID)
\tif err != nil {{
\t\tt.Fatal(err)
\t}}
\tt.Cleanup(func() {{
\t\t_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, limUser)
\t\t_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID)
\t}})
\tlimTok := adminLogin(t, r, limUser, "limited-pass")
\t_, code = doAdminJSON(t, r, http.MethodGet, "{path_list}", nil, limTok)
\tif code != http.StatusForbidden {{
\t\tt.Fatalf("expected 403, got %d", code)
\t}}
\tstart := time.Now()
\tenv, code := doAdminJSON(t, r, http.MethodGet, "{path_list}", nil, tok)
\tif code != 200 || env.Code != 0 {{
\t\tt.Fatalf("list http=%d msg=%s", code, env.Message)
\t}}
\tif time.Since(start) > 500*time.Millisecond {{
\t\tt.Fatalf("list too slow %v", time.Since(start))
\t}}
\tvar list struct {{
\t\tItems []struct {{
\t\t\tID string `json:"id"`
\t\t\tCode string `json:"code"`
\t\t}} `json:"items"`
\t}}
\t_ = json.Unmarshal(env.Data, &list)
\tvar id string
\tfor _, it := range list.Items {{
\t\tif it.Code == "{seed_code}" {{
\t\t\tid = it.ID
\t\t\tbreak
\t\t}}
\t}}
\tif id == "" {{
\t\tt.Fatalf("missing {seed_code}: %#v", list.Items)
\t}}
\tenv, code = doAdminJSON(t, r, http.MethodGet, "{path_list}/"+id, nil, tok)
\tif code != 200 {{
\t\tt.Fatalf("get %d", code)
\t}}
\t_, code = doAdminJSON(t, r, http.MethodGet, "{path_list}/"+fakeUUID(), nil, tok)
\tif code != http.StatusNotFound {{
\t\tt.Fatalf("expected 404, got %d", code)
\t}}
}}
"""
write(ROOT / f"apps/api/internal/integration/{slug.replace('-', '_')}_test.go", test)
# 5) openapi
oa = ROOT / "proto/openapi.yaml"
ot = oa.read_text()
block = f""" /api/v1/admin{route_group}/{route_res}:
get:
tags: [admin]
summary: List {concept} catalog
description: Requires {perm_code}
responses:
'200':
description: OK
'401':
description: Unauthorized
'403':
description: Forbidden
/api/v1/admin{route_group}/{route_res}/{{id}}:
get:
tags: [admin]
summary: Get {concept}
parameters:
- in: path
name: id
required: true
schema: {{ type: string, format: uuid }}
responses:
'200':
description: OK
'404':
description: Not found
"""
if f"/admin{route_group}/{route_res}:" in ot:
print("openapi already has route")
else:
if openapi_before in ot:
ot = ot.replace(openapi_before, block + openapi_before)
else:
ot = ot + "\n" + block
oa.write_text(ot)
print("updated openapi")
# 6) client.ts append before orders:
client = ROOT / "apps/admin-h5/src/api/client.ts"
ct = client.read_text()
method = cfg.get("client_list", route_res.replace("-", "_"))
# camelCase
def camel(s: str) -> str:
parts = s.replace("_", "-").split("-")
return parts[0] + "".join(p.title() for p in parts[1:])
list_fn = camel(route_res)
get_fn = camel(route_res.rstrip("s") if route_res.endswith("s") else route_res + "Item")
if route_res.endswith("s"):
get_fn = camel(route_res[:-1])
stub = f""" {list_fn}: () =>
request<{{ items: Array<Record<string, unknown>> }}>('GET', '{route_group}/{route_res}'),
{get_fn}: (id: string) =>
request<Record<string, unknown>>('GET', `{route_group}/{route_res}/${{id}}`),
"""
if f"{list_fn}:" not in ct:
ct = ct.replace(" orders: () =>", stub + " orders: () =>")
client.write_text(ct)
print("updated client")
print("GENERATED", ecr, concept)
if __name__ == "__main__":
main()