Files
ai_site/platform/internal/logic/applogic/publish.go
whm 154f849f57 fix: close remaining Z39 slug isolation gaps
Bind preview rows and published API base paths to the active module slug so cloned or stale state cannot address another module.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 17:34:15 +08:00

689 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package applogic
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
"aijianzhan/platform/internal/agentcap"
"aijianzhan/platform/internal/agentstore"
"aijianzhan/platform/internal/audit"
"aijianzhan/platform/internal/authx"
"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,
DatabaseName: it.DatabaseName,
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.EnsureDefaultImportExport()
// 草稿允许尚未完全合法;尽量校验,失败则仍以宽松方式保存关键字段
_ = 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
}
// Z39API 地址只由本次发布路径 slug 决定,禁止克隆蓝图沿用旧模块 base_path。
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 {
if isNothingNewToPublish(err) {
if needsSchemaIsolation(existing, authx.Role(l.ctx)) {
publishMode = "storage_isolated"
mergeRes = nil
} else {
return l.handleNoBlueprintDelta(existing, req, slug, tenantID, userID)
}
} else {
return nil, err
}
} else {
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 {
if isNothingNewToPublish(err) {
if needsSchemaIsolation(existing, authx.Role(l.ctx)) {
publishMode = "storage_isolated"
mergeRes = nil
} else {
return l.handleNoBlueprintDelta(existing, req, slug, tenantID, userID)
}
} else {
return nil, err
}
} else {
publishMode = "pages_added"
}
} else {
bp = incoming
publishMode = "created"
}
}
// Z36有蓝图增量时也把 host_meta.module_name 落到蓝图/模块显示名
if req.HostMeta != nil {
if n := strings.TrimSpace(req.HostMeta.ModuleName); n != "" {
bp.Meta.Name = n
}
}
bp.EnsureDefaultImportExport()
if err := bp.Validate(slug); err != nil {
return nil, err
}
schemaName := bp.AssignSchemaName(tenantID)
dbName := bp.AssignDatabaseName(tenantID)
boundAgentDatabase := false
// Z8c/Z39智能体绑定库可由多个模块共享因此库内仍须按 tenant+slug 分 Schema
// 不能把所有模块都放进 public 并按同名 entity table 复用。
// Z35中文昵称等非法名不得用于 EnsureDatabase回落 AssignDatabaseName
if authx.Role(l.ctx) == authx.RoleAgent && l.svcCtx.Agents != nil {
if aid := authx.AgentID(l.ctx); aid > 0 {
if acc, err := l.svcCtx.Agents.Get(l.ctx, tenantID, aid); err == nil && acc != nil {
if dn := strings.TrimSpace(acc.DatabaseName); dn != "" && schema.ValidDBName(dn) {
bp.Storage.Mode = "schema_per_app"
schemaName = bp.AssignSchemaName(tenantID)
dbName = dn
if exists && existing.DatabaseName != "" {
dbName = existing.DatabaseName
}
boundAgentDatabase = true
} else if dn != "" && !schema.ValidDBName(dn) {
// 历史脏数据(如 DisplayName 当库名):纠正智能体落点库名,避免反复 400
safeName := schema.SanitizeDBName(tenantID, slug)
_, _ = l.svcCtx.Agents.Update(l.ctx, tenantID, aid, agentstore.UpdateInput{
DatabaseName: &safeName,
})
bp.Storage.Mode = "schema_per_app"
schemaName = bp.AssignSchemaName(tenantID)
dbName = safeName
boundAgentDatabase = true
} else if exists && existing.DatabaseName != "" {
// 已发布模块仍位于智能体绑定库;重发时一并升级旧 public Schema。
bp.Storage.Mode = "schema_per_app"
schemaName = bp.AssignSchemaName(tenantID)
dbName = existing.DatabaseName
boundAgentDatabase = true
}
}
}
}
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
// Z39旧版把智能体同库下的所有模块放入 public导致同名资源串表。
// 重发时切到独立 app_t{tenant}_{slug};数据由既有双向同步重新灌入。
if existing.SchemaName != "" && !(boundAgentDatabase && existing.SchemaName == "public") {
schemaName = existing.SchemaName
bp.Storage.SchemaName = existing.SchemaName
}
if existing.DatabaseName != "" && schema.ValidDBName(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)
}
}
}
// Z11a蓝图新增字段 → ALTER TABLE ADD COLUMN IF NOT EXISTS
inspectDB := appDB
if inspectDB == nil {
inspectDB = l.svcCtx.DB
}
if inspectDB != nil {
alter, err := schema.BuildPostgresAlterDDL(l.ctx, inspectDB, bp)
if err != nil {
rec.Status = meta.StatusFailed
rec.Error = err.Error()
_ = l.svcCtx.Meta.Save(l.ctx, rec)
return nil, fmt.Errorf("build alter ddl: %w", err)
}
if len(alter) > 0 {
ddl = schema.MergeDDL(ddl, alter)
rec.DDL = ddl
_ = l.svcCtx.Meta.Save(l.ctx, 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,
"blueprint_revision": bp.Revision(), "resource_tables": resourceTables(bp),
}
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,
BlueprintRevision: bp.Revision(),
ResourceTables: resourceTables(bp),
}
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 isNothingNewToPublish(err error) bool {
return err != nil && strings.Contains(err.Error(), "nothing new to publish")
}
func needsSchemaIsolation(existing *meta.AppRecord, role string) bool {
return role == authx.RoleAgent &&
existing != nil &&
existing.DatabaseName != "" &&
existing.SchemaName == "public"
}
// handleNoBlueprintDelta Z36merge 无增量时,有 module_name 则仅更新 host_meta否则中文业务错误。
func (l *PublishLogic) handleNoBlueprintDelta(
existing *meta.AppRecord,
req *types.PublishReq,
slug string,
tenantID, userID int64,
) (*types.PublishResp, error) {
name := ""
if req.HostMeta != nil {
name = strings.TrimSpace(req.HostMeta.ModuleName)
}
if name == "" {
return nil, fmt.Errorf("没有可发布的蓝图增量(页面/实体/接口未变化)。若仅修改模块显示名,请在 host_meta.module_name 传入新名称后重试 [NO_BLUEPRINT_DELTA]")
}
return l.publishHostMetaOnly(existing, req, slug, tenantID, userID, name)
}
// publishHostMetaOnly 无蓝图增量时只更新模块显示名并重签胶囊,不跑 DDL。
func (l *PublishLogic) publishHostMetaOnly(
existing *meta.AppRecord,
req *types.PublishReq,
slug string,
tenantID, userID int64,
moduleName string,
) (*types.PublishResp, error) {
if existing == nil || existing.Blueprint == nil {
return nil, fmt.Errorf("existing app has no blueprint")
}
bp := existing.Blueprint
bp.Meta.Name = moduleName
bp.Meta.Slug = slug
bp.Apis.BasePath = "/api/v1/apps/" + slug
rec := existing
rec.Name = moduleName
rec.Blueprint = bp
rec.UpdatedAt = time.Now().UTC()
if rec.Status == "" {
rec.Status = meta.StatusPublished
}
if err := l.svcCtx.Meta.Save(l.ctx, rec); err != nil {
return nil, fmt.Errorf("meta save: %w", err)
}
_ = l.audit("publish.host_meta", audit.DetailJSON(map[string]any{
"slug": slug, "module_name": moduleName, "user_id": userID, "publish_mode": "host_meta_updated",
}))
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"
}
}
}
publishStyle := ""
if req.HostMeta != nil {
publishStyle = strings.TrimSpace(req.HostMeta.PublishStyle)
}
if publishStyle == "" {
publishStyle = "immediate"
}
endpoints := rec.Endpoints
if len(endpoints) == 0 {
endpoints = buildEndpoints(bp)
}
return &types.PublishResp{
AppID: rec.AppID,
Slug: slug,
SchemaName: rec.SchemaName,
DatabaseName: rec.DatabaseName,
Status: string(rec.Status),
Endpoints: endpoints,
MemoryMode: l.svcCtx.MemoryMode,
PublishMode: "host_meta_updated",
ModuleName: moduleName,
PublishStyle: publishStyle,
AccessPath: accessPath,
AccessURL: accessURL,
PublishedAt: rec.UpdatedAt.UTC().Format(time.RFC3339),
OwnerID: ownerID,
BlueprintRevision: bp.Revision(),
ResourceTables: resourceTables(bp),
}, nil
}
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
}
func resourceTables(bp *blueprint.Blueprint) map[string]string {
out := make(map[string]string, len(bp.Apis.Resources))
entities := make(map[string]string, len(bp.Entities))
for _, entity := range bp.Entities {
entities[entity.Name] = entity.Table
}
for _, resource := range bp.Apis.Resources {
path := strings.TrimPrefix(resource.Path, "/")
if table := entities[resource.Entity]; path != "" && table != "" {
out[path] = bp.Storage.SchemaName + "." + table
}
}
return out
}