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,216 @@
package meta
import (
"context"
"database/sql"
"fmt"
)
const metaSchemaSQL = `
CREATE SCHEMA IF NOT EXISTS platform_meta;
CREATE TABLE IF NOT EXISTS platform_meta.tenants (
tenant_id BIGSERIAL PRIMARY KEY,
name VARCHAR(128) NOT NULL,
slug VARCHAR(64) NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS platform_meta.users (
user_id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT REFERENCES platform_meta.tenants(tenant_id),
username VARCHAR(64) NOT NULL,
password_hash TEXT NOT NULL,
display_name VARCHAR(128) NOT NULL DEFAULT '',
role VARCHAR(32) NOT NULL DEFAULT 'pending',
status VARCHAR(32) NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uq_users_username UNIQUE (username)
);
CREATE INDEX IF NOT EXISTS idx_users_tenant ON platform_meta.users (tenant_id);
-- 兼容旧库允许无租户pending
ALTER TABLE platform_meta.users ALTER COLUMN tenant_id DROP NOT NULL;
ALTER TABLE platform_meta.users ADD COLUMN IF NOT EXISTS status VARCHAR(32) NOT NULL DEFAULT 'active';
CREATE TABLE IF NOT EXISTS platform_meta.tenant_apps (
app_id UUID PRIMARY KEY,
tenant_id BIGINT NOT NULL,
slug VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
schema_name VARCHAR(64) NOT NULL,
engine VARCHAR(32) NOT NULL,
status VARCHAR(32) NOT NULL,
blueprint_json JSONB NOT NULL,
ddl_json JSONB NOT NULL DEFAULT '[]'::jsonb,
endpoints_json JSONB NOT NULL DEFAULT '[]'::jsonb,
error_msg TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uq_tenant_apps_tenant_slug UNIQUE (tenant_id, slug)
);
CREATE INDEX IF NOT EXISTS idx_tenant_apps_tenant_status
ON platform_meta.tenant_apps (tenant_id, status);
ALTER TABLE platform_meta.tenant_apps
ADD COLUMN IF NOT EXISTS database_name VARCHAR(64) NOT NULL DEFAULT '';
CREATE TABLE IF NOT EXISTS platform_meta.audit_logs (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
action VARCHAR(64) NOT NULL,
detail TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 智能体服务账号(机器身份,供宿主自动登录)
CREATE TABLE IF NOT EXISTS platform_meta.agent_accounts (
agent_id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES platform_meta.tenants(tenant_id),
name VARCHAR(128) NOT NULL,
client_id VARCHAR(64) NOT NULL,
client_secret_hash TEXT NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'active',
created_by BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_token_at TIMESTAMPTZ,
CONSTRAINT uq_agent_client_id UNIQUE (client_id)
);
CREATE INDEX IF NOT EXISTS idx_agent_accounts_tenant
ON platform_meta.agent_accounts (tenant_id);
ALTER TABLE platform_meta.agent_accounts
ADD COLUMN IF NOT EXISTS host_key VARCHAR(128) NOT NULL DEFAULT '';
CREATE UNIQUE INDEX IF NOT EXISTS uq_agent_tenant_host_key
ON platform_meta.agent_accounts (tenant_id, host_key)
WHERE host_key <> '';
CREATE TABLE IF NOT EXISTS platform_meta.agent_permissions (
agent_id BIGINT NOT NULL REFERENCES platform_meta.agent_accounts(agent_id) ON DELETE CASCADE,
perm VARCHAR(64) NOT NULL,
PRIMARY KEY (agent_id, perm)
);
CREATE TABLE IF NOT EXISTS platform_meta.agent_app_grants (
agent_id BIGINT NOT NULL REFERENCES platform_meta.agent_accounts(agent_id) ON DELETE CASCADE,
slug VARCHAR(64) NOT NULL,
PRIMARY KEY (agent_id, slug)
);
-- 可编辑角色(租户级)
CREATE TABLE IF NOT EXISTS platform_meta.roles (
role_id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES platform_meta.tenants(tenant_id),
code VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
description TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uq_roles_tenant_code UNIQUE (tenant_id, code)
);
CREATE INDEX IF NOT EXISTS idx_roles_tenant ON platform_meta.roles (tenant_id);
CREATE TABLE IF NOT EXISTS platform_meta.role_permissions (
role_id BIGINT NOT NULL REFERENCES platform_meta.roles(role_id) ON DELETE CASCADE,
perm VARCHAR(64) NOT NULL,
PRIMARY KEY (role_id, perm)
);
ALTER TABLE platform_meta.agent_accounts
ADD COLUMN IF NOT EXISTS role_id BIGINT;
-- 租户邀请码pending 用户凭码加入已有公司
CREATE TABLE IF NOT EXISTS platform_meta.tenant_invites (
invite_id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES platform_meta.tenants(tenant_id) ON DELETE CASCADE,
code VARCHAR(64) NOT NULL,
role VARCHAR(32) NOT NULL DEFAULT 'editor',
created_by BIGINT NOT NULL DEFAULT 0,
max_uses INT NOT NULL DEFAULT 1,
used_count INT NOT NULL DEFAULT 0,
status VARCHAR(32) NOT NULL DEFAULT 'active',
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uq_tenant_invites_code UNIQUE (code)
);
CREATE INDEX IF NOT EXISTS idx_tenant_invites_tenant
ON platform_meta.tenant_invites (tenant_id);
-- 租户内多级组织(默认最多 5 级)
CREATE TABLE IF NOT EXISTS platform_meta.org_units (
org_unit_id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES platform_meta.tenants(tenant_id) ON DELETE CASCADE,
parent_id BIGINT REFERENCES platform_meta.org_units(org_unit_id) ON DELETE CASCADE,
name VARCHAR(128) NOT NULL,
code VARCHAR(64) NOT NULL DEFAULT '',
depth INT NOT NULL DEFAULT 1,
path VARCHAR(512) NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_org_units_tenant_parent
ON platform_meta.org_units (tenant_id, parent_id);
CREATE INDEX IF NOT EXISTS idx_org_units_tenant_path
ON platform_meta.org_units (tenant_id, path);
CREATE UNIQUE INDEX IF NOT EXISTS uq_org_units_tenant_code
ON platform_meta.org_units (tenant_id, code)
WHERE code <> '';
ALTER TABLE platform_meta.users
ADD COLUMN IF NOT EXISTS org_unit_id BIGINT;
ALTER TABLE platform_meta.tenant_invites
ADD COLUMN IF NOT EXISTS org_unit_id BIGINT NOT NULL DEFAULT 0;
ALTER TABLE platform_meta.users
ALTER COLUMN role TYPE VARCHAR(64);
CREATE TABLE IF NOT EXISTS platform_meta.tenant_permissions (
tenant_id BIGINT NOT NULL REFERENCES platform_meta.tenants(tenant_id) ON DELETE CASCADE,
perm VARCHAR(64) NOT NULL,
PRIMARY KEY (tenant_id, perm)
);
CREATE TABLE IF NOT EXISTS platform_meta.tenant_entitlement_state (
tenant_id BIGINT PRIMARY KEY REFERENCES platform_meta.tenants(tenant_id) ON DELETE CASCADE,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 公司路径 slugwww.example.com/{slug}/);旧库补列
ALTER TABLE platform_meta.tenants ADD COLUMN IF NOT EXISTS slug VARCHAR(64) NOT NULL DEFAULT '';
CREATE UNIQUE INDEX IF NOT EXISTS uq_tenants_slug ON platform_meta.tenants (slug) WHERE slug <> '';
-- 手机号(可绑定;非空时全局唯一,可用于登录)
ALTER TABLE platform_meta.users ADD COLUMN IF NOT EXISTS phone VARCHAR(20) NOT NULL DEFAULT '';
CREATE UNIQUE INDEX IF NOT EXISTS uq_users_phone ON platform_meta.users (phone) WHERE phone <> '';
-- 已绑手机时可禁用用户名登录(仅手机号+密码/短信)
ALTER TABLE platform_meta.users ADD COLUMN IF NOT EXISTS username_login_disabled BOOLEAN NOT NULL DEFAULT false;
-- 授权租约已消费 id删 leases 目录后仍能拦截旧延期包)
CREATE TABLE IF NOT EXISTS platform_meta.license_consumed (
lease_id VARCHAR(64) PRIMARY KEY,
active BOOLEAN NOT NULL DEFAULT false,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_license_consumed_active ON platform_meta.license_consumed (active) WHERE active;
`
// EnsureSchema 创建平台元数据表(幂等)。
func EnsureSchema(ctx context.Context, db *sql.DB) error {
if db == nil {
return fmt.Errorf("db is nil")
}
if _, err := db.ExecContext(ctx, metaSchemaSQL); err != nil {
return fmt.Errorf("meta migrate: %w", err)
}
return nil
}

View File

@@ -0,0 +1,206 @@
package meta
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
"aijianzhan/platform/internal/blueprint"
"github.com/lib/pq"
)
type PostgresStore struct {
DB *sql.DB
}
func NewPostgresStore(db *sql.DB) *PostgresStore {
return &PostgresStore{DB: db}
}
func (s *PostgresStore) GetBySlug(ctx context.Context, tenantID int64, slug string) (*AppRecord, error) {
const q = `
SELECT app_id, tenant_id, slug, name, schema_name, COALESCE(database_name,''), engine, status,
blueprint_json, ddl_json, endpoints_json, error_msg, created_at, updated_at
FROM platform_meta.tenant_apps
WHERE tenant_id = $1 AND slug = $2
LIMIT 1`
row := s.DB.QueryRowContext(ctx, q, tenantID, slug)
rec, err := scanApp(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("app not found")
}
return rec, err
}
func (s *PostgresStore) FindPublishedBySlug(ctx context.Context, slug string) (*AppRecord, error) {
const q = `
SELECT app_id, tenant_id, slug, name, schema_name, COALESCE(database_name,''), engine, status,
blueprint_json, ddl_json, endpoints_json, error_msg, created_at, updated_at
FROM platform_meta.tenant_apps
WHERE slug = $1 AND status = $2
ORDER BY updated_at DESC
LIMIT 1`
row := s.DB.QueryRowContext(ctx, q, slug, string(StatusPublished))
rec, err := scanApp(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("app not found")
}
return rec, err
}
func (s *PostgresStore) ListByTenant(ctx context.Context, tenantID int64) ([]AppSummary, error) {
const q = `
SELECT app_id, tenant_id, slug, name, schema_name, COALESCE(database_name,''), engine, status,
blueprint_json, ddl_json, endpoints_json, error_msg, created_at, updated_at
FROM platform_meta.tenant_apps
WHERE tenant_id = $1
ORDER BY updated_at DESC`
rows, err := s.DB.QueryContext(ctx, q, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]AppSummary, 0)
for rows.Next() {
rec, err := scanApp(rows)
if err != nil {
return nil, err
}
out = append(out, summarizeApp(rec))
}
return out, rows.Err()
}
func (s *PostgresStore) Save(ctx context.Context, app *AppRecord) error {
if app == nil {
return fmt.Errorf("app is nil")
}
if app.Blueprint == nil {
return fmt.Errorf("blueprint is nil")
}
bpRaw, err := json.Marshal(app.Blueprint)
if err != nil {
return err
}
ddlRaw, err := json.Marshal(app.DDL)
if err != nil {
return err
}
epRaw, err := json.Marshal(app.Endpoints)
if err != nil {
return err
}
if app.CreatedAt.IsZero() {
app.CreatedAt = time.Now().UTC()
}
app.UpdatedAt = time.Now().UTC()
const q = `
INSERT INTO platform_meta.tenant_apps (
app_id, tenant_id, slug, name, schema_name, database_name, engine, status,
blueprint_json, ddl_json, endpoints_json, error_msg, created_at, updated_at
) VALUES (
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14
)
ON CONFLICT (tenant_id, slug) DO UPDATE SET
name = EXCLUDED.name,
schema_name = EXCLUDED.schema_name,
database_name = EXCLUDED.database_name,
engine = EXCLUDED.engine,
status = EXCLUDED.status,
blueprint_json = EXCLUDED.blueprint_json,
ddl_json = EXCLUDED.ddl_json,
endpoints_json = EXCLUDED.endpoints_json,
error_msg = EXCLUDED.error_msg,
updated_at = EXCLUDED.updated_at,
app_id = platform_meta.tenant_apps.app_id,
created_at = platform_meta.tenant_apps.created_at
RETURNING app_id, created_at, updated_at`
err = s.DB.QueryRowContext(ctx, q,
app.AppID,
app.TenantID,
app.Slug,
app.Name,
app.SchemaName,
app.DatabaseName,
app.Engine,
string(app.Status),
bpRaw,
ddlRaw,
epRaw,
app.Error,
app.CreatedAt,
app.UpdatedAt,
).Scan(&app.AppID, &app.CreatedAt, &app.UpdatedAt)
if err != nil {
return fmt.Errorf("meta save: %w", err)
}
return nil
}
func (s *PostgresStore) ResolveResource(ctx context.Context, tenantID int64, slug, resource string) (*ResourceRef, error) {
app, err := s.GetBySlug(ctx, tenantID, slug)
if err != nil {
return nil, err
}
return resolveResource(app, resource)
}
type scannable interface {
Scan(dest ...any) error
}
func scanApp(row scannable) (*AppRecord, error) {
var (
rec AppRecord
status string
bpRaw, ddlRaw, epRaw []byte
errMsg sql.NullString
)
err := row.Scan(
&rec.AppID,
&rec.TenantID,
&rec.Slug,
&rec.Name,
&rec.SchemaName,
&rec.DatabaseName,
&rec.Engine,
&status,
&bpRaw,
&ddlRaw,
&epRaw,
&errMsg,
&rec.CreatedAt,
&rec.UpdatedAt,
)
if err != nil {
return nil, err
}
rec.Status = AppStatus(status)
if errMsg.Valid {
rec.Error = errMsg.String
}
var bp blueprint.Blueprint
if err := json.Unmarshal(bpRaw, &bp); err != nil {
return nil, fmt.Errorf("blueprint json: %w", err)
}
rec.Blueprint = &bp
if len(ddlRaw) > 0 {
_ = json.Unmarshal(ddlRaw, &rec.DDL)
}
if len(epRaw) > 0 {
_ = json.Unmarshal(epRaw, &rec.Endpoints)
}
return &rec, nil
}
// IsUniqueViolation 便于上层识别冲突(预留)。
func IsUniqueViolation(err error) bool {
var pqErr *pq.Error
return errors.As(err, &pqErr) && pqErr.Code == "23505"
}

View File

@@ -0,0 +1,126 @@
package meta_test
import (
"context"
"database/sql"
"os"
"testing"
"time"
"aijianzhan/platform/internal/blueprint"
"aijianzhan/platform/internal/meta"
_ "github.com/lib/pq"
)
func testDSN(t *testing.T) string {
t.Helper()
dsn := os.Getenv("PLATFORM_TEST_DSN")
if dsn == "" {
dsn = "postgres://platform:platform@127.0.0.1:5432/platform?sslmode=disable"
}
return dsn
}
func TestPostgresMetaPersist(t *testing.T) {
db, err := sql.Open("postgres", testDSN(t))
if err != nil {
t.Skip(err)
}
if err := db.Ping(); err != nil {
t.Skip(err)
}
defer db.Close()
ctx := context.Background()
if err := meta.EnsureSchema(ctx, db); err != nil {
t.Fatal(err)
}
store := meta.NewPostgresStore(db)
slug := "meta_persist_demo"
tenantID := int64(99001)
_, _ = db.ExecContext(ctx, `DELETE FROM platform_meta.tenant_apps WHERE tenant_id=$1 AND slug=$2`, tenantID, slug)
n := false
bp := &blueprint.Blueprint{
Version: "1.0",
Meta: blueprint.Meta{Name: "持久化测试", Slug: slug, Locale: "zh-CN"},
Storage: blueprint.Storage{Mode: "schema_per_app", Engine: "postgres", SchemaName: "app_t99001_meta_persist_demo"},
Entities: []blueprint.Entity{{
Name: "item", Table: "item", Label: "Item", PrimaryKey: "id",
Fields: []blueprint.Field{
{Name: "id", Type: "bigint", Label: "ID", Nullable: &n},
{Name: "title", Type: "string", Label: "标题", Nullable: &n, MaxLength: 64},
},
}},
Apis: blueprint.Apis{
BasePath: "/api/v1/apps/" + slug,
Resources: []blueprint.APIResource{{
Entity: "item", Path: "/items",
Operations: []string{"list", "get", "create"},
}},
},
Pages: []blueprint.Page{{
ID: "list", Title: "列表", Route: "/items", Type: "list", Entity: "item",
}},
Security: blueprint.Security{
Visibility: "private",
Roles: []blueprint.Role{{
Name: "管理员", Permissions: []string{"读取模块", "写入模块", "发布模块", "新增数据", "查询数据"},
}},
RowPolicies: []blueprint.RowPolicy{{Entity: "item", Rule: "tenant_isolated"}},
},
}
rec := &meta.AppRecord{
AppID: "11111111-1111-1111-1111-111111111111",
TenantID: tenantID,
Slug: slug,
Name: bp.Meta.Name,
SchemaName: bp.Storage.SchemaName,
Engine: "postgres",
Status: meta.StatusPublished,
Blueprint: bp,
DDL: []string{"SELECT 1"},
Endpoints: []string{"GET /api/v1/apps/" + slug + "/items"},
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
if err := store.Save(ctx, rec); err != nil {
t.Fatal(err)
}
got, err := store.GetBySlug(ctx, tenantID, slug)
if err != nil {
t.Fatal(err)
}
if got.Status != meta.StatusPublished || got.Blueprint == nil || got.Blueprint.Meta.Name != "持久化测试" {
t.Fatalf("unexpected record: %+v", got)
}
ref, err := store.ResolveResource(ctx, tenantID, slug, "items")
if err != nil {
t.Fatal(err)
}
if ref.Entity.Name != "item" {
t.Fatalf("entity=%s", ref.Entity.Name)
}
// 二次 Save 应保留 app_id
rec.Name = "持久化测试-更新"
rec.AppID = "22222222-2222-2222-2222-222222222222"
if err := store.Save(ctx, rec); err != nil {
t.Fatal(err)
}
got2, err := store.GetBySlug(ctx, tenantID, slug)
if err != nil {
t.Fatal(err)
}
if got2.AppID != "11111111-1111-1111-1111-111111111111" {
t.Fatalf("app_id should be preserved, got %s", got2.AppID)
}
if got2.Name != "持久化测试-更新" {
t.Fatalf("name not updated: %s", got2.Name)
}
}

View File

@@ -0,0 +1,217 @@
package meta
import (
"context"
"encoding/json"
"fmt"
"sync"
"time"
"aijianzhan/platform/internal/blueprint"
)
type AppStatus string
const (
StatusDraft AppStatus = "draft" // 在建:已生成蓝图、尚未成功发布
StatusValidating AppStatus = "validating"
StatusProvisioning AppStatus = "provisioning"
StatusPublished AppStatus = "published"
StatusFailed AppStatus = "failed"
)
// StatusLabelCN 管理端展示用中文状态。
func StatusLabelCN(s AppStatus) string {
switch s {
case StatusPublished:
return "已发布"
case StatusFailed:
return "失败"
case StatusDraft, StatusValidating, StatusProvisioning:
return "在建"
default:
if s == "" {
return "未知"
}
return string(s)
}
}
// IsBuilding 是否视为在建(含草稿与发布中)。
func IsBuilding(s AppStatus) bool {
return s == StatusDraft || s == StatusValidating || s == StatusProvisioning
}
type AppRecord struct {
AppID string `json:"app_id"`
TenantID int64 `json:"tenant_id"`
Slug string `json:"slug"`
Name string `json:"name"`
SchemaName string `json:"schema_name"`
DatabaseName string `json:"database_name,omitempty"`
Engine string `json:"engine"`
Status AppStatus `json:"status"`
Blueprint *blueprint.Blueprint `json:"blueprint"`
DDL []string `json:"ddl,omitempty"`
Endpoints []string `json:"endpoints,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Error string `json:"error,omitempty"`
}
type ResourceRef struct {
App *AppRecord
Entity blueprint.Entity
Resource blueprint.APIResource
}
type AppSummary struct {
AppID string `json:"app_id"`
Slug string `json:"slug"`
Name string `json:"name"`
Status AppStatus `json:"status"`
SchemaName string `json:"schema_name,omitempty"`
PageCount int `json:"page_count"`
EntityCount int `json:"entity_count"`
UpdatedAt time.Time `json:"updated_at"`
CreatedAt time.Time `json:"created_at"`
}
type Store interface {
GetBySlug(ctx context.Context, tenantID int64, slug string) (*AppRecord, error)
FindPublishedBySlug(ctx context.Context, slug string) (*AppRecord, error)
ListByTenant(ctx context.Context, tenantID int64) ([]AppSummary, error)
Save(ctx context.Context, app *AppRecord) error
ResolveResource(ctx context.Context, tenantID int64, slug, resource string) (*ResourceRef, error)
}
type MemoryStore struct {
mu sync.RWMutex
apps map[string]*AppRecord // key: tenantID:slug
}
func NewMemoryStore() *MemoryStore {
return &MemoryStore{apps: map[string]*AppRecord{}}
}
func key(tenantID int64, slug string) string {
return fmt.Sprintf("%d:%s", tenantID, slug)
}
func (s *MemoryStore) GetBySlug(_ context.Context, tenantID int64, slug string) (*AppRecord, error) {
s.mu.RLock()
defer s.mu.RUnlock()
app, ok := s.apps[key(tenantID, slug)]
if !ok {
return nil, fmt.Errorf("app not found")
}
return cloneApp(app), nil
}
func (s *MemoryStore) FindPublishedBySlug(_ context.Context, slug string) (*AppRecord, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var best *AppRecord
for _, app := range s.apps {
if app == nil || app.Slug != slug || app.Status != StatusPublished {
continue
}
if best == nil || app.UpdatedAt.After(best.UpdatedAt) {
best = app
}
}
if best == nil {
return nil, fmt.Errorf("app not found")
}
return cloneApp(best), nil
}
func (s *MemoryStore) ListByTenant(_ context.Context, tenantID int64) ([]AppSummary, error) {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]AppSummary, 0)
for _, app := range s.apps {
if app == nil || app.TenantID != tenantID {
continue
}
out = append(out, summarizeApp(app))
}
return out, nil
}
func summarizeApp(app *AppRecord) AppSummary {
sum := AppSummary{
AppID: app.AppID,
Slug: app.Slug,
Name: app.Name,
Status: app.Status,
SchemaName: app.SchemaName,
UpdatedAt: app.UpdatedAt,
CreatedAt: app.CreatedAt,
}
if app.Blueprint != nil {
sum.PageCount = len(app.Blueprint.Pages)
sum.EntityCount = len(app.Blueprint.Entities)
}
return sum
}
func (s *MemoryStore) Save(_ context.Context, app *AppRecord) error {
s.mu.Lock()
defer s.mu.Unlock()
s.apps[key(app.TenantID, app.Slug)] = cloneApp(app)
return nil
}
func (s *MemoryStore) ResolveResource(ctx context.Context, tenantID int64, slug, resource string) (*ResourceRef, error) {
app, err := s.GetBySlug(ctx, tenantID, slug)
if err != nil {
return nil, err
}
return resolveResource(app, resource)
}
func resolveResource(app *AppRecord, resource string) (*ResourceRef, error) {
if app.Status != StatusPublished {
return nil, fmt.Errorf("app not published")
}
if app.Blueprint == nil {
return nil, fmt.Errorf("blueprint missing")
}
for _, r := range app.Blueprint.Apis.Resources {
path := r.Path
if len(path) > 0 && path[0] == '/' {
path = path[1:]
}
if path != resource {
continue
}
for _, e := range app.Blueprint.Entities {
if e.Name == r.Entity {
return &ResourceRef{App: app, Entity: e, Resource: r}, nil
}
}
return nil, fmt.Errorf("entity missing for resource")
}
return nil, fmt.Errorf("resource not found")
}
func cloneApp(app *AppRecord) *AppRecord {
if app == nil {
return nil
}
cp := *app
if app.Blueprint != nil {
raw, _ := json.Marshal(app.Blueprint)
var bp blueprint.Blueprint
_ = json.Unmarshal(raw, &bp)
cp.Blueprint = &bp
}
if app.DDL != nil {
cp.DDL = append([]string{}, app.DDL...)
}
if app.Endpoints != nil {
cp.Endpoints = append([]string{}, app.Endpoints...)
}
return &cp
}