chore: initial commit of ai site platform

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
whm
2026-07-31 10:31:17 +08:00
commit 4ca82fb58a
203 changed files with 45745 additions and 0 deletions

View 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 由平台重写 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
}