feat: harden loose-offline sync for user JWT, schema, and console ops
Enable Binding-scoped agent push/pull, empty-table schema ensure, SyncPage inspect/drop-table, default module import, and agent-bound publish docs from the 宇恒联调意见. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
165
platform/internal/blueprint/ensure_ops.go
Normal file
165
platform/internal/blueprint/ensure_ops.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package blueprint
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// defaultImportExportOps Z9:业务表默认允许导入/导出。
|
||||
var defaultImportExportOps = []string{"import", "export"}
|
||||
|
||||
// defaultListActions Z9:列表页默认工具栏(含导入)。
|
||||
var defaultListActions = []string{"create", "edit", "delete", "export", "import", "refresh"}
|
||||
|
||||
// EnsureDefaultImportExport 发布/合并兜底(Z9d):
|
||||
// 可写业务 resource 缺 import/export 则补上;list 页 actions 缺 import 则补上。
|
||||
// 跳过:只读 resource(仅 list/get);meta.ui.disable_default_import == true。
|
||||
func (bp *Blueprint) EnsureDefaultImportExport() {
|
||||
if bp == nil {
|
||||
return
|
||||
}
|
||||
if disableDefaultImport(bp) {
|
||||
return
|
||||
}
|
||||
for i := range bp.Apis.Resources {
|
||||
r := &bp.Apis.Resources[i]
|
||||
if resourceReadOnly(r.Operations) {
|
||||
continue
|
||||
}
|
||||
r.Operations = ensureOpList(r.Operations, defaultImportExportOps...)
|
||||
}
|
||||
for i := range bp.Pages {
|
||||
p := &bp.Pages[i]
|
||||
if !strings.EqualFold(strings.TrimSpace(p.Type), "list") {
|
||||
continue
|
||||
}
|
||||
if p.Layout == nil {
|
||||
p.Layout = &PageLayout{}
|
||||
}
|
||||
p.Layout.Actions = ensureListActions(p.Layout.Actions)
|
||||
if p.Layout.ActionLabels == nil {
|
||||
p.Layout.ActionLabels = map[string]string{}
|
||||
}
|
||||
if _, ok := p.Layout.ActionLabels["import"]; !ok {
|
||||
p.Layout.ActionLabels["import"] = "导入 Excel"
|
||||
}
|
||||
if _, ok := p.Layout.ActionLabels["export"]; !ok {
|
||||
p.Layout.ActionLabels["export"] = "导出 Excel"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func disableDefaultImport(bp *Blueprint) bool {
|
||||
if len(bp.Meta.UI) == 0 {
|
||||
return false
|
||||
}
|
||||
var ui map[string]any
|
||||
if err := json.Unmarshal(bp.Meta.UI, &ui); err != nil {
|
||||
return false
|
||||
}
|
||||
v, ok := ui["disable_default_import"]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case bool:
|
||||
return t
|
||||
case string:
|
||||
return strings.EqualFold(t, "true") || t == "1"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func resourceReadOnly(ops []string) bool {
|
||||
if len(ops) == 0 {
|
||||
return false
|
||||
}
|
||||
writable := false
|
||||
for _, op := range ops {
|
||||
switch strings.ToLower(strings.TrimSpace(op)) {
|
||||
case "create", "update", "delete", "import", "export":
|
||||
writable = true
|
||||
}
|
||||
}
|
||||
// 仅 list/get → 只读,不强加 import
|
||||
if !writable {
|
||||
onlyRead := true
|
||||
for _, op := range ops {
|
||||
switch strings.ToLower(strings.TrimSpace(op)) {
|
||||
case "list", "get", "":
|
||||
default:
|
||||
onlyRead = false
|
||||
}
|
||||
}
|
||||
return onlyRead
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ensureOpList(ops []string, add ...string) []string {
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]string, 0, len(ops)+len(add))
|
||||
for _, op := range ops {
|
||||
op = strings.ToLower(strings.TrimSpace(op))
|
||||
if op == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[op]; ok {
|
||||
continue
|
||||
}
|
||||
seen[op] = struct{}{}
|
||||
out = append(out, op)
|
||||
}
|
||||
for _, op := range add {
|
||||
op = strings.ToLower(strings.TrimSpace(op))
|
||||
if op == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[op]; ok {
|
||||
continue
|
||||
}
|
||||
seen[op] = struct{}{}
|
||||
out = append(out, op)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ensureListActions(actions []string) []string {
|
||||
if len(actions) == 0 {
|
||||
return append([]string{}, defaultListActions...)
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]string, 0, len(actions)+2)
|
||||
for _, a := range actions {
|
||||
a = strings.ToLower(strings.TrimSpace(a))
|
||||
if a == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[a]; ok {
|
||||
continue
|
||||
}
|
||||
seen[a] = struct{}{}
|
||||
out = append(out, a)
|
||||
}
|
||||
for _, need := range []string{"import", "export"} {
|
||||
if _, ok := seen[need]; ok {
|
||||
continue
|
||||
}
|
||||
// 插在 refresh 前;若无 refresh 则追加
|
||||
inserted := false
|
||||
for i, a := range out {
|
||||
if a == "refresh" {
|
||||
out = append(out[:i], append([]string{need}, out[i:]...)...)
|
||||
seen[need] = struct{}{}
|
||||
inserted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !inserted {
|
||||
out = append(out, need)
|
||||
seen[need] = struct{}{}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
77
platform/internal/blueprint/ensure_ops_test.go
Normal file
77
platform/internal/blueprint/ensure_ops_test.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package blueprint_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"aijianzhan/platform/internal/blueprint"
|
||||
)
|
||||
|
||||
func TestEnsureDefaultImportExport(t *testing.T) {
|
||||
bp := &blueprint.Blueprint{
|
||||
Apis: blueprint.Apis{
|
||||
Resources: []blueprint.APIResource{
|
||||
{Entity: "a", Path: "/a", Operations: []string{"list", "get", "create", "update", "delete"}},
|
||||
{Entity: "ro", Path: "/ro", Operations: []string{"list", "get"}},
|
||||
{Entity: "b", Path: "/b", Operations: []string{"list", "create", "import"}},
|
||||
},
|
||||
},
|
||||
Pages: []blueprint.Page{
|
||||
{ID: "p1", Type: "list", Entity: "a", Layout: &blueprint.PageLayout{Actions: []string{"create", "edit", "delete", "refresh"}}},
|
||||
{ID: "p2", Type: "form", Entity: "a"},
|
||||
},
|
||||
}
|
||||
bp.EnsureDefaultImportExport()
|
||||
|
||||
ops0 := bp.Apis.Resources[0].Operations
|
||||
if !contains(ops0, "import") || !contains(ops0, "export") {
|
||||
t.Fatalf("writable resource should get import/export, got %v", ops0)
|
||||
}
|
||||
opsRO := bp.Apis.Resources[1].Operations
|
||||
if contains(opsRO, "import") {
|
||||
t.Fatalf("read-only should not get import, got %v", opsRO)
|
||||
}
|
||||
opsB := bp.Apis.Resources[2].Operations
|
||||
if !contains(opsB, "export") || countOp(opsB, "import") != 1 {
|
||||
t.Fatalf("should add export once, keep import: %v", opsB)
|
||||
}
|
||||
acts := bp.Pages[0].Layout.Actions
|
||||
if !contains(acts, "import") || !contains(acts, "export") {
|
||||
t.Fatalf("list actions should include import/export: %v", acts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDefaultImportExportDisabled(t *testing.T) {
|
||||
ui, _ := json.Marshal(map[string]any{"disable_default_import": true})
|
||||
bp := &blueprint.Blueprint{
|
||||
Meta: blueprint.Meta{UI: ui},
|
||||
Apis: blueprint.Apis{
|
||||
Resources: []blueprint.APIResource{
|
||||
{Entity: "a", Path: "/a", Operations: []string{"list", "create"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
bp.EnsureDefaultImportExport()
|
||||
if contains(bp.Apis.Resources[0].Operations, "import") {
|
||||
t.Fatal("disable_default_import should skip")
|
||||
}
|
||||
}
|
||||
|
||||
func contains(ss []string, want string) bool {
|
||||
for _, s := range ss {
|
||||
if s == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func countOp(ss []string, want string) int {
|
||||
n := 0
|
||||
for _, s := range ss {
|
||||
if s == want {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -5,16 +5,20 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MergeResult describes what was added when merging incoming into base.
|
||||
// MergeResult describes what was added/updated when merging incoming into base.
|
||||
type MergeResult struct {
|
||||
AddedPages []string
|
||||
AddedEntities []string
|
||||
AddedResources []string
|
||||
AddedPages []string
|
||||
AddedEntities []string
|
||||
AddedResources []string
|
||||
UpdatedPages []string
|
||||
UpdatedResources []string
|
||||
}
|
||||
|
||||
// MergeInto merges incoming pages/entities/apis into base (existing published app).
|
||||
// Existing entities keep their fields; new fields on known entities are appended.
|
||||
// Pages with the same id or route are rejected; new pages are appended.
|
||||
// Same-path resources union operations (e.g. add import).
|
||||
// Same page id:合并 actions,不再报错(支持「只改 API/按钮、不新建页」的编辑发布)。
|
||||
// 仅 route 冲突且 id 不同仍拒绝。
|
||||
func MergeInto(base, incoming *Blueprint) (*MergeResult, error) {
|
||||
if base == nil {
|
||||
return nil, fmt.Errorf("base blueprint is nil")
|
||||
@@ -49,7 +53,11 @@ func MergeInto(base, incoming *Blueprint) (*MergeResult, error) {
|
||||
for _, r := range incoming.Apis.Resources {
|
||||
k := pathKey(r.Path)
|
||||
if idx, ok := resByPath[k]; ok {
|
||||
before := len(base.Apis.Resources[idx].Operations)
|
||||
base.Apis.Resources[idx] = mergeResource(base.Apis.Resources[idx], r)
|
||||
if len(base.Apis.Resources[idx].Operations) > before {
|
||||
res.UpdatedResources = append(res.UpdatedResources, k)
|
||||
}
|
||||
continue
|
||||
}
|
||||
base.Apis.Resources = append(base.Apis.Resources, r)
|
||||
@@ -57,22 +65,27 @@ func MergeInto(base, incoming *Blueprint) (*MergeResult, error) {
|
||||
res.AddedResources = append(res.AddedResources, k)
|
||||
}
|
||||
|
||||
pageByID := map[string]struct{}{}
|
||||
routeBy := map[string]struct{}{}
|
||||
for _, p := range base.Pages {
|
||||
pageByID[p.ID] = struct{}{}
|
||||
routeBy[p.Route] = struct{}{}
|
||||
pageByID := map[string]int{}
|
||||
routeBy := map[string]string{} // route -> page id
|
||||
for i, p := range base.Pages {
|
||||
pageByID[p.ID] = i
|
||||
routeBy[p.Route] = p.ID
|
||||
}
|
||||
for _, p := range incoming.Pages {
|
||||
if _, ok := pageByID[p.ID]; ok {
|
||||
return nil, fmt.Errorf("page id already exists: %s (use a new page id when adding to an existing app)", p.ID)
|
||||
if idx, ok := pageByID[p.ID]; ok {
|
||||
before := pageActionCount(base.Pages[idx])
|
||||
base.Pages[idx] = mergePage(base.Pages[idx], p)
|
||||
if pageActionCount(base.Pages[idx]) > before {
|
||||
res.UpdatedPages = append(res.UpdatedPages, p.ID)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if _, ok := routeBy[p.Route]; ok {
|
||||
if owner, ok := routeBy[p.Route]; ok && owner != p.ID {
|
||||
return nil, fmt.Errorf("page route already exists: %s", p.Route)
|
||||
}
|
||||
base.Pages = append(base.Pages, p)
|
||||
pageByID[p.ID] = struct{}{}
|
||||
routeBy[p.Route] = struct{}{}
|
||||
pageByID[p.ID] = len(base.Pages) - 1
|
||||
routeBy[p.Route] = p.ID
|
||||
res.AddedPages = append(res.AddedPages, p.ID)
|
||||
}
|
||||
|
||||
@@ -89,7 +102,11 @@ func MergeInto(base, incoming *Blueprint) (*MergeResult, error) {
|
||||
base.Seed = incoming.Seed
|
||||
}
|
||||
|
||||
if len(res.AddedPages) == 0 && len(res.AddedEntities) == 0 && len(res.AddedResources) == 0 {
|
||||
if len(res.AddedPages) == 0 &&
|
||||
len(res.AddedEntities) == 0 &&
|
||||
len(res.AddedResources) == 0 &&
|
||||
len(res.UpdatedPages) == 0 &&
|
||||
len(res.UpdatedResources) == 0 {
|
||||
return nil, fmt.Errorf("nothing new to publish: provide newly generated pages (and entities/apis if needed) for an existing app")
|
||||
}
|
||||
return res, nil
|
||||
@@ -123,6 +140,34 @@ func mergeEntity(base, incoming Entity) Entity {
|
||||
return base
|
||||
}
|
||||
|
||||
func pageActionCount(p Page) int {
|
||||
if p.Layout == nil {
|
||||
return 0
|
||||
}
|
||||
return len(p.Layout.Actions)
|
||||
}
|
||||
|
||||
func mergePage(base, incoming Page) Page {
|
||||
if incoming.Layout == nil || len(incoming.Layout.Actions) == 0 {
|
||||
return base
|
||||
}
|
||||
if base.Layout == nil {
|
||||
base.Layout = &PageLayout{}
|
||||
}
|
||||
actSet := map[string]struct{}{}
|
||||
for _, a := range base.Layout.Actions {
|
||||
actSet[a] = struct{}{}
|
||||
}
|
||||
for _, a := range incoming.Layout.Actions {
|
||||
if _, ok := actSet[a]; ok {
|
||||
continue
|
||||
}
|
||||
base.Layout.Actions = append(base.Layout.Actions, a)
|
||||
actSet[a] = struct{}{}
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func mergeResource(base, incoming APIResource) APIResource {
|
||||
opSet := map[string]struct{}{}
|
||||
for _, op := range base.Operations {
|
||||
|
||||
@@ -40,14 +40,70 @@ func TestMergeIntoAddsPages(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeIntoRejectsDuplicatePageID(t *testing.T) {
|
||||
func TestMergeIntoUpdatesExistingPageAndResource(t *testing.T) {
|
||||
base := &Blueprint{
|
||||
Apis: Apis{Resources: []APIResource{{
|
||||
Entity: "settlement_points", Path: "/settlement_points",
|
||||
Operations: []string{"list", "get", "create", "update", "delete"},
|
||||
}}},
|
||||
Pages: []Page{{
|
||||
ID: "record_list", Route: "/records", Type: "list", Entity: "settlement_points",
|
||||
Layout: &PageLayout{Actions: []string{"create", "edit", "delete"}},
|
||||
}},
|
||||
}
|
||||
incoming := &Blueprint{
|
||||
Apis: Apis{Resources: []APIResource{{
|
||||
Entity: "settlement_points", Path: "/settlement_points",
|
||||
Operations: []string{"list", "get", "create", "update", "delete", "import", "export"},
|
||||
}}},
|
||||
Pages: []Page{{
|
||||
ID: "record_list", Route: "/records", Type: "list", Entity: "settlement_points",
|
||||
Layout: &PageLayout{Actions: []string{"create", "edit", "delete", "import"}},
|
||||
}},
|
||||
}
|
||||
res, err := MergeInto(base, incoming)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.AddedPages) != 0 {
|
||||
t.Fatalf("should not add pages: %+v", res.AddedPages)
|
||||
}
|
||||
if len(res.UpdatedResources) != 1 || res.UpdatedResources[0] != "settlement_points" {
|
||||
t.Fatalf("updated resources: %+v", res.UpdatedResources)
|
||||
}
|
||||
ops := base.Apis.Resources[0].Operations
|
||||
hasImport := false
|
||||
for _, op := range ops {
|
||||
if op == "import" {
|
||||
hasImport = true
|
||||
}
|
||||
}
|
||||
if !hasImport {
|
||||
t.Fatalf("ops missing import: %+v", ops)
|
||||
}
|
||||
acts := base.Pages[0].Layout.Actions
|
||||
hasAct := false
|
||||
for _, a := range acts {
|
||||
if a == "import" {
|
||||
hasAct = true
|
||||
}
|
||||
}
|
||||
if !hasAct {
|
||||
t.Fatalf("actions missing import: %+v", acts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeIntoRejectsRouteConflictDifferentID(t *testing.T) {
|
||||
base := &Blueprint{
|
||||
Pages: []Page{{ID: "a", Route: "/a", Type: "list", Entity: "x"}},
|
||||
}
|
||||
incoming := &Blueprint{
|
||||
Pages: []Page{{ID: "a", Route: "/b", Type: "list", Entity: "x"}},
|
||||
Pages: []Page{{ID: "b", Route: "/a", Type: "list", Entity: "x"}},
|
||||
Apis: Apis{Resources: []APIResource{{
|
||||
Entity: "x", Path: "/x", Operations: []string{"list", "import"},
|
||||
}}},
|
||||
}
|
||||
if _, err := MergeInto(base, incoming); err == nil {
|
||||
t.Fatal("expected duplicate page id error")
|
||||
t.Fatal("expected route conflict error")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user