Files
ai_site/platform/internal/logic/applogic/publish.go
whm cb56e6847e feat: add Z12/Z13 bind APIs, stock import, and sync docs
Enable auto default sync channels on agent activate, bind-code/phone confirm flows, publish ALTER, and align admin/yuheng docs with the production bind path.
2026-08-05 11:47:20 +08:00

508 lines
14 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/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,
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
}
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"
}
}
bp.EnsureDefaultImportExport()
if err := bp.Validate(slug); err != nil {
return nil, err
}
schemaName := bp.AssignSchemaName(tenantID)
dbName := bp.AssignDatabaseName(tenantID)
// Z8c智能体若绑定了 database_name新建模块优先落到该库database_per_app
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 != "" {
if !exists || existing.DatabaseName == "" {
bp.Storage.Mode = "database_per_app"
schemaName = bp.AssignSchemaName(tenantID)
dbName = dn
}
}
}
}
}
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)
}
}
}
// 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,
}
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
}