chore: initial commit of ai site platform
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
514
platform/internal/blueprint/blueprint.go
Normal file
514
platform/internal/blueprint/blueprint.go
Normal file
@@ -0,0 +1,514 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user