package blueprint import ( "encoding/json" "fmt" "regexp" "strings" ) var identRe = regexp.MustCompile(`^[a-z][a-z0-9_]{1,47}$`) var nonIdentRe = regexp.MustCompile(`[^a-z0-9_]+`) var multiUnderRe = regexp.MustCompile(`_+`) var reserved = map[string]struct{}{ "select": {}, "insert": {}, "update": {}, "delete": {}, "drop": {}, "create": {}, "alter": {}, "table": {}, "schema": {}, "user": {}, "where": {}, "from": {}, "join": {}, "grant": {}, "revoke": {}, } type Blueprint struct { Version string `json:"version"` Meta Meta `json:"meta"` Storage Storage `json:"storage"` Entities []Entity `json:"entities"` Apis Apis `json:"apis"` Pages []Page `json:"pages"` Security Security `json:"security"` Seed *Seed `json:"seed,omitempty"` } type Meta struct { Name string `json:"name"` Slug string `json:"slug"` Description string `json:"description,omitempty"` Locale string `json:"locale"` Confidence float64 `json:"confidence,omitempty"` UIPreset string `json:"ui_preset,omitempty"` PlatformTitle string `json:"platform_title,omitempty"` ProjectContext string `json:"project_context,omitempty"` UI json.RawMessage `json:"ui,omitempty"` Source json.RawMessage `json:"source,omitempty"` } type Storage struct { Mode string `json:"mode"` Engine string `json:"engine"` SchemaName string `json:"schema_name,omitempty"` } type Entity struct { Name string `json:"name"` Table string `json:"table"` Label string `json:"label"` PrimaryKey string `json:"primary_key"` Fields []Field `json:"fields"` Indexes []Index `json:"indexes,omitempty"` } type Field struct { Name string `json:"name"` Label string `json:"label"` Type string `json:"type"` Nullable *bool `json:"nullable,omitempty"` Unique bool `json:"unique,omitempty"` Default any `json:"default,omitempty"` MaxLength int `json:"max_length,omitempty"` Precision int `json:"precision,omitempty"` Scale int `json:"scale,omitempty"` EnumValues []string `json:"enum_values,omitempty"` UI json.RawMessage `json:"ui,omitempty"` } type Index struct { Name string `json:"name"` Columns []string `json:"columns"` Unique bool `json:"unique,omitempty"` } type Apis struct { BasePath string `json:"base_path"` Resources []APIResource `json:"resources"` } type APIResource struct { Entity string `json:"entity"` Path string `json:"path"` Operations []string `json:"operations"` List *ListOpt `json:"list,omitempty"` } type ListOpt struct { DefaultPageSize int `json:"default_page_size,omitempty"` MaxPageSize int `json:"max_page_size,omitempty"` AllowedFilters []string `json:"allowed_filters,omitempty"` AllowedSorts []string `json:"allowed_sorts,omitempty"` } type Page struct { ID string `json:"id"` Title string `json:"title"` Route string `json:"route"` Type string `json:"type"` Entity string `json:"entity"` Layout *PageLayout `json:"layout,omitempty"` } type PageLayout struct { Preset string `json:"preset,omitempty"` Columns []string `json:"columns,omitempty"` Filters []string `json:"filters,omitempty"` Actions []string `json:"actions,omitempty"` ActionLabels map[string]string `json:"action_labels,omitempty"` FilterStyle string `json:"filter_style,omitempty"` FormFields []string `json:"form_fields,omitempty"` Widgets []PageWidget `json:"widgets,omitempty"` } type PageWidget struct { Type string `json:"type"` Title string `json:"title,omitempty"` Metric string `json:"metric,omitempty"` Metrics []string `json:"metrics,omitempty"` GroupBy string `json:"group_by,omitempty"` XField string `json:"x_field,omitempty"` Entity string `json:"entity,omitempty"` Columns []string `json:"columns,omitempty"` LabelField string `json:"label_field,omitempty"` ValueField string `json:"value_field,omitempty"` SecondaryField string `json:"secondary_field,omitempty"` WarnField string `json:"warn_field,omitempty"` FilterField string `json:"filter_field,omitempty"` FilterOp string `json:"filter_op,omitempty"` FilterValue any `json:"filter_value,omitempty"` YUnit string `json:"y_unit,omitempty"` Variant string `json:"variant,omitempty"` CycleDays int `json:"cycle_days,omitempty"` } type Security struct { Visibility string `json:"visibility"` Roles []Role `json:"roles"` RowPolicies []RowPolicy `json:"row_policies"` } type Role struct { Name string `json:"name"` Permissions []string `json:"permissions"` } type RowPolicy struct { Entity string `json:"entity"` Rule string `json:"rule"` } type Seed struct { ImportExcel bool `json:"import_excel,omitempty"` MaxRows int `json:"max_rows,omitempty"` } func Parse(raw json.RawMessage) (*Blueprint, error) { var bp Blueprint if err := json.Unmarshal(raw, &bp); err != nil { return nil, fmt.Errorf("invalid blueprint json: %w", err) } return &bp, nil } func (bp *Blueprint) Validate(pathSlug string) error { if bp.Version != "1.0" { return fmt.Errorf("unsupported version: %s", bp.Version) } bp.SanitizeIdentifiers() // 连字符等非法字符规范为下划线(schema/表名不能含 -) bp.Meta.Slug = NormalizeIdent(bp.Meta.Slug) pathSlug = NormalizeIdent(pathSlug) if err := checkIdent("meta.slug", bp.Meta.Slug); err != nil { return err } if bp.Meta.Slug != pathSlug { return fmt.Errorf("meta.slug(%s) != path slug(%s)", bp.Meta.Slug, pathSlug) } if bp.Meta.Name == "" { return fmt.Errorf("meta.name required") } if bp.Storage.Mode != "schema_per_app" && bp.Storage.Mode != "database_per_app" { return fmt.Errorf("unsupported storage.mode: %s", bp.Storage.Mode) } if bp.Storage.Engine != "postgres" && bp.Storage.Engine != "mysql" { return fmt.Errorf("unsupported storage.engine: %s", bp.Storage.Engine) } if len(bp.Entities) == 0 { return fmt.Errorf("entities required") } entityNames := map[string]Entity{} for i, e := range bp.Entities { prefix := fmt.Sprintf("entities[%d]", i) if err := checkIdent(prefix+".name", e.Name); err != nil { return err } if err := checkIdent(prefix+".table", e.Table); err != nil { return err } if err := checkIdent(prefix+".primary_key", e.PrimaryKey); err != nil { return err } if len(e.Fields) == 0 { return fmt.Errorf("%s.fields required", prefix) } fields := map[string]struct{}{} pkFound := false for j, f := range e.Fields { fp := fmt.Sprintf("%s.fields[%d]", prefix, j) if err := checkIdent(fp+".name", f.Name); err != nil { return err } if !validFieldType(f.Type) { return fmt.Errorf("%s.type invalid: %s", fp, f.Type) } f.Type = NormalizeFieldType(f.Type) if _, ok := fields[f.Name]; ok { return fmt.Errorf("%s duplicate field %s", prefix, f.Name) } fields[f.Name] = struct{}{} if f.Name == e.PrimaryKey { pkFound = true } } if !pkFound { return fmt.Errorf("%s primary_key %s not in fields", prefix, e.PrimaryKey) } for j, idx := range e.Indexes { ip := fmt.Sprintf("%s.indexes[%d]", prefix, j) if err := checkIdent(ip+".name", idx.Name); err != nil { return err } if !strings.HasPrefix(idx.Name, "idx_") { return fmt.Errorf("%s.name must start with idx_", ip) } for _, col := range idx.Columns { if _, ok := fields[col]; !ok { return fmt.Errorf("%s unknown column %s", ip, col) } } } entityNames[e.Name] = e } if len(bp.Apis.Resources) == 0 { return fmt.Errorf("apis.resources required") } for i, r := range bp.Apis.Resources { prefix := fmt.Sprintf("apis.resources[%d]", i) if _, ok := entityNames[r.Entity]; !ok { return fmt.Errorf("%s unknown entity %s", prefix, r.Entity) } path := strings.TrimPrefix(r.Path, "/") if err := checkIdent(prefix+".path", path); err != nil { return err } if len(r.Operations) == 0 { return fmt.Errorf("%s.operations required", prefix) } } return nil } // AssignSchemaName 由平台重写 schema;database_per_app 时使用 public。 func (bp *Blueprint) AssignSchemaName(tenantID int64) string { if bp.Storage.Mode == "database_per_app" { bp.Storage.SchemaName = "public" return "public" } name := fmt.Sprintf("app_t%d_%s", tenantID, bp.Meta.Slug) if len(name) > 48 { name = name[:48] } bp.Storage.SchemaName = name return name } // AssignDatabaseName database_per_app 时返回独立库名,否则空。 func (bp *Blueprint) AssignDatabaseName(tenantID int64) string { if bp.Storage.Mode != "database_per_app" { return "" } name := fmt.Sprintf("appdb_t%d_%s", tenantID, bp.Meta.Slug) if len(name) > 48 { name = name[:48] } return name } func checkIdent(field, v string) error { if !identRe.MatchString(v) { return fmt.Errorf("%s invalid identifier: %s", field, v) } if _, bad := reserved[v]; bad { return fmt.Errorf("%s reserved identifier: %s", field, v) } return nil } // SanitizeIdentifiers 将 camelCase / 点号等统一为 snake_case,并同步页面与 API 引用。 func (bp *Blueprint) SanitizeIdentifiers() { entityRename := map[string]string{} for i := range bp.Entities { e := &bp.Entities[i] oldName := e.Name e.Name = NormalizeIdent(e.Name) e.Table = NormalizeIdent(e.Table) e.PrimaryKey = NormalizeIdent(e.PrimaryKey) if oldName != "" { entityRename[oldName] = e.Name entityRename[NormalizeIdent(oldName)] = e.Name } fieldRename := map[string]string{} used := map[string]int{} for j := range e.Fields { f := &e.Fields[j] old := f.Name next := NormalizeIdent(old) if n, ok := used[next]; ok { used[next] = n + 1 next = fmt.Sprintf("%s_%d", next, n+1) } else { used[next] = 1 } f.Name = next f.Type = NormalizeFieldType(f.Type) if old != "" { fieldRename[old] = next } } e.PrimaryKey = renameOrSelf(fieldRename, e.PrimaryKey) for j := range e.Indexes { idx := &e.Indexes[j] idx.Name = NormalizeIdent(idx.Name) if !strings.HasPrefix(idx.Name, "idx_") { idx.Name = "idx_" + idx.Name } for k, col := range idx.Columns { idx.Columns[k] = renameOrSelf(fieldRename, col) } } // pages / apis that reference this entity's fields for pi := range bp.Pages { p := &bp.Pages[pi] entRef := NormalizeIdent(p.Entity) if p.Entity == oldName || p.Entity == e.Name || entRef == e.Name || entRef == NormalizeIdent(oldName) { p.Entity = e.Name if p.Layout != nil { p.Layout.Columns = renameSlice(fieldRename, p.Layout.Columns) p.Layout.Filters = renameSlice(fieldRename, p.Layout.Filters) p.Layout.FormFields = renameSlice(fieldRename, p.Layout.FormFields) for wi := range p.Layout.Widgets { w := &p.Layout.Widgets[wi] w.Metric = renameOrSelf(fieldRename, w.Metric) w.GroupBy = renameOrSelf(fieldRename, w.GroupBy) w.XField = renameOrSelf(fieldRename, w.XField) w.LabelField = renameOrSelf(fieldRename, w.LabelField) w.ValueField = renameOrSelf(fieldRename, w.ValueField) w.WarnField = renameOrSelf(fieldRename, w.WarnField) w.FilterField = renameOrSelf(fieldRename, w.FilterField) w.Metrics = renameSlice(fieldRename, w.Metrics) w.Columns = renameSlice(fieldRename, w.Columns) if w.Entity == oldName || NormalizeIdent(w.Entity) == e.Name { w.Entity = e.Name } } } } } for ri := range bp.Apis.Resources { r := &bp.Apis.Resources[ri] entRef := NormalizeIdent(r.Entity) if r.Entity == oldName || r.Entity == e.Name || entRef == e.Name || entRef == NormalizeIdent(oldName) { r.Entity = e.Name if r.List != nil { r.List.AllowedFilters = renameSlice(fieldRename, r.List.AllowedFilters) r.List.AllowedSorts = renameSlice(fieldRename, r.List.AllowedSorts) } } } } for i := range bp.Pages { p := &bp.Pages[i] p.Entity = renameOrSelf(entityRename, p.Entity) } for i := range bp.Apis.Resources { r := &bp.Apis.Resources[i] r.Entity = renameOrSelf(entityRename, r.Entity) path := strings.TrimPrefix(r.Path, "/") r.Path = "/" + NormalizeIdent(path) } for i := range bp.Security.RowPolicies { rp := &bp.Security.RowPolicies[i] rp.Entity = renameOrSelf(entityRename, rp.Entity) } } func renameOrSelf(m map[string]string, v string) string { if v == "" { return v } if n, ok := m[v]; ok { return n } return NormalizeIdent(v) } func renameSlice(m map[string]string, in []string) []string { if len(in) == 0 { return in } out := make([]string, len(in)) for i, v := range in { out[i] = renameOrSelf(m, v) } return out } // NormalizeSlug 兼容旧名。 func NormalizeSlug(s string) string { return NormalizeIdent(s) } // NormalizeIdent 驼峰/点号/连字符 → snake_case,符合 ^[a-z][a-z0-9_]{1,47}$ func NormalizeIdent(s string) string { s = strings.TrimSpace(s) if s == "" { return "field" } var b strings.Builder b.Grow(len(s) + 8) prevUnder := false for i, r := range s { switch { case r >= 'A' && r <= 'Z': if i > 0 && !prevUnder { b.WriteByte('_') } b.WriteByte(byte(r - 'A' + 'a')) prevUnder = false case r >= 'a' && r <= 'z' || r >= '0' && r <= '9': b.WriteByte(byte(r)) prevUnder = false default: // '.', '-', 空格等 if !prevUnder && b.Len() > 0 { b.WriteByte('_') prevUnder = true } } } s = strings.Trim(b.String(), "_") s = multiUnderRe.ReplaceAllString(s, "_") if s == "" { return "field" } if s[0] < 'a' || s[0] > 'z' { s = "f_" + s } if len(s) > 48 { s = strings.TrimRight(s[:48], "_") } if s == "" { return "field" } return s } // NormalizeFieldType 将常见别名收成蓝图允许的 type。 func NormalizeFieldType(t string) string { t = strings.ToLower(strings.TrimSpace(t)) switch t { case "float", "float32", "float64", "double", "number", "numeric", "real", "money": return "decimal" case "integer", "int32", "int64", "long", "serial": if t == "int64" || t == "long" || t == "serial" { return "bigint" } return "int" case "bool": return "boolean" case "varchar", "str", "char": return "string" case "timestamp", "timestamptz", "time": return "datetime" case "file", "attachment", "blob": return "file_ref" case "string", "text", "int", "bigint", "decimal", "boolean", "date", "datetime", "enum", "json", "file_ref": return t default: if t == "" { return "string" } return t } } func validFieldType(t string) bool { switch NormalizeFieldType(t) { case "string", "text", "int", "bigint", "decimal", "boolean", "date", "datetime", "enum", "json", "file_ref": return true default: return false } } func BoolOr(p *bool, def bool) bool { if p == nil { return def } return *p }