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,188 @@
package crud
import (
"fmt"
"sort"
"strconv"
"strings"
"aijianzhan/platform/internal/blueprint"
"aijianzhan/platform/internal/meta"
)
func ensureOp(ref *meta.ResourceRef, op string) error {
for _, o := range ref.Resource.Operations {
if o == op {
return nil
}
}
return fmt.Errorf("operation %s not allowed", op)
}
func validateFilters(ref *meta.ResourceRef, filters map[string]string) error {
if len(filters) == 0 {
return nil
}
allowed := map[string]struct{}{}
if ref.Resource.List != nil {
for _, f := range ref.Resource.List.AllowedFilters {
allowed[f] = struct{}{}
}
}
for k := range filters {
if _, ok := allowed[k]; !ok {
return fmt.Errorf("filter not allowed: %s", k)
}
}
return nil
}
func validateSort(ref *meta.ResourceRef, sortBy string) error {
if sortBy == "" {
return nil
}
field := strings.TrimPrefix(sortBy, "-")
allowed := map[string]struct{}{}
if ref.Resource.List != nil {
for _, s := range ref.Resource.List.AllowedSorts {
allowed[s] = struct{}{}
}
}
if _, ok := allowed[field]; !ok {
return fmt.Errorf("sort not allowed: %s", field)
}
return nil
}
func sanitizeBody(entity blueprint.Entity, body map[string]any, partial bool) (map[string]any, error) {
fields := map[string]blueprint.Field{}
for _, f := range entity.Fields {
fields[f.Name] = f
}
out := map[string]any{}
for k, v := range body {
if k == "tenant_id" || k == "org_unit_id" || k == "created_at" || k == "updated_at" || k == "created_by" {
continue
}
f, ok := fields[k]
if !ok {
return nil, fmt.Errorf("unknown field: %s", k)
}
if f.Name == entity.PrimaryKey && partial {
continue
}
out[k] = v
}
if !partial {
system := map[string]struct{}{
"tenant_id": {}, "org_unit_id": {}, "created_at": {}, "updated_at": {}, "created_by": {},
}
for _, f := range entity.Fields {
if f.Name == entity.PrimaryKey {
continue
}
if _, ok := system[f.Name]; ok {
continue
}
if _, ok := out[f.Name]; !ok && !blueprint.BoolOr(f.Nullable, true) && f.Default == nil {
return nil, fmt.Errorf("missing required field: %s", f.Name)
}
}
}
return out, nil
}
func fieldByName(entity blueprint.Entity, name string) (blueprint.Field, bool) {
for _, f := range entity.Fields {
if f.Name == name {
return f, true
}
}
return blueprint.Field{}, false
}
func isAutoPK(entity blueprint.Entity) bool {
f, ok := fieldByName(entity, entity.PrimaryKey)
if !ok {
return false
}
return f.Type == "bigint" || f.Type == "int"
}
func quoteIdent(name string) string {
return `"` + strings.ReplaceAll(name, `"`, ``) + `"`
}
func qualifiedTable(ref *meta.ResourceRef) string {
return quoteIdent(ref.App.SchemaName) + "." + quoteIdent(ref.Entity.Table)
}
func pageBounds(ref *meta.ResourceRef, page, pageSize int) (int, int) {
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = 20
}
if ref.Resource.List != nil && ref.Resource.List.MaxPageSize > 0 && pageSize > ref.Resource.List.MaxPageSize {
pageSize = ref.Resource.List.MaxPageSize
}
return page, pageSize
}
func matchFilters(row map[string]any, filters map[string]string) bool {
for k, v := range filters {
if fmt.Sprint(row[k]) != v {
return false
}
}
return true
}
func sortRows(rows []map[string]any, sortBy string) {
if sortBy == "" {
return
}
desc := strings.HasPrefix(sortBy, "-")
field := strings.TrimPrefix(sortBy, "-")
sort.SliceStable(rows, func(i, j int) bool {
a, b := fmt.Sprint(rows[i][field]), fmt.Sprint(rows[j][field])
if desc {
return a > b
}
return a < b
})
}
func cloneRow(in map[string]any) map[string]any {
out := make(map[string]any, len(in))
for k, v := range in {
out[k] = v
}
return out
}
func toInt64(v any) int64 {
switch t := v.(type) {
case int64:
return t
case int:
return int64(t)
case float64:
return int64(t)
case string:
n, _ := strconv.ParseInt(t, 10, 64)
return n
default:
return 0
}
}
func normalizeDBValue(v any) any {
switch t := v.(type) {
case []byte:
return string(t)
default:
return t
}
}

View File

@@ -0,0 +1,15 @@
package crud
import (
"context"
"aijianzhan/platform/internal/meta"
)
type Engine interface {
List(ctx context.Context, ref *meta.ResourceRef, tenantID int64, page, pageSize int, filters map[string]string, sortBy string) (items []map[string]any, total int, err error)
Get(ctx context.Context, ref *meta.ResourceRef, tenantID int64, id string) (map[string]any, error)
Create(ctx context.Context, ref *meta.ResourceRef, tenantID, userID int64, body map[string]any) (map[string]any, error)
Update(ctx context.Context, ref *meta.ResourceRef, tenantID int64, id string, body map[string]any) (map[string]any, error)
Delete(ctx context.Context, ref *meta.ResourceRef, tenantID int64, id string) error
}

View File

@@ -0,0 +1,50 @@
package crud
import (
"strings"
"testing"
"aijianzhan/platform/internal/blueprint"
"aijianzhan/platform/internal/meta"
)
func TestValidateFiltersWhitelist(t *testing.T) {
ref := &meta.ResourceRef{
Resource: blueprint.APIResource{
List: &blueprint.ListOpt{AllowedFilters: []string{"name"}},
},
}
if err := validateFilters(ref, map[string]string{"hack": "1"}); err == nil {
t.Fatal("expected rejection")
}
if err := validateFilters(ref, map[string]string{"name": "a"}); err != nil {
t.Fatal(err)
}
}
func TestSanitizeRejectsUnknown(t *testing.T) {
n := false
entity := blueprint.Entity{
Name: "item", Table: "item", PrimaryKey: "id",
Fields: []blueprint.Field{
{Name: "id", Type: "bigint"},
{Name: "name", Type: "string", Nullable: &n},
},
}
_, err := sanitizeBody(entity, map[string]any{"name": "x", "drop": "1"}, false)
if err == nil || !strings.Contains(err.Error(), "unknown field") {
t.Fatalf("expected unknown field, got %v", err)
}
}
func TestQualifiedTable(t *testing.T) {
ref := &meta.ResourceRef{
App: &meta.AppRecord{SchemaName: "app_t1_demo"},
Entity: blueprint.Entity{Table: "item"},
}
got := qualifiedTable(ref)
want := `"app_t1_demo"."item"`
if got != want {
t.Fatalf("got %s want %s", got, want)
}
}

View File

@@ -0,0 +1,169 @@
package crud
import (
"context"
"fmt"
"sync"
"time"
"aijianzhan/platform/internal/meta"
"github.com/google/uuid"
)
// MemoryEngine 骨架默认引擎:不依赖外部 DB方便本地验证动态 CRUD。
type MemoryEngine struct {
mu sync.RWMutex
rows map[string][]map[string]any // schema.table
}
func NewMemoryEngine() *MemoryEngine {
return &MemoryEngine{rows: map[string][]map[string]any{}}
}
func tableKey(ref *meta.ResourceRef) string {
return ref.App.SchemaName + "." + ref.Entity.Table
}
func (e *MemoryEngine) List(ctx context.Context, ref *meta.ResourceRef, tenantID int64, page, pageSize int, filters map[string]string, sortBy string) ([]map[string]any, int, error) {
if err := ensureOp(ref, "list"); err != nil {
return nil, 0, err
}
if err := validateFilters(ref, filters); err != nil {
return nil, 0, err
}
if err := validateSort(ref, sortBy); err != nil {
return nil, 0, err
}
page, pageSize = pageBounds(ref, page, pageSize)
e.mu.RLock()
defer e.mu.RUnlock()
all := e.rows[tableKey(ref)]
filtered := make([]map[string]any, 0, len(all))
for _, row := range all {
if toInt64(row["tenant_id"]) != tenantID {
continue
}
if !matchOrgScope(row, RowScopeFrom(ctx).OrgUnitIDs) {
continue
}
if matchFilters(row, filters) {
filtered = append(filtered, cloneRow(row))
}
}
sortRows(filtered, sortBy)
total := len(filtered)
start := (page - 1) * pageSize
if start >= total {
return []map[string]any{}, total, nil
}
end := start + pageSize
if end > total {
end = total
}
return filtered[start:end], total, nil
}
func (e *MemoryEngine) Get(ctx context.Context, ref *meta.ResourceRef, tenantID int64, id string) (map[string]any, error) {
if err := ensureOp(ref, "get"); err != nil {
return nil, err
}
e.mu.RLock()
defer e.mu.RUnlock()
pk := ref.Entity.PrimaryKey
for _, row := range e.rows[tableKey(ref)] {
if toInt64(row["tenant_id"]) == tenantID && fmt.Sprint(row[pk]) == id {
if !matchOrgScope(row, RowScopeFrom(ctx).OrgUnitIDs) {
break
}
return cloneRow(row), nil
}
}
return nil, fmt.Errorf("not found")
}
func (e *MemoryEngine) Create(ctx context.Context, ref *meta.ResourceRef, tenantID, userID int64, body map[string]any) (map[string]any, error) {
if err := ensureOp(ref, "create"); err != nil {
return nil, err
}
row, err := sanitizeBody(ref.Entity, body, false)
if err != nil {
return nil, err
}
pk := ref.Entity.PrimaryKey
if _, ok := row[pk]; !ok {
if isAutoPK(ref.Entity) {
row[pk] = time.Now().UnixNano()
} else {
row[pk] = uuid.NewString()
}
}
now := time.Now().UTC().Format(time.RFC3339)
row["tenant_id"] = tenantID
row["created_by"] = userID
row["created_at"] = now
row["updated_at"] = now
if scope := RowScopeFrom(ctx); scope.WriteOrgUnit > 0 {
row["org_unit_id"] = scope.WriteOrgUnit
}
e.mu.Lock()
defer e.mu.Unlock()
k := tableKey(ref)
e.rows[k] = append(e.rows[k], row)
return cloneRow(row), nil
}
func (e *MemoryEngine) Update(_ context.Context, ref *meta.ResourceRef, tenantID int64, id string, body map[string]any) (map[string]any, error) {
if err := ensureOp(ref, "update"); err != nil {
return nil, err
}
patch, err := sanitizeBody(ref.Entity, body, true)
if err != nil {
return nil, err
}
e.mu.Lock()
defer e.mu.Unlock()
pk := ref.Entity.PrimaryKey
rows := e.rows[tableKey(ref)]
for i, row := range rows {
if toInt64(row["tenant_id"]) == tenantID && fmt.Sprint(row[pk]) == id {
for k, v := range patch {
if k == pk || k == "tenant_id" {
continue
}
row[k] = v
}
row["updated_at"] = time.Now().UTC().Format(time.RFC3339)
rows[i] = row
return cloneRow(row), nil
}
}
return nil, fmt.Errorf("not found")
}
func (e *MemoryEngine) Delete(_ context.Context, ref *meta.ResourceRef, tenantID int64, id string) error {
if err := ensureOp(ref, "delete"); err != nil {
return err
}
e.mu.Lock()
defer e.mu.Unlock()
pk := ref.Entity.PrimaryKey
k := tableKey(ref)
rows := e.rows[k]
out := rows[:0]
found := false
for _, row := range rows {
if toInt64(row["tenant_id"]) == tenantID && fmt.Sprint(row[pk]) == id {
found = true
continue
}
out = append(out, row)
}
if !found {
return fmt.Errorf("not found")
}
e.rows[k] = out
return nil
}

View File

@@ -0,0 +1,50 @@
package crud
import (
"database/sql"
"fmt"
"sync"
"aijianzhan/platform/internal/meta"
"aijianzhan/platform/internal/schema"
)
// DBPool 按 database_per_app 缓存连接。
type DBPool struct {
mu sync.Mutex
baseDSN string
primary *sql.DB
dbs map[string]*sql.DB
}
func NewDBPool(defaultDB *sql.DB, baseDSN string) *DBPool {
return &DBPool{primary: defaultDB, baseDSN: baseDSN, dbs: map[string]*sql.DB{}}
}
func (p *DBPool) ForApp(app *meta.AppRecord) (*sql.DB, error) {
if p == nil {
return nil, fmt.Errorf("db pool nil")
}
if app == nil || app.DatabaseName == "" {
return p.primary, nil
}
p.mu.Lock()
defer p.mu.Unlock()
if db, ok := p.dbs[app.DatabaseName]; ok {
return db, nil
}
dsn, err := schema.DSNForDatabase(p.baseDSN, app.DatabaseName)
if err != nil {
return nil, err
}
db, err := sql.Open("postgres", dsn)
if err != nil {
return nil, err
}
if err := db.Ping(); err != nil {
_ = db.Close()
return nil, fmt.Errorf("ping app db %s: %w", app.DatabaseName, err)
}
p.dbs[app.DatabaseName] = db
return db, nil
}

View File

@@ -0,0 +1,301 @@
package crud
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
"aijianzhan/platform/internal/meta"
"github.com/google/uuid"
)
// PostgresEngine dynamic row CRUD with tenant_id and optional org_unit_id scope.
type PostgresEngine struct {
DB *sql.DB
Pool *DBPool
}
func NewPostgresEngine(db *sql.DB, pool *DBPool) *PostgresEngine {
return &PostgresEngine{DB: db, Pool: pool}
}
func (e *PostgresEngine) conn(ref *meta.ResourceRef) (*sql.DB, error) {
if e.Pool != nil && ref != nil && ref.App != nil {
return e.Pool.ForApp(ref.App)
}
return e.DB, nil
}
func (e *PostgresEngine) List(ctx context.Context, ref *meta.ResourceRef, tenantID int64, page, pageSize int, filters map[string]string, sortBy string) ([]map[string]any, int, error) {
if err := ensureOp(ref, "list"); err != nil {
return nil, 0, err
}
if err := validateFilters(ref, filters); err != nil {
return nil, 0, err
}
if err := validateSort(ref, sortBy); err != nil {
return nil, 0, err
}
page, pageSize = pageBounds(ref, page, pageSize)
db, err := e.conn(ref)
if err != nil {
return nil, 0, err
}
where := []string{`tenant_id = $1`}
args := []any{tenantID}
argN := 2
scope := RowScopeFrom(ctx)
where, args, argN = appendOrgFilter(where, args, argN, scope.OrgUnitIDs)
for col, val := range filters {
where = append(where, fmt.Sprintf("%s = $%d", quoteIdent(col), argN))
args = append(args, val)
argN++
}
whereSQL := strings.Join(where, " AND ")
table := qualifiedTable(ref)
var total int
countSQL := fmt.Sprintf("SELECT COUNT(1) FROM %s WHERE %s", table, whereSQL)
if err := db.QueryRowContext(ctx, countSQL, args...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count: %w", err)
}
orderSQL := ""
if sortBy != "" {
desc := strings.HasPrefix(sortBy, "-")
field := strings.TrimPrefix(sortBy, "-")
dir := "ASC"
if desc {
dir = "DESC"
}
orderSQL = " ORDER BY " + quoteIdent(field) + " " + dir
}
offset := (page - 1) * pageSize
listArgs := append(append([]any{}, args...), pageSize, offset)
listSQL := fmt.Sprintf(
"SELECT * FROM %s WHERE %s%s LIMIT $%d OFFSET $%d",
table, whereSQL, orderSQL, argN, argN+1,
)
rows, err := db.QueryContext(ctx, listSQL, listArgs...)
if err != nil {
return nil, 0, fmt.Errorf("list: %w", err)
}
defer rows.Close()
items, err := scanRows(rows)
if err != nil {
return nil, 0, err
}
return items, total, nil
}
func (e *PostgresEngine) Get(ctx context.Context, ref *meta.ResourceRef, tenantID int64, id string) (map[string]any, error) {
if err := ensureOp(ref, "get"); err != nil {
return nil, err
}
db, err := e.conn(ref)
if err != nil {
return nil, err
}
pk := ref.Entity.PrimaryKey
where := []string{`tenant_id = $1`, fmt.Sprintf("%s = $2", quoteIdent(pk))}
args := []any{tenantID, id}
argN := 3
scope := RowScopeFrom(ctx)
where, args, argN = appendOrgFilter(where, args, argN, scope.OrgUnitIDs)
_ = argN
q := fmt.Sprintf(
"SELECT * FROM %s WHERE %s LIMIT 1",
qualifiedTable(ref), strings.Join(where, " AND "),
)
rows, err := db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
items, err := scanRows(rows)
if err != nil {
return nil, err
}
if len(items) == 0 {
return nil, fmt.Errorf("not found")
}
return items[0], nil
}
func (e *PostgresEngine) Create(ctx context.Context, ref *meta.ResourceRef, tenantID, userID int64, body map[string]any) (map[string]any, error) {
if err := ensureOp(ref, "create"); err != nil {
return nil, err
}
db, err := e.conn(ref)
if err != nil {
return nil, err
}
row, err := sanitizeBody(ref.Entity, body, false)
if err != nil {
return nil, err
}
pk := ref.Entity.PrimaryKey
autoPK := isAutoPK(ref.Entity)
if !autoPK {
if _, ok := row[pk]; !ok {
row[pk] = uuid.NewString()
}
} else {
delete(row, pk)
}
now := time.Now().UTC()
row["tenant_id"] = tenantID
row["created_by"] = userID
row["created_at"] = now
row["updated_at"] = now
if scope := RowScopeFrom(ctx); scope.WriteOrgUnit > 0 {
row["org_unit_id"] = scope.WriteOrgUnit
}
cols := make([]string, 0, len(row))
placeholders := make([]string, 0, len(row))
args := make([]any, 0, len(row))
i := 1
for k, v := range row {
cols = append(cols, quoteIdent(k))
placeholders = append(placeholders, fmt.Sprintf("$%d", i))
args = append(args, v)
i++
}
q := fmt.Sprintf(
"INSERT INTO %s (%s) VALUES (%s) RETURNING *",
qualifiedTable(ref),
strings.Join(cols, ", "),
strings.Join(placeholders, ", "),
)
rows, err := db.QueryContext(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("insert: %w", err)
}
defer rows.Close()
items, err := scanRows(rows)
if err != nil {
return nil, err
}
if len(items) == 0 {
return nil, fmt.Errorf("insert returned no row")
}
return items[0], nil
}
func (e *PostgresEngine) Update(ctx context.Context, ref *meta.ResourceRef, tenantID int64, id string, body map[string]any) (map[string]any, error) {
if err := ensureOp(ref, "update"); err != nil {
return nil, err
}
db, err := e.conn(ref)
if err != nil {
return nil, err
}
patch, err := sanitizeBody(ref.Entity, body, true)
if err != nil {
return nil, err
}
delete(patch, ref.Entity.PrimaryKey)
delete(patch, "tenant_id")
delete(patch, "org_unit_id")
if len(patch) == 0 {
return e.Get(ctx, ref, tenantID, id)
}
patch["updated_at"] = time.Now().UTC()
sets := make([]string, 0, len(patch))
args := make([]any, 0, len(patch)+2)
i := 1
for k, v := range patch {
sets = append(sets, fmt.Sprintf("%s = $%d", quoteIdent(k), i))
args = append(args, v)
i++
}
where := []string{fmt.Sprintf("tenant_id = $%d", i), fmt.Sprintf("%s = $%d", quoteIdent(ref.Entity.PrimaryKey), i+1)}
args = append(args, tenantID, id)
argN := i + 2
scope := RowScopeFrom(ctx)
where, args, argN = appendOrgFilter(where, args, argN, scope.OrgUnitIDs)
_ = argN
q := fmt.Sprintf(
"UPDATE %s SET %s WHERE %s RETURNING *",
qualifiedTable(ref),
strings.Join(sets, ", "),
strings.Join(where, " AND "),
)
rows, err := db.QueryContext(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("update: %w", err)
}
defer rows.Close()
items, err := scanRows(rows)
if err != nil {
return nil, err
}
if len(items) == 0 {
return nil, fmt.Errorf("not found")
}
return items[0], nil
}
func (e *PostgresEngine) Delete(ctx context.Context, ref *meta.ResourceRef, tenantID int64, id string) error {
if err := ensureOp(ref, "delete"); err != nil {
return err
}
db, err := e.conn(ref)
if err != nil {
return err
}
where := []string{`tenant_id = $1`, fmt.Sprintf("%s = $2", quoteIdent(ref.Entity.PrimaryKey))}
args := []any{tenantID, id}
argN := 3
scope := RowScopeFrom(ctx)
where, args, argN = appendOrgFilter(where, args, argN, scope.OrgUnitIDs)
_ = argN
q := fmt.Sprintf(
"DELETE FROM %s WHERE %s",
qualifiedTable(ref), strings.Join(where, " AND "),
)
res, err := db.ExecContext(ctx, q, args...)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("not found")
}
return nil
}
func scanRows(rows *sql.Rows) ([]map[string]any, error) {
cols, err := rows.Columns()
if err != nil {
return nil, err
}
out := make([]map[string]any, 0)
for rows.Next() {
vals := make([]any, len(cols))
ptrs := make([]any, len(cols))
for i := range vals {
ptrs[i] = &vals[i]
}
if err := rows.Scan(ptrs...); err != nil {
return nil, err
}
row := make(map[string]any, len(cols))
for i, c := range cols {
row[c] = normalizeDBValue(vals[i])
}
out = append(out, row)
}
return out, rows.Err()
}

View File

@@ -0,0 +1,58 @@
package crud
import (
"context"
"fmt"
"strings"
)
type scopeKey struct{}
// RowScope 行级组织范围OrgUnitIDs 非空时限制可读范围WriteOrgUnit 写入新建行。
type RowScope struct {
OrgUnitIDs []int64
WriteOrgUnit int64
}
func WithRowScope(ctx context.Context, s RowScope) context.Context {
return context.WithValue(ctx, scopeKey{}, s)
}
func RowScopeFrom(ctx context.Context) RowScope {
v, _ := ctx.Value(scopeKey{}).(RowScope)
return v
}
func appendOrgFilter(where []string, args []any, argN int, orgIDs []int64) ([]string, []any, int) {
if len(orgIDs) == 0 {
return where, args, argN
}
ph := make([]string, 0, len(orgIDs))
for _, id := range orgIDs {
ph = append(ph, fmt.Sprintf("$%d", argN))
args = append(args, id)
argN++
}
where = append(where, fmt.Sprintf("(org_unit_id IS NULL OR org_unit_id IN (%s))", strings.Join(ph, ",")))
return where, args, argN
}
func matchOrgScope(row map[string]any, orgIDs []int64) bool {
if len(orgIDs) == 0 {
return true
}
v, ok := row["org_unit_id"]
if !ok || v == nil {
return true
}
oid := toInt64(v)
if oid == 0 {
return true
}
for _, id := range orgIDs {
if id == oid {
return true
}
}
return false
}