chore: initial commit of ai site platform
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
470
platform/internal/logic/applogic/publish.go
Normal file
470
platform/internal/logic/applogic/publish.go
Normal file
@@ -0,0 +1,470 @@
|
||||
package applogic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"aijianzhan/platform/internal/audit"
|
||||
"aijianzhan/platform/internal/authx"
|
||||
"aijianzhan/platform/internal/agentcap"
|
||||
"aijianzhan/platform/internal/blueprint"
|
||||
"aijianzhan/platform/internal/meta"
|
||||
"aijianzhan/platform/internal/schema"
|
||||
"aijianzhan/platform/internal/svc"
|
||||
"aijianzhan/platform/internal/types"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type PublishLogic struct {
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewPublishLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PublishLogic {
|
||||
return &PublishLogic{ctx: ctx, svcCtx: svcCtx}
|
||||
}
|
||||
|
||||
func (l *PublishLogic) ListApps() (*types.AppListResp, error) {
|
||||
tenantID := authx.TenantID(l.ctx)
|
||||
items, err := l.svcCtx.Meta.ListByTenant(l.ctx, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
allowed := map[string]struct{}{}
|
||||
filter := false
|
||||
scope := "all"
|
||||
// 管理账号:本租户全部模块(含在建)
|
||||
// 智能体:app_slugs 为空或含 * → 不限制;否则仅白名单
|
||||
if authx.Role(l.ctx) == authx.RoleAgent && l.svcCtx.Agents != nil {
|
||||
acc, err := l.svcCtx.Agents.Get(l.ctx, tenantID, authx.AgentID(l.ctx))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
open := len(acc.AppSlugs) == 0
|
||||
for _, s := range acc.AppSlugs {
|
||||
if s == "*" {
|
||||
open = true
|
||||
}
|
||||
allowed[s] = struct{}{}
|
||||
}
|
||||
if open {
|
||||
scope = "open"
|
||||
filter = false
|
||||
} else {
|
||||
scope = "granted"
|
||||
filter = true
|
||||
}
|
||||
}
|
||||
out := make([]types.AppListItem, 0, len(items))
|
||||
for _, it := range items {
|
||||
if filter {
|
||||
if _, ok := allowed[it.Slug]; !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
st := it.Status
|
||||
out = append(out, types.AppListItem{
|
||||
AppID: it.AppID,
|
||||
Slug: it.Slug,
|
||||
Name: it.Name,
|
||||
Status: string(st),
|
||||
StatusLabel: meta.StatusLabelCN(st),
|
||||
Building: meta.IsBuilding(st),
|
||||
SchemaName: it.SchemaName,
|
||||
PageCount: it.PageCount,
|
||||
EntityCount: it.EntityCount,
|
||||
UpdatedAt: it.UpdatedAt.UTC().Format(time.RFC3339),
|
||||
CreatedAt: it.CreatedAt.UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
return &types.AppListResp{Items: out, Scope: scope}, nil
|
||||
}
|
||||
|
||||
// SaveDraft 登记/更新「在建」模块蓝图(不跑 DDL)。管理账号可用来看到生成中尚未发布的模块。
|
||||
func (l *PublishLogic) SaveDraft(slug string, req *types.DraftReq) (*types.AppListItem, error) {
|
||||
tenantID := authx.TenantID(l.ctx)
|
||||
bp, err := blueprint.Parse(req.Blueprint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
slug = blueprint.NormalizeIdent(slug)
|
||||
bp.Meta.Slug = blueprint.NormalizeIdent(bp.Meta.Slug)
|
||||
if bp.Meta.Slug == "" {
|
||||
bp.Meta.Slug = slug
|
||||
}
|
||||
if slug == "" {
|
||||
slug = bp.Meta.Slug
|
||||
}
|
||||
bp.Meta.Slug = slug
|
||||
if bp.Apis.BasePath == "" || strings.Contains(bp.Apis.BasePath, "-") {
|
||||
bp.Apis.BasePath = "/api/v1/apps/" + slug
|
||||
}
|
||||
if bp.Meta.Name == "" {
|
||||
bp.Meta.Name = slug
|
||||
}
|
||||
if bp.Version == "" {
|
||||
bp.Version = "1.0"
|
||||
}
|
||||
if bp.Storage.Mode == "" {
|
||||
bp.Storage.Mode = "schema_per_app"
|
||||
}
|
||||
if bp.Storage.Engine == "" {
|
||||
bp.Storage.Engine = "postgres"
|
||||
}
|
||||
// 草稿允许尚未完全合法;尽量校验,失败则仍以宽松方式保存关键字段
|
||||
_ = bp.Validate(slug)
|
||||
|
||||
now := time.Now().UTC()
|
||||
appID := uuid.NewString()
|
||||
schemaName := bp.AssignSchemaName(tenantID)
|
||||
if existing, err := l.svcCtx.Meta.GetBySlug(l.ctx, tenantID, slug); err == nil && existing != nil {
|
||||
if existing.Status == meta.StatusPublished {
|
||||
return nil, fmt.Errorf("module already published: %s (use publish to update pages)", slug)
|
||||
}
|
||||
appID = existing.AppID
|
||||
now = existing.CreatedAt
|
||||
if existing.SchemaName != "" {
|
||||
schemaName = existing.SchemaName
|
||||
bp.Storage.SchemaName = existing.SchemaName
|
||||
}
|
||||
}
|
||||
rec := &meta.AppRecord{
|
||||
AppID: appID,
|
||||
TenantID: tenantID,
|
||||
Slug: slug,
|
||||
Name: bp.Meta.Name,
|
||||
SchemaName: schemaName,
|
||||
Engine: bp.Storage.Engine,
|
||||
Status: meta.StatusDraft,
|
||||
Blueprint: bp,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
if err := l.svcCtx.Meta.Save(l.ctx, rec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = l.audit("draft.save", audit.DetailJSON(map[string]any{"slug": slug, "user_id": authx.UserID(l.ctx)}))
|
||||
return &types.AppListItem{
|
||||
AppID: rec.AppID,
|
||||
Slug: slug,
|
||||
Name: rec.Name,
|
||||
Status: string(meta.StatusDraft),
|
||||
StatusLabel: meta.StatusLabelCN(meta.StatusDraft),
|
||||
Building: true,
|
||||
SchemaName: schemaName,
|
||||
PageCount: len(bp.Pages),
|
||||
EntityCount: len(bp.Entities),
|
||||
UpdatedAt: rec.UpdatedAt.UTC().Format(time.RFC3339),
|
||||
CreatedAt: rec.CreatedAt.UTC().Format(time.RFC3339),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (l *PublishLogic) Publish(slug string, req *types.PublishReq) (*types.PublishResp, error) {
|
||||
tenantID := authx.TenantID(l.ctx)
|
||||
userID := authx.UserID(l.ctx)
|
||||
|
||||
incoming, err := blueprint.Parse(req.Blueprint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
slug = blueprint.NormalizeIdent(slug)
|
||||
incoming.Meta.Slug = blueprint.NormalizeIdent(incoming.Meta.Slug)
|
||||
if incoming.Meta.Slug == "" {
|
||||
incoming.Meta.Slug = slug
|
||||
}
|
||||
if slug == "" {
|
||||
slug = incoming.Meta.Slug
|
||||
}
|
||||
// 路径与蓝图不一致时以路径 slug 为准(先选应用再发布)
|
||||
if slug != "" && slug != incoming.Meta.Slug {
|
||||
incoming.Meta.Slug = slug
|
||||
}
|
||||
if incoming.Apis.BasePath == "" || strings.Contains(incoming.Apis.BasePath, "-") {
|
||||
incoming.Apis.BasePath = "/api/v1/apps/" + slug
|
||||
}
|
||||
|
||||
mode := strings.ToLower(strings.TrimSpace(req.Mode))
|
||||
if mode == "" {
|
||||
mode = "auto"
|
||||
}
|
||||
if mode == "merge" {
|
||||
mode = "add_pages" // 兼容旧别名
|
||||
}
|
||||
switch mode {
|
||||
case "auto", "add_pages", "create", "replace":
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported publish mode: %s (use auto|add_pages|create|replace)", mode)
|
||||
}
|
||||
|
||||
existing, existErr := l.svcCtx.Meta.GetBySlug(l.ctx, tenantID, slug)
|
||||
exists := existErr == nil && existing != nil
|
||||
|
||||
publishMode := "created"
|
||||
var mergeRes *blueprint.MergeResult
|
||||
var bp *blueprint.Blueprint
|
||||
|
||||
switch {
|
||||
case mode == "create":
|
||||
if exists {
|
||||
return nil, fmt.Errorf("app already exists: %s (select it and publish with mode=add_pages to add newly generated pages)", slug)
|
||||
}
|
||||
bp = incoming
|
||||
publishMode = "created"
|
||||
case mode == "add_pages":
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("app not found: %s (create it first, or use mode=create/auto)", slug)
|
||||
}
|
||||
if existing.Blueprint == nil {
|
||||
return nil, fmt.Errorf("existing app has no blueprint")
|
||||
}
|
||||
bp = existing.Blueprint
|
||||
bp.Meta.Slug = slug
|
||||
bp.Apis.BasePath = "/api/v1/apps/" + slug
|
||||
mergeRes, err = blueprint.MergeInto(bp, incoming)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
publishMode = "pages_added"
|
||||
case mode == "replace":
|
||||
bp = incoming
|
||||
if exists {
|
||||
publishMode = "replaced"
|
||||
} else {
|
||||
publishMode = "created"
|
||||
}
|
||||
default: // auto
|
||||
if exists {
|
||||
if existing.Blueprint == nil {
|
||||
return nil, fmt.Errorf("existing app has no blueprint")
|
||||
}
|
||||
bp = existing.Blueprint
|
||||
bp.Meta.Slug = slug
|
||||
bp.Apis.BasePath = "/api/v1/apps/" + slug
|
||||
mergeRes, err = blueprint.MergeInto(bp, incoming)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
publishMode = "pages_added"
|
||||
} else {
|
||||
bp = incoming
|
||||
publishMode = "created"
|
||||
}
|
||||
}
|
||||
|
||||
if err := bp.Validate(slug); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
schemaName := bp.AssignSchemaName(tenantID)
|
||||
dbName := bp.AssignDatabaseName(tenantID)
|
||||
ddl, err := schema.BuildPostgresDDL(bp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
appID := uuid.NewString()
|
||||
now := time.Now().UTC()
|
||||
republish := false
|
||||
if exists {
|
||||
appID = existing.AppID
|
||||
now = existing.CreatedAt
|
||||
republish = existing.Status == meta.StatusPublished
|
||||
if existing.SchemaName != "" {
|
||||
schemaName = existing.SchemaName
|
||||
bp.Storage.SchemaName = existing.SchemaName
|
||||
}
|
||||
if existing.DatabaseName != "" {
|
||||
dbName = existing.DatabaseName
|
||||
}
|
||||
ddl, err = schema.BuildPostgresDDL(bp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
rec := &meta.AppRecord{
|
||||
AppID: appID,
|
||||
TenantID: tenantID,
|
||||
Slug: slug,
|
||||
Name: bp.Meta.Name,
|
||||
SchemaName: schemaName,
|
||||
DatabaseName: dbName,
|
||||
Engine: bp.Storage.Engine,
|
||||
Status: meta.StatusProvisioning,
|
||||
Blueprint: bp,
|
||||
DDL: ddl,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
if err := l.svcCtx.Meta.Save(l.ctx, rec); err != nil {
|
||||
return nil, fmt.Errorf("meta save: %w", err)
|
||||
}
|
||||
|
||||
runner := l.svcCtx.Schema
|
||||
var appDB *sql.DB
|
||||
if dbName != "" {
|
||||
if err := runner.EnsureDatabase(l.ctx, dbName); err != nil {
|
||||
rec.Status = meta.StatusFailed
|
||||
rec.Error = err.Error()
|
||||
_ = l.svcCtx.Meta.Save(l.ctx, rec)
|
||||
_ = l.audit("publish.failed", audit.DetailJSON(map[string]any{"slug": slug, "error": err.Error()}))
|
||||
return nil, fmt.Errorf("ensure database: %w", err)
|
||||
}
|
||||
if l.svcCtx.Config.DataSource != "" {
|
||||
dsn, err := schema.DSNForDatabase(l.svcCtx.Config.DataSource, dbName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
appDB, err = sql.Open("postgres", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer appDB.Close()
|
||||
runner = &schema.PostgresRunner{DB: appDB}
|
||||
if l.svcCtx.DBPool != nil {
|
||||
_, _ = l.svcCtx.DBPool.ForApp(rec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := runner.ExecDDL(l.ctx, ddl); err != nil {
|
||||
rec.Status = meta.StatusFailed
|
||||
rec.Error = err.Error()
|
||||
rec.UpdatedAt = time.Now().UTC()
|
||||
_ = l.svcCtx.Meta.Save(l.ctx, rec)
|
||||
_ = l.audit("publish.failed", audit.DetailJSON(map[string]any{"slug": slug, "error": err.Error()}))
|
||||
return nil, fmt.Errorf("provision failed: %w", err)
|
||||
}
|
||||
|
||||
endpoints := buildEndpoints(bp)
|
||||
rec.Status = meta.StatusPublished
|
||||
rec.Endpoints = endpoints
|
||||
rec.Error = ""
|
||||
rec.UpdatedAt = time.Now().UTC()
|
||||
if err := l.svcCtx.Meta.Save(l.ctx, rec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
action := "publish.success"
|
||||
if republish {
|
||||
action = "publish.republish"
|
||||
}
|
||||
detail := map[string]any{
|
||||
"slug": slug, "schema": schemaName, "database": dbName, "user_id": userID,
|
||||
"republish": republish, "publish_mode": publishMode,
|
||||
}
|
||||
if mergeRes != nil {
|
||||
detail["added_pages"] = mergeRes.AddedPages
|
||||
detail["added_entities"] = mergeRes.AddedEntities
|
||||
}
|
||||
_ = l.audit(action, audit.DetailJSON(detail))
|
||||
|
||||
// 智能体新建发布:自动把该 slug 写入可访问模块,后续不必再去控制台授权
|
||||
if authx.Role(l.ctx) == authx.RoleAgent && l.svcCtx.Agents != nil && slug != "" {
|
||||
if aid := authx.AgentID(l.ctx); aid > 0 {
|
||||
_ = l.svcCtx.Agents.GrantAppSlug(l.ctx, aid, slug)
|
||||
}
|
||||
}
|
||||
|
||||
ownerID := authx.AgentID(l.ctx)
|
||||
if ownerID <= 0 {
|
||||
ownerID = userID
|
||||
}
|
||||
secret := l.svcCtx.Config.Agent.CapsuleSecret
|
||||
if secret == "" {
|
||||
secret = l.svcCtx.JWT.AccessSecret
|
||||
}
|
||||
accessPath := ""
|
||||
accessURL := ""
|
||||
if secret != "" && ownerID > 0 {
|
||||
_, filePath, err := agentcap.SealModulePath(secret, tenantID, ownerID, slug)
|
||||
if err == nil {
|
||||
accessPath = filePath
|
||||
base := strings.TrimRight(l.svcCtx.Config.PublicBaseURL, "/")
|
||||
if req.HostMeta != nil && strings.TrimSpace(req.HostMeta.HostBaseURL) != "" {
|
||||
accessURL = strings.TrimRight(strings.TrimSpace(req.HostMeta.HostBaseURL), "/")
|
||||
} else if base != "" {
|
||||
accessURL = base + "/api/v1/public/" + filePath + "/blueprint"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
moduleName := bp.Meta.Name
|
||||
publishStyle := ""
|
||||
if req.HostMeta != nil {
|
||||
if n := strings.TrimSpace(req.HostMeta.ModuleName); n != "" {
|
||||
moduleName = n
|
||||
}
|
||||
publishStyle = strings.TrimSpace(req.HostMeta.PublishStyle)
|
||||
}
|
||||
if publishStyle == "" {
|
||||
publishStyle = "immediate"
|
||||
}
|
||||
|
||||
resp := &types.PublishResp{
|
||||
AppID: rec.AppID,
|
||||
Slug: slug,
|
||||
SchemaName: schemaName,
|
||||
DatabaseName: dbName,
|
||||
Status: string(meta.StatusPublished),
|
||||
Endpoints: endpoints,
|
||||
DDL: ddl,
|
||||
MemoryMode: l.svcCtx.MemoryMode,
|
||||
PublishMode: publishMode,
|
||||
ModuleName: moduleName,
|
||||
PublishStyle: publishStyle,
|
||||
AccessPath: accessPath,
|
||||
AccessURL: accessURL,
|
||||
PublishedAt: rec.UpdatedAt.UTC().Format(time.RFC3339),
|
||||
OwnerID: ownerID,
|
||||
}
|
||||
if mergeRes != nil {
|
||||
resp.AddedPages = mergeRes.AddedPages
|
||||
resp.AddedEntities = mergeRes.AddedEntities
|
||||
resp.AddedResources = mergeRes.AddedResources
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (l *PublishLogic) audit(action, detail string) error {
|
||||
if l.svcCtx.Audit == nil {
|
||||
return nil
|
||||
}
|
||||
return l.svcCtx.Audit.Log(l.ctx, authx.TenantID(l.ctx), authx.UserID(l.ctx), action, detail)
|
||||
}
|
||||
|
||||
func buildEndpoints(bp *blueprint.Blueprint) []string {
|
||||
base := strings.TrimRight(bp.Apis.BasePath, "/")
|
||||
if base == "" {
|
||||
base = "/api/v1/apps/" + bp.Meta.Slug
|
||||
}
|
||||
out := []string{"GET " + base + "/blueprint"}
|
||||
for _, r := range bp.Apis.Resources {
|
||||
path := r.Path
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
full := base + path
|
||||
for _, op := range r.Operations {
|
||||
switch op {
|
||||
case "list":
|
||||
out = append(out, "GET "+full)
|
||||
case "create":
|
||||
out = append(out, "POST "+full)
|
||||
case "get":
|
||||
out = append(out, "GET "+full+"/{id}")
|
||||
case "update":
|
||||
out = append(out, "PUT "+full+"/{id}")
|
||||
case "delete":
|
||||
out = append(out, "DELETE "+full+"/{id}")
|
||||
case "import":
|
||||
out = append(out, "POST "+full+"/import")
|
||||
case "export":
|
||||
out = append(out, "GET "+full+"/export")
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user