chore: initial commit of ai site platform
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
216
platform/internal/meta/migrate.go
Normal file
216
platform/internal/meta/migrate.go
Normal 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()
|
||||
);
|
||||
|
||||
-- 公司路径 slug(www.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
|
||||
}
|
||||
206
platform/internal/meta/postgres.go
Normal file
206
platform/internal/meta/postgres.go
Normal 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"
|
||||
}
|
||||
126
platform/internal/meta/postgres_test.go
Normal file
126
platform/internal/meta/postgres_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
217
platform/internal/meta/store.go
Normal file
217
platform/internal/meta/store.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user