Files
ai_site/platform/internal/blueprint/blueprint.go
2026-07-31 10:31:17 +08:00

515 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 由平台重写 schemadatabase_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
}