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 }