chore: initial commit of ai site platform
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
188
platform/internal/crud/common.go
Normal file
188
platform/internal/crud/common.go
Normal 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
|
||||
}
|
||||
}
|
||||
15
platform/internal/crud/engine.go
Normal file
15
platform/internal/crud/engine.go
Normal 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
|
||||
}
|
||||
50
platform/internal/crud/helpers_test.go
Normal file
50
platform/internal/crud/helpers_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
169
platform/internal/crud/memory.go
Normal file
169
platform/internal/crud/memory.go
Normal 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
|
||||
}
|
||||
50
platform/internal/crud/pool.go
Normal file
50
platform/internal/crud/pool.go
Normal 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
|
||||
}
|
||||
301
platform/internal/crud/postgres.go
Normal file
301
platform/internal/crud/postgres.go
Normal 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()
|
||||
}
|
||||
58
platform/internal/crud/scope.go
Normal file
58
platform/internal/crud/scope.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user